Self-Host Gitea on a VPS with Docker Compose

11 min read·Matthieu·CI/CDSelf-hostingDocker ComposeGiteaPostgreSQLDockerGit|

Deploy a production-ready Gitea instance with PostgreSQL, SSH passthrough on port 22, Gitea Actions CI/CD, Git LFS, GitHub mirroring, and automated backups. All on a single VPS with Docker Compose.

Gitea is a lightweight, self-hosted Git service written in Go. It gives you pull requests, issue tracking, CI/CD, package registries, and Git LFS in a single binary that idles at around 150 MB of RAM. Compare that to GitLab's 4 GB minimum and you see why Gitea fits a VPS perfectly.

This guide deploys Gitea with Docker Compose and PostgreSQL on a VPS. You will set up SSH passthrough so git clone git@your-server:user/repo.git works on port 22, configure Gitea Actions with a containerized runner, enable Git LFS, mirror repositories from GitHub, set up webhooks, and automate backups.

Self-Host Apps on a VPS: Architecture, RAM Usage, and What to Deploy First

What do you need before installing Gitea?

You need a VPS running Debian 12 or Ubuntu 24.04 with Docker and Docker Compose v2 installed, a domain name pointing to your server, and a reverse proxy (Nginx or Caddy) with TLS configured. This guide assumes you have completed initial server hardening: non-root user with sudo, SSH key authentication, firewall enabled.

Prerequisite Minimum
OS Debian 12 / Ubuntu 24.04
RAM 1 GB (2 GB recommended with CI runner)
Docker 27.x+ with Compose v2
Domain A record pointing to your VPS IP
Reverse proxy Nginx or Caddy with TLS

SSH Hardening on a Linux VPS: Complete sshd_config Security Guide

How do you deploy Gitea with Docker Compose and PostgreSQL?

Create a project directory, generate secrets in a .env file, and define the Gitea and PostgreSQL services in docker-compose.yml. The .env file keeps credentials out of version control and compose files.

Create the project directory

sudo mkdir -p /opt/gitea
sudo chown $USER:$USER /opt/gitea
cd /opt/gitea

Generate secrets

openssl rand -base64 32 > /dev/null  # test that openssl works
cat > .env << 'ENVFILE'
POSTGRES_USER=gitea
POSTGRES_PASSWORD=REPLACE_ME
POSTGRES_DB=gitea
GITEA_SECRET_KEY=REPLACE_ME
GITEA_INTERNAL_TOKEN=REPLACE_ME
GITEA_LFS_JWT_SECRET=REPLACE_ME
ENVFILE

Now replace each REPLACE_ME with a real secret:

sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(openssl rand -base64 32)/" .env
sed -i "s/^GITEA_SECRET_KEY=.*/GITEA_SECRET_KEY=$(openssl rand -base64 32)/" .env
sed -i "s/^GITEA_INTERNAL_TOKEN=.*/GITEA_INTERNAL_TOKEN=$(openssl rand -base64 32)/" .env
sed -i "s/^GITEA_LFS_JWT_SECRET=.*/GITEA_LFS_JWT_SECRET=$(openssl rand -base64 32)/" .env
chmod 600 .env

Docker Compose file

# /opt/gitea/docker-compose.yml
services:
  gitea:
    image: docker.gitea.com/gitea:1.25.5
    container_name: gitea
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=db:5432
      - GITEA__database__NAME=${POSTGRES_DB}
      - GITEA__database__USER=${POSTGRES_USER}
      - GITEA__database__PASSWD=${POSTGRES_PASSWORD}
      - GITEA__server__DOMAIN=git.example.com
      - GITEA__server__ROOT_URL=https://git.example.com/
      - GITEA__server__SSH_DOMAIN=git.example.com
      - GITEA__server__SSH_PORT=22
      - GITEA__server__LFS_START_SERVER=true
      - GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET}
      - GITEA__service__DISABLE_REGISTRATION=true
      - GITEA__service__REQUIRE_SIGNIN_VIEW=false
      - GITEA__security__SECRET_KEY=${GITEA_SECRET_KEY}
      - GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN}
      - GITEA__actions__ENABLED=true
    restart: always
    volumes:
      - gitea-data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      db:
        condition: service_healthy
    networks:
      - gitea

  db:
    image: docker.io/library/postgres:17
    container_name: gitea-db
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
    restart: always
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - gitea
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

