Gitea server with Git branch tree, Docker whale carrying containers, SSH key, SSL proxy, and CI/CD pipeline
← Back
[ self-hosting ]

How to Self-Host Gitea with Docker Compose

enim · Apr 30, 2026 · 10 min read · Updated: May 9, 2026

GitHub is free, reliable, and deeply integrated into every developer's workflow. So why would anyone self-host Gitea as an alternative? Control. Your code, your data, your rules. No surprise policy changes, no training AI models on your private repositories, no vendor lock-in. If Microsoft decides tomorrow that free private repos require a new plan, you are stuck. With Gitea running on your own VPS, you answer to no one.

This guide is part of our Self-Hosting Series — step-by-step guides for running your own services on a VPS.

Gitea is a lightweight, self-hosted Git service written in Go. It consumes around 200MB of RAM under normal usage — a fraction of what GitLab demands. It includes pull requests, issues, a package registry, and since version 1.19, Gitea Actions — a CI/CD system compatible with GitHub Actions workflows. For a personal or small-team setup, it is the best self-hosted Git option available.

In this guide, I will walk through a complete production-ready Gitea deployment: Docker Compose with PostgreSQL, reverse proxy configuration, SSH passthrough for Git operations, CI/CD with Gitea Actions, and migrating your existing repositories from GitHub.

Prerequisites

  • A Linux VPS (Ubuntu 22.04 or Debian 12) with at least 1 GB RAM and 20 GB storage
  • Docker Engine 24+ and Docker Compose v2 installed
  • A domain name pointing to your VPS (e.g., git.yourdomain.com)
  • A reverse proxy (Nginx Proxy Manager, Caddy, or Traefik) — I use Nginx Proxy Manager as described in my VPS setup guide
  • Basic familiarity with Docker and Git
  • SSH access to your server — see my Vaultwarden guide for managing credentials securely

If you need a VPS, I run all my self-hosted services on a Hetzner CPX22. It handles Ghost, Uptime Kuma, Nginx Proxy Manager, and Gitea without breaking a sweat.

Setting Up the Directory Structure

Create a dedicated directory for Gitea:

sudo mkdir -p /opt/gitea
cd /opt/gitea

I keep all my Docker services under /opt/ with one directory per service. This makes backups and management predictable.

Docker Compose Configuration

Create the Compose file:

# /opt/gitea/docker-compose.yml
services:
  gitea:
    image: gitea/gitea:1.22-rootless
    container_name: gitea
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=db:5432
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=${DB_PASSWORD}
      - GITEA__server__ROOT_URL=https://<YOUR_DOMAIN>/
      - GITEA__server__SSH_DOMAIN=<YOUR_DOMAIN>
      - GITEA__server__SSH_PORT=2222
      - GITEA__server__LFS_START_SERVER=true
      - GITEA__service__DISABLE_REGISTRATION=true
      - GITEA__mailer__ENABLED=false
      - GITEA__openid__ENABLE_OPENID_SIGNIN=false
    volumes:
      - gitea-data:/var/lib/gitea
      - gitea-config:/etc/gitea
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "2222:2222"
    networks:
      - gitea-net

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

volumes:
  gitea-data:
  gitea-config:
  postgres-data:

networks:
  gitea-net:
    driver: bridge

A few important decisions in this configuration:

  • Rootless image (gitea/gitea:1.22-rootless): The container runs as a non-root user. This is a security best practice I covered in my Docker security guide. If an attacker escapes the Gitea process, they land as an unprivileged user.
  • PostgreSQL over SQLite: SQLite works for personal use, but PostgreSQL handles concurrent connections better and makes backups more reliable. The overhead is minimal — PostgreSQL Alpine uses about 30MB of RAM.
  • Registration disabled: DISABLE_REGISTRATION=true prevents random people from creating accounts on your instance. You create accounts via the admin panel or CLI.
  • Health check on PostgreSQL: The depends_on with condition: service_healthy ensures Gitea does not start before the database is ready. Without this, Gitea may crash on first boot and require a manual restart.

Creating the Environment File

cat > /opt/gitea/.env << 'EOF'
DB_PASSWORD=<YOUR_SECURE_PASSWORD>
EOF
chmod 600 /opt/gitea/.env

