Self-Host Gitea on a VPS with Docker Compose
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=truebecause open registration on a public instance invites abuseACTIONS__ENABLED=trueto 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:
- Click + > New Migration
- Select GitHub as the source
- Enter the repository URL (e.g.,
https://github.com/owner/repo.git) - For private repos, enter your GitHub username and a personal access token as the password
- Check This repository will be a mirror
- Set the mirror interval (default: 8 hours)
- 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