networks:
  gitea:
    external: false

volumes:
  gitea-data:
  postgres-data:

Replace git.example.com with your actual domain. Note the key decisions:

  • PostgreSQL 17 instead of the outdated 14 that most guides still recommend
  • Named volumes (gitea-data, postgres-data) instead of bind mounts for cleaner management
  • Health check on PostgreSQL so Gitea waits for the database to be ready
  • DISABLE_REGISTRATION=true because open registration on a public instance invites abuse
  • ACTIONS__ENABLED=true to activate Gitea Actions from the start
  • Port 3000 bound to localhost (127.0.0.1:3000:3000) so it is only reachable through the reverse proxy, not directly from the internet
  • No SSH port mapping on the container. SSH will be handled through host passthrough (next section)

Start the stack

docker compose up -d
[+] Running 3/3
 ✔ Network gitea_gitea     Created
 ✔ Container gitea-db      Healthy
 ✔ Container gitea         Started
docker compose ps
NAME        IMAGE                           STATUS                   PORTS
gitea       docker.gitea.com/gitea:1.25.5   Up 2 minutes             127.0.0.1:3000->3000/tcp
gitea-db    postgres:17                     Up 2 minutes (healthy)   5432/tcp

Reverse proxy configuration

Add Gitea to your existing Nginx configuration:

server {
    listen 443 ssl http2;
    server_name git.example.com;

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

    server_tokens off;

    client_max_body_size 512M;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}

client_max_body_size 512M allows large file pushes, especially useful when Git LFS is active. server_tokens off hides the Nginx version from response headers because version disclosure helps attackers target known vulnerabilities.

sudo nginx -t && sudo systemctl reload nginx

Create the admin user

docker exec -it gitea gitea admin user create \
  --username admin \
  --password "$(openssl rand -base64 16)" \
  --email admin@example.com \
  --admin \
  --must-change-password
New user 'admin' has been successfully created!

Save the generated password somewhere safe (a password manager, not a sticky note). The --must-change-password flag forces a password change on first login. Once logged in, enable two-factor authentication under Settings > Security > Two-Factor Authentication.

How do you set up SSH passthrough for Gitea in Docker?

Create a git user on the host with the same UID as inside the container. Add an AuthorizedKeysCommand block to /etc/ssh/sshd_config that calls docker exec gitea /usr/local/bin/gitea keys. This routes SSH git operations from port 22 on the host directly into the container without exposing a second SSH port.

Create the host git user

The user needs UID 1000 to match the container's internal user:

sudo adduser --system --shell /bin/bash --group --disabled-password --home /home/git --uid 1000 git

If UID 1000 is taken by your regular user, either change USER_UID/USER_GID in the compose file to match an available UID, or adjust the --uid flag here.

Create the shell wrapper

The git user's shell needs to forward commands into the container:

sudo tee /usr/local/bin/gitea-shell > /dev/null << 'WRAPPER'
#!/bin/sh
/usr/bin/docker exec -i --env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" gitea sh "$@"
WRAPPER
sudo chmod 755 /usr/local/bin/gitea-shell
sudo usermod -s /usr/local/bin/gitea-shell git
ls -la /usr/local/bin/gitea-shell
-rwxr-xr-x 1 root root 99 Mar 20 10:00 /usr/local/bin/gitea-shell

Add the git user to the docker group

sudo usermod -aG docker git

This gives the git user permission to run docker exec. On a dedicated Gitea server, this is acceptable. On a shared host, consider using sudo rules scoped to the specific docker exec command instead.

Configure sshd

Add this block at the end of /etc/ssh/sshd_config:

# Gitea SSH passthrough
Match User git
  AuthorizedKeysCommandUser git
  AuthorizedKeysCommand /usr/bin/docker exec -i gitea /usr/local/bin/gitea keys -c /etc/gitea/app.ini -e git -u %u -t %t -k %k

The AuthorizedKeysCommand asks the Gitea container to verify whether the public key presented by the SSH client belongs to a registered Gitea user. If it matches, SSH grants access. The Match User git block restricts this to the git user only, leaving your regular SSH access untouched.

sudo sshd -t

No output means the configuration is valid. If you see errors, check for typos in the Match block.

sudo systemctl restart sshd

Test SSH access

Add your SSH public key to your Gitea account through the web UI (Settings > SSH/GPG Keys). Then from your local machine:

ssh -T git@git.example.com
Hi there, admin! You've successfully authenticated with the key named "my-laptop", but Gitea does not provide shell access.

You can now clone, push, and pull over SSH on port 22:

git clone git@git.example.com:admin/my-repo.git

How do you enable and configure Gitea Actions for CI/CD?

Gitea Actions is a built-in CI/CD system that uses a syntax compatible with GitHub Actions. You enable it with an environment variable (already set in our compose file), deploy a runner as a Docker Compose service, and write workflow YAML files in your repositories.

How do you register an act_runner with Docker Compose?

First, generate a runner registration token from the Gitea admin panel:

docker exec -it gitea gitea actions generate-runner-token
NxxxxxxxxxxxxxxxxxxxxxxxN

Copy this token. Add it to your .env file:

echo "GITEA_RUNNER_TOKEN=YOUR_TOKEN_HERE" >> /opt/gitea/.env
chmod 600 /opt/gitea/.env

Now add the runner service to your docker-compose.yml:

  runner:
    image: docker.io/gitea/act_runner:0.3.0
    container_name: gitea-runner
    environment:
      - GITEA_INSTANCE_URL=http://gitea:3000
      - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_TOKEN}
      - GITEA_RUNNER_NAME=vps-runner
    volumes:
      - runner-data:/data
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - gitea
    restart: always
    networks:
      - gitea

Add runner-data: to the volumes: section at the bottom of the file. Then bring up the runner:

docker compose up -d runner
docker compose logs runner --tail 20
level=info msg="Starting runner daemon"
level=info msg="Runner registered successfully"

The runner mounts the Docker socket (/var/run/docker.sock) so it can spawn containers for each job. This is standard practice for CI runners but means jobs could access the host Docker daemon. For isolated environments, consider running the runner with Docker-in-Docker (DinD) instead.

Environment variable Purpose
GITEA_INSTANCE_URL Internal URL where the runner reaches Gitea (use the service name, not the public domain)
GITEA_RUNNER_REGISTRATION_TOKEN One-time token from gitea actions generate-runner-token
GITEA_RUNNER_NAME Display name in the Gitea admin panel

What does a Gitea Actions workflow look like?

Gitea Actions uses the same YAML syntax as GitHub Actions, with some differences. Create .gitea/workflows/build.yml in your repository:

name: Build and Test

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

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.23'

      - name: Build
        run: go build -v ./...

      - name: Test
        run: go test -v ./...

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

      - name: Deploy via SSH
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
        run: |
          mkdir -p ~/.ssh
          echo "$DEPLOY_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh -o StrictHostKeyChecking=accept-new user@production "cd /app && git pull && systemctl restart myapp"

Store the DEPLOY_KEY secret in your repository settings under Settings > Actions > Secrets.