Generate a strong password:

openssl rand -base64 32

Replace <YOUR_SECURE_PASSWORD> with the generated value. Replace <YOUR_DOMAIN> in the Compose file with your actual domain (e.g., git.byte-guard.net).

Note: Never commit .env files to version control. The chmod 600 ensures only the file owner can read it.

Starting Gitea

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

Check that both containers are running:

docker compose ps

Expected output:

NAME        SERVICE   STATUS
gitea       gitea     Up (healthy)
gitea-db    db        Up (healthy)

Gitea is now listening on port 3000 (HTTP) and port 2222 (SSH).

Reverse Proxy Configuration

You should never expose Gitea directly on port 3000. Put it behind a reverse proxy with TLS.

Nginx Proxy Manager

If you use Nginx Proxy Manager (as I do on all my projects):

  1. Add a new proxy host
  2. Domain: git.yourdomain.com
  3. Forward Hostname: gitea (or the server IP if NPM runs in a different Docker network)
  4. Forward Port: 3000
  5. SSL: Request a new Let's Encrypt certificate, force SSL

If NPM and Gitea are on different Docker networks, either add Gitea to NPM's network or use the host IP:

# Add to the gitea service in docker-compose.yml
networks:
  - gitea-net
  - npm-network

# Add at the bottom
networks:
  gitea-net:
    driver: bridge
  npm-network:
    external: true

Caddy (Alternative)

If you prefer Caddy as your reverse proxy:

git.yourdomain.com {
    reverse_proxy gitea:3000
}

Caddy handles TLS automatically — no certificate configuration needed.

Initial Setup

Open https://git.yourdomain.com in your browser. On first visit, Gitea shows an installation page. Most settings are already configured via environment variables, but verify:

  • Site Title: Your preferred name (e.g., "ByteGuard Git")
  • Admin Account: Create your admin user here — this is the only chance to do it through the web UI before registration is disabled

Click "Install Gitea." The page will redirect to the login screen.

Creating Additional Users via CLI

Since registration is disabled, add users through the Gitea CLI:

docker exec -it gitea gitea admin user create \
  --username developer \
  --password '<STRONG_PASSWORD>' \
  --email developer@yourdomain.com

SSH Passthrough for Git Operations

Git over HTTPS works immediately through the reverse proxy. But serious Git users prefer SSH for authentication — no password prompts, no token management, better performance for large pushes.

The Compose file already maps port 2222 from the container to the host. Configure your SSH client to use this port:

cat >> ~/.ssh/config << 'EOF'

Host git.yourdomain.com
    HostName git.yourdomain.com
    Port 2222
    User git
    IdentityFile ~/.ssh/id_ed25519
EOF

Add your public key to Gitea through the web UI: Settings → SSH / GPG Keys → Add Key.

Test the connection:

ssh -T git@git.yourdomain.com

Expected output:

Hi there, <username>! You've successfully authenticated, but Gitea does not provide shell access.

Now clone repositories using SSH:

git clone git@git.yourdomain.com:<username>/<repo>.git

Using Port 22 Instead of 2222

If you want standard SSH Git operations on port 22, you have two options:

Option A: Move your system SSH to a different port (e.g., 2222) and give port 22 to Gitea. Update your server's SSH config in /etc/ssh/sshd_config:

Port 2200

Then change the Gitea port mapping to "22:2222".

Option B (recommended): Use SSH passthrough. This is more complex but lets both system SSH and Gitea SSH coexist on port 22. It requires adding the git user on the host and configuring AuthorizedKeysCommand in sshd. The Gitea documentation covers this in detail — I recommend Option A for most self-hosters because it is simpler to maintain.

CI/CD with Gitea Actions

Gitea Actions (available since v1.19) is compatible with GitHub Actions workflow syntax. If you have existing GitHub Actions workflows, they work in Gitea with minimal changes.

Enabling Actions

Add the Actions configuration to your Gitea environment variables:

# Add to the gitea service environment in docker-compose.yml
- GITEA__actions__ENABLED=true

Restart Gitea:

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

Setting Up an Actions Runner

