Hosting your own Git server gives you complete control over your source code: no third-party access, no storage limits, no per-user pricing, and full ownership of your data. Whether you need a simple bare repository accessible over SSH or a full-featured web interface with pull requests and issue tracking, an Ubuntu VPS can handle it all.

This guide covers the full spectrum of self-hosted Git: setting up bare repositories with SSH access, managing multi-user access control, deploying Gitea as a lightweight GitHub alternative (both natively and with Docker), configuring webhooks for CI/CD automation, backing up your repositories, and comparing self-hosted Git with cloud platforms like GitHub and GitLab.

Prerequisites

Before starting, you need:

MassiveGRID Ubuntu VPS — Ubuntu 24.04 LTS pre-installed, Proxmox HA cluster with automatic failover, Ceph 3x replicated NVMe storage, independent CPU/RAM/storage scaling, 12 Tbps DDoS protection, 4 global datacenter locations, 100% uptime SLA, and 24/7 human support rated 9.5/10. Deploy a Cloud VPS from $1.99/mo.

Installing Git on the Server

Git is usually pre-installed on Ubuntu 24.04. Verify and install if needed:

git --version
# git version 2.43.0

If Git is not installed:

sudo apt update
sudo apt install -y git

Setting Up Bare Git Repositories

A bare Git repository contains only the version history (the contents of the .git directory) without a working tree. This is the standard format for server-side repositories that developers push to and pull from.

Creating a Dedicated Git User

Create a system user specifically for Git operations. This user will own all repositories and handle SSH connections:

sudo adduser --system --shell /usr/bin/git-shell --group --disabled-password --home /home/git git

The git-shell is a restricted shell that only allows Git operations — the git user cannot open an interactive terminal session, which is a security benefit.

Create the SSH directory for the git user:

sudo mkdir -p /home/git/.ssh
sudo touch /home/git/.ssh/authorized_keys
sudo chmod 700 /home/git/.ssh
sudo chmod 600 /home/git/.ssh/authorized_keys
sudo chown -R git:git /home/git/.ssh

Adding Developer SSH Keys

For each developer who needs access, add their public SSH key to the git user's authorized_keys file:

# On the developer's local machine, copy their public key
cat ~/.ssh/id_ed25519.pub

On the server, append the key:

sudo nano /home/git/.ssh/authorized_keys

Add one public key per line. Each developer can then clone and push via SSH using the git user.

Creating a Bare Repository

Create a directory to hold all repositories:

sudo mkdir -p /home/git/repositories
sudo chown git:git /home/git/repositories

Create a new bare repository:

sudo -u git git init --bare /home/git/repositories/myproject.git

This creates the repository structure:

ls /home/git/repositories/myproject.git/
# HEAD  branches  config  description  hooks  info  objects  refs

Cloning and Pushing from a Local Machine

From a developer's local machine:

# Clone the empty repository
git clone git@your-server-ip:/home/git/repositories/myproject.git

# Or with a custom SSH port
git clone ssh://git@your-server-ip:2222/home/git/repositories/myproject.git

For a new project, initialize locally and push to the server:

mkdir myproject && cd myproject
git init
echo "# My Project" > README.md
git add .
git commit -m "Initial commit"
git remote add origin git@your-server-ip:/home/git/repositories/myproject.git
git push -u origin main

Repository Naming Convention

By convention, bare repositories end with .git. This is not required but makes it immediately clear that a directory is a bare repository:

/home/git/repositories/
├── webapp.git
├── api-service.git
├── mobile-app.git
├── infrastructure.git
└── docs.git

SSH Access Control

Per-Repository Access with SSH Keys

For fine-grained access control without additional software, you can restrict SSH keys to specific repositories using the command option in authorized_keys:

# In /home/git/.ssh/authorized_keys
# Developer 1 — full access to all repos
ssh-ed25519 AAAAC3... developer1@laptop

# Developer 2 — restricted to webapp.git only
command="git-shell -c \"$(echo $SSH_ORIGINAL_COMMAND | grep -E '^git-(upload-pack|receive-pack) .*/webapp.git$' || echo false)\"",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAAC3... developer2@laptop

This approach works but becomes unwieldy with many developers and repositories. For team access management, use Gitea (covered later in this guide).

Group-Based Access with Unix Groups

A simpler multi-project setup uses Unix groups to control repository access:

# Create groups for each project
sudo groupadd project-webapp
sudo groupadd project-api

# Add developers to groups
sudo usermod -aG project-webapp developer1
sudo usermod -aG project-webapp developer2
sudo usermod -aG project-api developer1

