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=trueprevents random people from creating accounts on your instance. You create accounts via the admin panel or CLI. - Health check on PostgreSQL: The
depends_onwithcondition: service_healthyensures 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.envfiles to version control. Thechmod 600ensures 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):
- Add a new proxy host
- Domain:
git.yourdomain.com - Forward Hostname:
gitea(or the server IP if NPM runs in a different Docker network) - Forward Port:
3000 - 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
- Click + → New Migration
- Select GitHub as the source
- Enter the repository URL (e.g.,
https://github.com/youruser/yourrepo) - For private repos, provide a GitHub Personal Access Token with
reposcope - Select what to migrate (issues, PRs, labels, etc.)
- 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.
← Back
Comments
Sign in with GitHub to comment. Threads live in the byteguard-comments repo.