Gitea Actions requires a runner — a separate process that picks up jobs and executes them. This is identical to GitHub's self-hosted runner concept.

First, get a registration token from Gitea. Navigate to Site Administration → Runners and copy the registration token.

Add the runner to your Compose file:

  runner:
    image: gitea/act_runner:latest
    container_name: gitea-runner
    restart: unless-stopped
    depends_on:
      - gitea
    environment:
      - GITEA_INSTANCE_URL=http://gitea:3000
      - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
      - GITEA_RUNNER_NAME=local-runner
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - runner-data:/data
    networks:
      - gitea-net

Add runner-data to the volumes section and RUNNER_TOKEN to your .env file.

Note: The runner needs Docker socket access because it launches job containers. This is a security consideration — review my Docker security guide section on socket protection. For a personal instance, the risk is manageable. For a team, consider running the runner on a separate host.

Creating a Workflow

In any repository, create .gitea/workflows/ci.yml:

name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: |
          echo "Running tests..."
          # Replace with your actual test command
          make test

Push this file, and Gitea Actions will pick it up automatically. Check the Actions tab in your repository to see the workflow status.

Migrating Repositories from GitHub

Gitea has a built-in migration tool that imports repositories including issues, pull requests, labels, milestones, releases, and wiki pages.

Via the Web UI

  1. Click +New Migration
  2. Select GitHub as the source
  3. Enter the repository URL (e.g., https://github.com/youruser/yourrepo)
  4. For private repos, provide a GitHub Personal Access Token with repo scope
  5. Select what to migrate (issues, PRs, labels, etc.)
  6. Click Migrate Repository

Via the API (Bulk Migration)

For migrating many repositories, use the Gitea API:

#!/bin/bash
# migrate-repos.sh
GITEA_URL="https://git.yourdomain.com"
GITEA_TOKEN="<YOUR_GITEA_API_TOKEN>"
GITHUB_TOKEN="<YOUR_GITHUB_TOKEN>"
GITHUB_USER="<YOUR_GITHUB_USERNAME>"

# Get list of GitHub repos
repos=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
  "https://api.github.com/user/repos?per_page=100&type=owner" | \
  jq -r '.[].clone_url')

for repo_url in $repos; do
  repo_name=$(basename "$repo_url" .git)
  echo "Migrating $repo_name..."

  curl -s -X POST "$GITEA_URL/api/v1/repos/migrate" \
    -H "Authorization: token $GITEA_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{
      \"clone_addr\": \"$repo_url\",
      \"auth_token\": \"$GITHUB_TOKEN\",
      \"repo_name\": \"$repo_name\",
      \"repo_owner\": \"$(echo $GITEA_TOKEN | cut -d: -f1)\",
      \"service\": \"github\",
      \"mirror\": false,
      \"issues\": true,
      \"pull_requests\": true,
      \"labels\": true,
      \"milestones\": true,
      \"releases\": true
    }"

  echo " Done."
done

Set "mirror": true if you want Gitea to periodically sync from GitHub rather than making a one-time copy. Mirroring is useful during a transition period when you are still pushing to both.

Backup Strategy

Your Git repositories contain irreplaceable work. Back them up.

Database Backup

docker exec gitea-db pg_dump -U gitea gitea > /opt/gitea/backups/gitea-db-$(date +%Y%m%d).sql

Repository Data Backup

docker run --rm \
  -v gitea-data:/data:ro \
  -v /opt/gitea/backups:/backup \
  alpine tar czf /backup/gitea-data-$(date +%Y%m%d).tar.gz -C /data .

Automated Daily Backups

Create a cron job:

cat > /etc/cron.daily/gitea-backup << 'SCRIPT'
#!/bin/bash
BACKUP_DIR="/opt/gitea/backups"
mkdir -p "$BACKUP_DIR"

# Database
docker exec gitea-db pg_dump -U gitea gitea > "$BACKUP_DIR/gitea-db-$(date +%Y%m%d).sql"

# Data volumes
docker run --rm \
  -v gitea-data:/data:ro \
  -v "$BACKUP_DIR":/backup \
  alpine tar czf "/backup/gitea-data-$(date +%Y%m%d).tar.gz" -C /data .