# Set repository group ownership
sudo chgrp -R project-webapp /home/git/repositories/webapp.git
sudo chgrp -R project-api /home/git/repositories/api-service.git

# Set group write permissions
sudo chmod -R g+rwX /home/git/repositories/webapp.git
sudo chmod -R g+rwX /home/git/repositories/api-service.git

# Enable shared repository mode
cd /home/git/repositories/webapp.git
sudo -u git git config core.sharedRepository group

This requires each developer to have a system user account on the server, which is more overhead than the single git user approach but provides proper access isolation.

Server-Side Git Hooks

Git hooks are scripts that run automatically when certain events occur. Server-side hooks are powerful for enforcing policies and triggering automation.

Available Server-Side Hooks

Hook When It Runs Use Case
pre-receive Before accepting any pushed refs Reject pushes that don't meet policy
update Before updating each ref Per-branch policy enforcement
post-receive After all refs are updated Trigger deployments, notifications
post-update After refs are updated Update server info for dumb HTTP

Post-Receive Hook for Auto-Deployment

Create a hook that deploys your application whenever code is pushed to the main branch:

sudo nano /home/git/repositories/webapp.git/hooks/post-receive
#!/bin/bash
DEPLOY_DIR="/var/www/webapp"
BRANCH="main"

while read oldrev newrev refname; do
    if [ "$refname" = "refs/heads/$BRANCH" ]; then
        echo "==> Deploying $BRANCH to $DEPLOY_DIR..."

        # Checkout the latest code
        git --work-tree="$DEPLOY_DIR" --git-dir="/home/git/repositories/webapp.git" checkout -f "$BRANCH"

        # Run post-deploy commands
        cd "$DEPLOY_DIR"

        if [ -f "package.json" ]; then
            echo "==> Installing dependencies..."
            npm install --production
        fi

        if [ -f "docker-compose.yml" ]; then
            echo "==> Restarting Docker services..."
            docker compose up -d --build
        fi

        echo "==> Deployment complete!"
    fi
done
sudo chmod +x /home/git/repositories/webapp.git/hooks/post-receive
sudo chown git:git /home/git/repositories/webapp.git/hooks/post-receive

Make sure the deploy directory exists and is writable:

sudo mkdir -p /var/www/webapp
sudo chown git:git /var/www/webapp

Now every git push to the main branch triggers an automatic deployment.

Pre-Receive Hook for Policy Enforcement

Reject pushes that contain files larger than 10 MB:

sudo nano /home/git/repositories/webapp.git/hooks/pre-receive
#!/bin/bash
MAX_FILE_SIZE=10485760  # 10 MB in bytes

while read oldrev newrev refname; do
    # Skip delete operations
    if [ "$newrev" = "0000000000000000000000000000000000000000" ]; then
        continue
    fi

    # Check each new file
    git diff --name-only "$oldrev" "$newrev" 2>/dev/null | while read file; do
        size=$(git cat-file -s "$newrev:$file" 2>/dev/null)
        if [ -n "$size" ] && [ "$size" -gt "$MAX_FILE_SIZE" ]; then
            echo "ERROR: File '$file' is $(($size / 1048576)) MB, exceeding the 10 MB limit."
            echo "Use Git LFS for large files."
            exit 1
        fi
    done

    if [ $? -ne 0 ]; then
        exit 1
    fi
done
sudo chmod +x /home/git/repositories/webapp.git/hooks/pre-receive
sudo chown git:git /home/git/repositories/webapp.git/hooks/pre-receive

Gitea: A Self-Hosted Git Web Interface

Bare repositories with SSH access work well for small teams, but as your team grows, you'll want a web interface with pull requests, issue tracking, code review, and user management. Gitea is a lightweight, self-hosted Git service written in Go. It's fast, has minimal resource requirements, and provides a familiar GitHub-like interface.

Why Gitea

Installing Gitea with Docker

Docker is the recommended installation method for Gitea — it simplifies updates, isolates the application, and makes backups straightforward. If you haven't installed Docker yet, follow our Docker installation guide.

Create the Gitea directory:

sudo mkdir -p /opt/gitea

Create the Docker Compose file:

sudo nano /opt/gitea/docker-compose.yml
services:
  gitea:
    image: gitea/gitea:1.22
    container_name: gitea
    restart: unless-stopped
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=gitea-db:5432
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=your-db-password
      - GITEA__server__ROOT_URL=https://git.yourdomain.com/
      - GITEA__server__SSH_DOMAIN=git.yourdomain.com
      - GITEA__server__SSH_PORT=2222
      - GITEA__server__SSH_LISTEN_PORT=22
      - GITEA__service__DISABLE_REGISTRATION=false
      - GITEA__mailer__ENABLED=false
    volumes:
      - gitea_data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "2222:22"
    depends_on:
      gitea-db:
        condition: service_healthy
    networks:
      - gitea-internal

  gitea-db:
    image: postgres:16-alpine
    container_name: gitea-db
    restart: unless-stopped
    environment:
      - POSTGRES_USER=gitea
      - POSTGRES_PASSWORD=your-db-password
      - POSTGRES_DB=gitea
    volumes:
      - gitea_db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gitea"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - gitea-internal

volumes:
  gitea_data:
  gitea_db_data:

networks:
  gitea-internal:

Start Gitea:

cd /opt/gitea
docker compose up -d

Check the logs to confirm startup:

docker compose logs -f gitea

Initial Setup

Open http://your-server-ip:3000 in your browser. Gitea's initial configuration page will appear. Most settings are pre-filled from the environment variables. Review the settings and click "Install Gitea."

After installation, register your admin account. This is the first user account and will automatically receive admin privileges.

Setting Up a Reverse Proxy

For production use, put Gitea behind a reverse proxy with SSL. If you're using Traefik (see our Traefik Docker guide), add labels to the Gitea container:

    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.gitea.rule=Host(`git.yourdomain.com`)"
      - "traefik.http.routers.gitea.entrypoints=websecure"
      - "traefik.http.routers.gitea.tls.certresolver=letsencrypt"
      - "traefik.http.services.gitea.loadbalancer.server.port=3000"
    networks:
      - web
      - gitea-internal

And add the web network as external:

networks:
  web:
    external: true
  gitea-internal:

If you're using Nginx instead, create a server block. See our Nginx reverse proxy guide for the full setup, and our Let's Encrypt SSL guide for certificate provisioning:

sudo nano /etc/nginx/sites-available/gitea
server {
    listen 80;
    listen [::]:80;
    server_name git.yourdomain.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name git.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/git.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/git.yourdomain.com/privkey.pem;

    client_max_body_size 100M;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
sudo ln -s /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Configuring Gitea

Disabling Public Registration

For private teams, disable public registration after creating your initial accounts. Set the environment variable:

- GITEA__service__DISABLE_REGISTRATION=true

Or edit the configuration file inside the container at /data/gitea/conf/app.ini:

[service]
DISABLE_REGISTRATION = true

Restart Gitea after changes:

cd /opt/gitea
docker compose restart gitea

SSH Configuration

The Docker Compose file maps port 2222 on the host to port 22 inside the container. Developers clone repositories using this port:

git clone ssh://git@git.yourdomain.com:2222/username/repo.git

To use the standard SSH port (22), either change the host mapping or run Gitea's SSH on port 22 and move the system SSH to a different port (as recommended in our security hardening guide).

Developers should add their SSH keys through the Gitea web interface: Settings > SSH / GPG Keys > Add Key.

Email Notifications

Enable email for notifications and password resets:

- GITEA__mailer__ENABLED=true
- GITEA__mailer__PROTOCOL=smtps
- GITEA__mailer__SMTP_ADDR=smtp.yourdomain.com
- GITEA__mailer__SMTP_PORT=465
- GITEA__mailer__FROM=gitea@yourdomain.com
- GITEA__mailer__USER=gitea@yourdomain.com
- GITEA__mailer__PASSWD=your-smtp-password

Repository Mirroring

Gitea can mirror repositories from GitHub, GitLab, or other Git servers. This is useful for maintaining a local backup of important external repositories:

  1. In Gitea, click "New Migration" from the + menu
  2. Select the source platform (GitHub, GitLab, etc.)
  3. Enter the repository URL and authentication if needed
  4. Check "This repository will be a mirror" to enable periodic syncing

Mirror sync happens automatically on a configurable interval (default: 8 hours).

Setting Up Webhooks for CI/CD

Webhooks trigger HTTP requests when events occur in a repository (push, pull request, issue, etc.). This is the foundation for CI/CD pipelines.

Configuring a Webhook in Gitea

  1. Navigate to your repository > Settings > Webhooks > Add Webhook
  2. Select the webhook type (Gitea, Slack, Discord, Telegram, or custom)
  3. Enter the target URL (your CI/CD server's webhook endpoint)
  4. Select which events trigger the webhook
  5. Set a secret for payload verification

Webhook Receiver Script

Create a simple webhook receiver that triggers deployment on push:

sudo nano /usr/local/bin/webhook-deploy.sh
#!/bin/bash
set -euo pipefail

DEPLOY_DIR="/var/www/webapp"
REPO_URL="ssh://git@localhost:2222/myuser/webapp.git"
BRANCH="main"
LOGFILE="/var/log/webhook-deploy.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOGFILE"
}

log "Webhook received. Starting deployment..."

cd "$DEPLOY_DIR"
git fetch origin
git reset --hard "origin/$BRANCH"

if [ -f "package.json" ]; then
    log "Installing dependencies..."
    npm install --production >> "$LOGFILE" 2>&1
fi

if [ -f "docker-compose.yml" ]; then
    log "Restarting services..."
    docker compose up -d --build >> "$LOGFILE" 2>&1
fi

log "Deployment complete."
sudo chmod +x /usr/local/bin/webhook-deploy.sh

For a lightweight webhook server, use webhook:

sudo apt install -y webhook

Create the webhook configuration:

sudo nano /etc/webhook.conf
[
  {
    "id": "deploy-webapp",
    "execute-command": "/usr/local/bin/webhook-deploy.sh",
    "command-working-directory": "/var/www/webapp",
    "trigger-rule": {
      "and": [
        {
          "match": {
            "type": "value",
            "value": "your-webhook-secret",
            "parameter": {
              "source": "header",
              "name": "X-Gitea-Signature"
            }
          }
        }
      ]
    }
  }
]

Start the webhook server:

webhook -hooks /etc/webhook.conf -port 9000 -verbose

The webhook endpoint is now available at http://your-server-ip:9000/hooks/deploy-webapp. Point your Gitea webhook to this URL.

Gitea Actions (CI/CD)

Gitea 1.19+ includes Gitea Actions, a CI/CD system compatible with GitHub Actions workflows. Enable it by adding to the Gitea environment:

- GITEA__actions__ENABLED=true

You'll also need to deploy a Gitea Actions runner. Create a runner Docker Compose file:

sudo mkdir -p /opt/gitea-runner
sudo nano /opt/gitea-runner/docker-compose.yml
services:
  runner:
    image: gitea/act_runner:latest
    container_name: gitea-runner
    restart: unless-stopped
    environment:
      - GITEA_INSTANCE_URL=https://git.yourdomain.com
      - GITEA_RUNNER_REGISTRATION_TOKEN=your-registration-token
      - GITEA_RUNNER_NAME=ubuntu-runner
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - runner_data:/data
    networks:
      - gitea-internal

volumes:
  runner_data:

networks:
  gitea-internal:
    external: true
    name: gitea_gitea-internal

Get the registration token from Gitea: Site Administration > Runners > Create new Runner.

cd /opt/gitea-runner
docker compose up -d

Now you can add workflow files to your repositories in .gitea/workflows/ using GitHub Actions-compatible YAML syntax:

# .gitea/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: npm install
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: ./deploy.sh

Backup Strategies for Git Repositories

Your Git repositories contain your team's source code and history. Back them up regularly and verify the backups work.

Backing Up Bare Repositories

Bare repositories are just directories. A simple file copy works:

sudo nano /usr/local/bin/backup-git.sh
#!/bin/bash
set -euo pipefail

REPO_DIR="/home/git/repositories"
BACKUP_DIR="/var/backups/git"
DATE=$(date +%Y-%m-%d)
RETENTION_DAYS=30

mkdir -p "$BACKUP_DIR"

# Create compressed archive of all repositories
tar czf "$BACKUP_DIR/git-repos-${DATE}.tar.gz" -C "$REPO_DIR" .

# Remove old backups
find "$BACKUP_DIR" -name "git-repos-*.tar.gz" -mtime +$RETENTION_DAYS -delete

echo "Git backup completed: git-repos-${DATE}.tar.gz"
sudo chmod +x /usr/local/bin/backup-git.sh

Schedule daily backups with cron (see our cron jobs guide):

0 1 * * * /usr/local/bin/backup-git.sh >> /var/log/git-backup.log 2>&1

Backing Up Gitea

Gitea includes a built-in backup command that exports repositories, database, configuration, and attachments:

# Run Gitea's built-in backup
docker exec -u git gitea gitea dump -c /data/gitea/conf/app.ini --file /data/gitea-backup.zip

Copy the backup file out of the container:

docker cp gitea:/data/gitea-backup.zip /var/backups/gitea/gitea-backup-$(date +%Y%m%d).zip

Alternatively, back up the Docker volumes directly:

sudo nano /usr/local/bin/backup-gitea.sh
#!/bin/bash
set -euo pipefail

BACKUP_DIR="/var/backups/gitea"
DATE=$(date +%Y-%m-%d)
RETENTION_DAYS=30

mkdir -p "$BACKUP_DIR"

# Stop Gitea briefly for consistent backup
cd /opt/gitea
docker compose stop gitea

# Backup Gitea data volume
docker run --rm \
  -v gitea_gitea_data:/data \
  -v "$BACKUP_DIR":/backup \
  alpine tar czf "/backup/gitea-data-${DATE}.tar.gz" -C /data .

# Backup PostgreSQL
docker compose exec -T gitea-db pg_dump -U gitea gitea | gzip > "$BACKUP_DIR/gitea-db-${DATE}.sql.gz"

# Restart Gitea
docker compose start gitea

# Remove old backups
find "$BACKUP_DIR" -name "gitea-*" -mtime +$RETENTION_DAYS -delete

echo "Gitea backup completed."
sudo chmod +x /usr/local/bin/backup-gitea.sh

Offsite Backup with rsync

For disaster recovery, copy backups to a separate location:

# Sync backups to offsite storage
rsync -avz --delete /var/backups/git/ backup-user@offsite-server:/backups/git/
rsync -avz --delete /var/backups/gitea/ backup-user@offsite-server:/backups/gitea/

For a comprehensive backup strategy including encryption and cloud storage, see our Ubuntu VPS automatic backups guide.

Verifying Backups

A backup you haven't tested is not a backup. Periodically verify your Git backups by restoring them to a temporary location:

# Verify bare repository backup
mkdir /tmp/git-restore-test
tar xzf /var/backups/git/git-repos-2026-02-27.tar.gz -C /tmp/git-restore-test

# Verify each repository is valid
for repo in /tmp/git-restore-test/*.git; do
    echo "Verifying $repo..."
    git -C "$repo" fsck --full
done

# Clean up
rm -rf /tmp/git-restore-test

Comparing Self-Hosted vs GitHub/GitLab

Aspect Self-Hosted (Gitea) GitHub GitLab SaaS
Cost (10 users) VPS cost only (~$4-20/mo) Free (public) / $4/user/mo (Team) Free / $29/user/mo (Premium)
Data ownership Complete — your server, your data GitHub's servers (US) GitLab's servers (US/EU)
Storage limits Your disk size 500 MB - 50 GB per repo 5 GB - 50 GB per project
Compliance You control data residency Limited control EU data residency option
Uptime Depends on your infrastructure 99.9% SLA (Enterprise) 99.95% SLA (Premium)
CI/CD Gitea Actions or external GitHub Actions (generous free tier) GitLab CI (400 min/mo free)
Community/ecosystem Smaller, growing Largest developer community Strong enterprise ecosystem
Maintenance You handle updates and backups Fully managed Fully managed
Offline access Yes — runs on your network Requires internet Requires internet

When to Self-Host

When to Use GitHub/GitLab

Many teams use a hybrid approach: GitHub for open source and collaboration, self-hosted Gitea for proprietary code and sensitive projects.

Performance Tuning

For repositories with many files or large histories, tune Gitea and the underlying system:

Gitea Configuration

Edit the Gitea config in /data/gitea/conf/app.ini (inside the container):

[repository]
; Increase max file size for web editor
MAX_CREATION_LIMIT = -1

[git]
; Max number of files in a diff
MAX_GIT_DIFF_LINES = 1000
MAX_GIT_DIFF_LINE_CHARACTERS = 5000
MAX_GIT_DIFF_FILES = 100

[git.timeout]
DEFAULT = 360
MIGRATE = 600
MIRROR = 300
CLONE = 300
PULL = 300
GC = 60

Git Garbage Collection

Run periodic garbage collection on repositories to optimize storage and performance:

sudo nano /usr/local/bin/git-gc.sh
#!/bin/bash
REPO_DIR="/home/git/repositories"

for repo in "$REPO_DIR"/*.git; do
    echo "Running GC on $repo..."
    git -C "$repo" gc --aggressive --prune=now
done
sudo chmod +x /usr/local/bin/git-gc.sh

Schedule weekly GC runs:

0 4 * * 0 /usr/local/bin/git-gc.sh >> /var/log/git-gc.log 2>&1

For VPS performance optimization beyond Git, see our Ubuntu VPS performance guide.

Security Best Practices

Prefer Managed Infrastructure?

If managing a Git server, database, reverse proxy, SSL, backups, security updates, and monitoring is more infrastructure work than your team wants to take on, consider MassiveGRID's Managed Dedicated Cloud Servers. The managed service handles the entire infrastructure layer — operating system, security patches, backups, monitoring, and 24/7 incident response — while you focus on your code and your team. Every managed server runs on a Proxmox HA cluster with automatic failover and Ceph triple-replicated NVMe storage for enterprise-grade reliability.

What's Next