Feature Gitea Actions GitHub Actions
Workflow directory .gitea/workflows/ .github/workflows/
uses: actions/* Works (fetched from GitHub by default) Native
Container services Supported Supported
Matrix builds Supported Supported
Reusable workflows Supported since Gitea 1.24 Supported
Marketplace actions Most work, some need adaptation Native

How do you enable Git LFS in Gitea?

Git Large File Storage lets you track binary files (images, models, datasets) without bloating your repository. Gitea has built-in LFS support. We already enabled it with LFS_START_SERVER=true in the compose file. The LFS data is stored inside the gitea-data volume by default.

To configure the storage path explicitly or increase limits, edit the Gitea configuration:

docker exec -i gitea sh -c 'cat >> /data/gitea/conf/app.ini' << 'EOF'

[lfs]
PATH = /data/git/lfs
EOF

Restart Gitea to apply:

docker compose restart gitea

On the client side, install git-lfs and track file types:

git lfs install
git lfs track "*.bin" "*.h5" "*.onnx"
git add .gitattributes
git commit -m "Track model files with LFS"
git push

LFS files are stored on the server at the configured PATH inside the container. For large deployments, you can configure S3-compatible storage in app.ini under the [lfs] section.

How do you mirror GitHub repositories to Gitea?

Pull mirroring creates a read-only copy of a GitHub repository on your Gitea instance. It syncs automatically on a configurable schedule. This is useful for backup, for having a local cache behind your CI runner, or for reducing dependency on GitHub.

In the Gitea web UI:

  1. Click + > New Migration
  2. Select GitHub as the source
  3. Enter the repository URL (e.g., https://github.com/owner/repo.git)
  4. For private repos, enter your GitHub username and a personal access token as the password
  5. Check This repository will be a mirror
  6. Set the mirror interval (default: 8 hours)
  7. Click Migrate Repository

The mirror syncs branches, tags, and releases. Issues, pull requests, and wikis can also be migrated during the initial import but are not continuously synced.

To mirror via the API instead:

curl -X POST "https://git.example.com/api/v1/repos/migrate" \
  -H "Authorization: token YOUR_GITEA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "clone_addr": "https://github.com/owner/repo.git",
    "mirror": true,
    "mirror_interval": "8h",
    "repo_name": "repo-mirror",
    "repo_owner": "admin",
    "service": "github"
  }'

How do you configure webhooks for deployment?

Webhooks send HTTP POST requests to a URL when events occur in a repository. They are a simple way to trigger deployments, notifications, or external CI systems.

In your repository, go to Settings > Webhooks > Add Webhook > Gitea:

  • Target URL: https://deploy.example.com/hooks/gitea
  • HTTP Method: POST
  • Content Type: application/json
  • Secret: generate with openssl rand -hex 32
  • Trigger events: Push events (or customize)

On the receiving end, your deployment script should verify the webhook signature:

# The webhook sends a X-Gitea-Signature header
# Verify it with the shared secret before acting on the payload
EXPECTED=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
if [ "$SIGNATURE" != "$EXPECTED" ]; then
  echo "Invalid signature"
  exit 1
fi

A minimal webhook receiver using a lightweight tool like webhook:

[
  {
    "id": "deploy",
    "execute-command": "/opt/deploy.sh",
    "command-working-directory": "/opt/app",
    "trigger-rule": {
      "match": {
        "type": "payload-hmac-sha256",
        "secret": "your-webhook-secret",
        "parameter": {
          "source": "header",
          "name": "X-Gitea-Signature"
        }
      }
    }
  }
]

How do you back up a Gitea instance?

Gitea ships a gitea dump command that packages repositories, the database, configuration, and LFS objects into a single zip file. For PostgreSQL, you also want a separate pg_dump for point-in-time recovery.

Manual backup

docker exec -it gitea /usr/local/bin/gitea dump -c /data/gitea/conf/app.ini --file /data/gitea-backup.zip
docker cp gitea:/data/gitea-backup.zip /opt/gitea/backups/

The dump includes:

Content Included in dump
Git repositories Yes
Database (SQLite or dump) Yes
Configuration (app.ini) Yes
LFS objects Yes
Attachments, avatars Yes
Packages No (back up separately)

For PostgreSQL, also run:

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

Automated backup with cron

sudo mkdir -p /opt/gitea/backups
sudo chown root:root /opt/gitea/backups
sudo chmod 700 /opt/gitea/backups

Create the backup script:

sudo tee /opt/gitea/backup.sh > /dev/null << 'BACKUP'
#!/bin/bash
set -euo pipefail

BACKUP_DIR="/opt/gitea/backups"
DATE=$(date +%Y%m%d-%H%M)

# Gitea dump
docker exec gitea /usr/local/bin/gitea dump -c /data/gitea/conf/app.ini --file /data/gitea-backup.zip --quiet
docker cp gitea:/data/gitea-backup.zip "$BACKUP_DIR/gitea-dump-$DATE.zip"
docker exec gitea rm /data/gitea-backup.zip

# PostgreSQL dump
docker exec gitea-db pg_dump -U gitea gitea | gzip > "$BACKUP_DIR/gitea-db-$DATE.sql.gz"

# Keep last 7 daily backups
find "$BACKUP_DIR" -name "gitea-*" -mtime +7 -delete

echo "Backup completed: $DATE"
BACKUP
sudo chmod 700 /opt/gitea/backup.sh
ls -la /opt/gitea/backup.sh
-rwx------ 1 root root 523 Mar 20 10:00 /opt/gitea/backup.sh

Schedule it daily at 3 AM:

echo "0 3 * * * root /opt/gitea/backup.sh >> /var/log/gitea-backup.log 2>&1" | sudo tee /etc/cron.d/gitea-backup
sudo chmod 644 /etc/cron.d/gitea-backup

Test the script once manually:

sudo /opt/gitea/backup.sh
Backup completed: 20260320-1030
ls -lh /opt/gitea/backups/
-rw-r--r-- 1 root root 2.3M Mar 20 10:30 gitea-dump-20260320-1030.zip
-rw-r--r-- 1 root root  48K Mar 20 10:30 gitea-db-20260320-1030.sql.gz

Copy backups off-server. A local backup that dies with the disk is not a backup.

How do you update Gitea safely?

Pull the new image, recreate the container, and let Gitea handle database migrations automatically. Pin image versions so updates are intentional, not accidental.

cd /opt/gitea

# Back up first
sudo /opt/gitea/backup.sh

# Update the image tag in docker-compose.yml, then:
docker compose pull gitea
docker compose up -d gitea
docker compose logs gitea --tail 30

Watch for migration messages:

2026/03/20 10:35:00 ...les/migration.go:67:Migrate() [I] Migration completed

After confirming Gitea starts cleanly, update the runner if a new version is available:

docker compose pull runner
docker compose up -d runner

Docker Update Strategy: Zero-Downtime Container Updates on a VPS

What is the difference between Gitea and Forgejo?

Forgejo forked from Gitea in late 2022 after a for-profit company (Gitea Ltd.) took control of the Gitea project. Forgejo is governed by Codeberg e.V., a German non-profit. As of early 2026, Forgejo is a hard fork with diverging codebases. Both run a similar Docker setup, but migration between them is no longer seamless.

Gitea Forgejo
Governance Gitea Ltd. (for-profit) Codeberg e.V. (non-profit)
License MIT GPL-3.0+ (since Forgejo v9.0)
Docker image docker.gitea.com/gitea codeberg.org/forgejo/forgejo
Actions support Yes (act_runner) Yes (compatible runner)
API compatibility GitHub-compatible GitHub-compatible
Unique features Gitea Enterprise, MCP server Federation (ForgeFed), moderation tools

If you want a community-governed project with a copyleft license, pick Forgejo. If you want the original project with commercial backing, pick Gitea. The Docker Compose setup in this guide works for both with minimal changes (swap the image, adjust paths).

If you start with Gitea and want to switch later: export your data with gitea dump, set up Forgejo, and import. Test thoroughly. The codebases have diverged enough that some database schemas differ.

How much RAM and CPU does Gitea need?

A Gitea instance with PostgreSQL uses around 150-250 MB of RAM at idle. Under active use with 5-10 users and CI runners, expect 300-500 MB total. This is roughly 10x lighter than a GitLab instance, which needs 4 GB minimum.

These numbers come from a running Gitea instance on a Virtua Cloud VPS (4 vCPU, 8 GB RAM):

docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
NAME            CPU %     MEM USAGE / LIMIT
gitea           0.15%     148.2MiB / 7.77GiB
gitea-db        0.08%     45.3MiB / 7.77GiB
gitea-runner    0.02%     32.1MiB / 7.77GiB
Scenario RAM (total stack) CPU
Idle, few repos ~230 MB < 1%
Active, 5-10 users ~400 MB 2-5%
CI build running ~600 MB (spikes during builds) 20-50% per job
GitLab equivalent 4,000+ MB 10%+ idle

Gitea runs comfortably on a 2 GB VPS. With CI runners, 4 GB gives you headroom for concurrent builds.

Troubleshooting

SSH connection refused: Check that the git user exists, sshd_config has the Match block, and sshd was restarted. Check the logs:

journalctl -u sshd -f

Runner not picking up jobs: Confirm the runner is registered in the admin panel (Site Administration > Actions > Runners). Check runner logs:

docker compose logs runner --tail 50

Database connection errors on startup: The healthcheck should prevent this, but if Gitea starts before PostgreSQL is ready:

docker compose restart gitea

LFS push fails with 413: Increase client_max_body_size in your Nginx config. 512M is usually enough, but adjust for your largest files.

Gitea logs:

docker compose logs gitea --tail 100

Or follow live:

docker compose logs gitea -f

Self-Host Vaultwarden on a VPS with Docker Compose


Copyright 2026 Virtua.Cloud. All rights reserved. This content is original work by the Virtua.Cloud team. Reproduction, republication, or redistribution without written permission is prohibited.

Ready to try it yourself?

Deploy your own server in seconds. Linux, Windows, or FreeBSD.

See VPS Plans