# Retain 30 days
find "$BACKUP_DIR" -name "*.sql" -mtime +30 -delete
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +30 -delete

echo "$(date): Gitea backup completed" >> /var/log/gitea-backup.log
SCRIPT
chmod +x /etc/cron.daily/gitea-backup

Security Considerations

Self-hosting a Git server means you are responsible for its security. Here is what matters most:

Disable public registration. I set DISABLE_REGISTRATION=true in the Compose file. If you leave registration open, bots will create accounts within hours.

Use the rootless image. The gitea/gitea:1.22-rootless image runs as UID 1000 instead of root. If an attacker exploits a Gitea vulnerability, they cannot escalate to root inside the container.

Restrict SSH access. Only expose the SSH port (2222) if you need Git-over-SSH. If HTTPS-only is acceptable for your workflow, remove the SSH port mapping entirely.

Keep Gitea updated. Gitea releases security patches frequently. Pin a minor version (e.g., 1.22) rather than latest to avoid surprise breaking changes, but update regularly:

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

Protect the runner. The Actions runner has Docker socket access. Do not run untrusted workflows. For a personal instance, this is fine. For a team, restrict who can create workflows via repository permissions.

Enable two-factor authentication. In Gitea's admin panel, you can require 2FA for all users. Do this — passwords alone are not enough.

Troubleshooting

Problem: Gitea shows "502 Bad Gateway" behind the reverse proxy. Cause: The reverse proxy cannot reach Gitea on port 3000, usually because they are on different Docker networks. Fix: Either add both services to the same Docker network (shown in the Nginx Proxy Manager section above) or use host.docker.internal as the forward hostname. Verify with docker exec npm curl -s http://gitea:3000.

Problem: SSH clone fails with "Connection refused" on port 2222. Cause: The port mapping is not working, or your firewall blocks port 2222. Fix: Verify the port is listening with ss -tlnp | grep 2222. If not, check docker compose ps to confirm the container is running. If the port is listening but connections fail, open it in your firewall: sudo ufw allow 2222/tcp.

Problem: Gitea Actions workflow stuck in "Waiting" status. Cause: No runner is registered, or the runner is offline. Fix: Check runner status in Site Administration → Runners. If no runner is listed, verify the GITEA_RUNNER_REGISTRATION_TOKEN matches what Gitea shows. Check runner logs with docker logs gitea-runner.

Problem: Migration from GitHub fails with "401 Unauthorized." Cause: The GitHub Personal Access Token has expired or lacks the repo scope. Fix: Generate a new token at github.com/settings/tokens with the repo scope. Classic tokens work more reliably than fine-grained tokens for migration.

Problem: PostgreSQL container fails to start with "could not access directory." Cause: The volume permissions are wrong, usually after restoring from a backup. Fix: Check ownership inside the volume: docker run --rm -v postgres-data:/data alpine ls -la /data. PostgreSQL requires the data directory to be owned by UID 70 (the postgres user in Alpine). Fix with docker run --rm -v postgres-data:/data alpine chown -R 70:70 /data.

Conclusion

You now have a fully functional, self-hosted Git service with PostgreSQL for reliability, SSH passthrough for developer convenience, CI/CD with Gitea Actions, and automated backups. The entire stack uses around 300MB of RAM and runs comfortably on the smallest VPS tier.

Gitea is not a GitHub replacement in terms of social features and ecosystem. It is a GitHub replacement in terms of functionality you actually use daily: hosting code, reviewing pull requests, tracking issues, and running CI pipelines. The trade-off is maintenance responsibility — you own the uptime, the backups, and the security updates.

For related self-hosting guides, check out my post on self-hosting Vaultwarden for password management alongside your Git server, and my Docker security best practices for hardening the containers running Gitea.

If you are setting up a self-hosted development environment, you will need a reliable VPS. I use Hetzner for all my projects — the CPX22 in Helsinki gives me solid performance for around 5 euros per month, and you can follow my infrastructure guide to get the base setup running.

enim

Security researcher, CTF player, and compulsive self-hoster. Building byte-guard.net from a $10/mo Hetzner VPS. Everything I publish I have actually run in production.

Comments

Sign in with GitHub to comment. Threads live in the byteguard-comments repo.