Why Self-Host Your Git Server?
GitHub and GitLab work great until they don't: private repositories that cost money at scale, outages during critical deploys, compliance requirements that prohibit third-party code hosting, or simply the principle that your source code shouldn't live on someone else's server.
Gitea is the lightest self-hosted Git server that actually covers everything a real engineering team needs — repositories, pull requests, issue tracking, CI/CD via Gitea Actions, webhooks, and a package registry. It's a single Go binary. The Docker image is under 100 MB. It runs fine on a $6/month VPS.
This guide walks you through the full production setup: Docker Compose stack with PostgreSQL, HTTPS via Traefik, SSH access on a clean port, environment-variable-driven configuration, and Gitea Actions for CI/CD.
Prerequisites
Before you start:
- A Linux server running Ubuntu 22.04 or 24.04 (minimum 1 vCPU / 1 GB RAM; 2 GB recommended for Actions)
- Docker Engine v24+ installed via the official apt method — not snap
- Docker Compose v2 (
docker compose versionreturns v2.x) - A domain or subdomain pointing at your server's public IP via a DNS A record
- Ports 80, 443, and 222 (or your chosen SSH port) open in your firewall
- Traefik running with a
letsencryptcertificate resolver on thetraefik-publicnetwork (or another reverse proxy of your choice)
# Verify the basics
docker --version # v24+
docker compose version # v2.x
# Check that your chosen SSH port is free
sudo ss -tlnp | grep -E ':222|:3000'
# Confirm DNS resolves to this server
curl -s https://ifconfig.me # your server's public IP
dig +short git.yourdomain.com # should match
If you want a Traefik + Caddy comparison for the reverse proxy layer, check out our dedicated production guides: Gitea with Traefik + PostgreSQL and Gitea with Caddy + PostgreSQL go deep on each option.
Step 1: Directory Layout and Environment File
Structure everything under a single directory. Gitea stores its data (repositories, attachments, LFS objects) in a named volume, keeping it decoupled from the container lifecycle.
mkdir -p /opt/gitea && cd /opt/gitea
# Create the environment file
cat > .env <<'EOF'
# --- Gitea container identity ---
USER_UID=1000
USER_GID=1000
# --- Database (passed as GITEA__ env vars) ---
GITEA__database__DB_TYPE=postgres
GITEA__database__HOST=gitea-db:5432
GITEA__database__NAME=gitea
GITEA__database__USER=gitea
GITEA__database__PASSWD=changeme_db_password
# --- Server settings ---
GITEA__server__DOMAIN=git.yourdomain.com
GITEA__server__ROOT_URL=https://git.yourdomain.com/
GITEA__server__HTTP_PORT=3000
GITEA__server__SSH_DOMAIN=git.yourdomain.com
GITEA__server__SSH_PORT=222
GITEA__server__START_SSH_SERVER=true
# --- Security ---
GITEA__security__SECRET_KEY=replace_with_64_char_random_string
GITEA__security__INTERNAL_TOKEN=replace_with_64_char_random_string
# --- Mailer (optional, set ENABLED=true to configure) ---
GITEA__mailer__ENABLED=false
# --- PostgreSQL container ---
POSTGRES_USER=gitea
POSTGRES_PASSWORD=changeme_db_password
POSTGRES_DB=gitea
EOF
chmod 600 .env
The GITEA__<section>__<KEY> pattern is Gitea's environment-variable override syntax. Every setting in app.ini can be set this way — no need to manage the ini file directly in Docker deployments. Gitea writes the resolved config to app.ini on startup.
Generate secure values for SECRET_KEY and INTERNAL_TOKEN:
# Generate two independent 64-character secrets
openssl rand -hex 32 # use output for SECRET_KEY
openssl rand -hex 32 # use output for INTERNAL_TOKEN
Step 2: Docker Compose Stack with PostgreSQL
Here's the full production-ready Compose file. It runs Gitea and PostgreSQL on an internal network, exposes only Gitea to the Traefik-managed external network, and mounts timezone files so timestamps are correct.
# /opt/gitea/docker-compose.yml
services:
gitea:
image: docker.gitea.com/gitea:1.26.2
container_name: gitea
restart: unless-stopped
env_file: .env
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "222:22" # Git SSH access on host port 222
depends_on:
gitea-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.gitea.rule=Host(`git.yourdomain.com`)"
- "traefik.http.routers.gitea.entrypoints=websecure"
- "traefik.http.routers.gitea.tls=true"
- "traefik.http.routers.gitea.tls.certresolver=letsencrypt"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
networks:
- traefik-public
- gitea-internal
gitea-db:
image: postgres:16-alpine
container_name: gitea-db
restart: unless-stopped
env_file: .env
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:
traefik-public:
external: true
gitea-internal:
internal: true
Key design decisions here:
- PostgreSQL healthcheck — the
depends_on: condition: service_healthyensures Gitea waits for the database to be ready before starting. Without this you get connection-refused errors on cold starts. - Internal network for the database —
gitea-internalis markedinternal: true. PostgreSQL has no internet access; only Gitea can reach it. - SSH on port 222 — port 22 on the host is almost certainly occupied by the system's own sshd. Using 222 (or any high port) avoids the conflict. You'll tell Git to use this port when cloning.
- Pin the image version —
gitea:1.26.2rather than:latest. Control when you upgrade.
# Create the shared Traefik network if it doesn't exist yet
docker network create traefik-public 2>/dev/null || true
# Start the stack
docker compose up -d
# Watch Gitea logs for startup confirmation
docker compose logs -f gitea
Once running, navigate to https://git.yourdomain.com. On first visit Gitea shows an installation wizard — but because all settings are already supplied via environment variables, most fields will be pre-filled. Review, confirm, and create the admin account.
Step 3: Configure SSH Access for Git Operations
HTTPS cloning works immediately once Gitea is up. SSH cloning requires a bit more thought because of the port situation.
How Gitea SSH Works in Docker
Gitea runs its own built-in SSH server inside the container (START_SSH_SERVER=true). This is separate from the host's system sshd. We map container port 22 → host port 222, so SSH Git operations target port 222 on your server.
Add Your SSH Key to Gitea
In the Gitea UI: Settings → SSH / GPG Keys → Add Key. Paste your public key (~/.ssh/id_ed25519.pub or equivalent).
Configure SSH on Your Local Machine
Rather than appending :222 to every clone URL, add a Host block to your local ~/.ssh/config:
# ~/.ssh/config (on your local machine, not the server)
Host git.yourdomain.com
HostName git.yourdomain.com
User git
Port 222
IdentityFile ~/.ssh/id_ed25519
Test the connection:
ssh -T [email protected]
# Expected output:
# Hi username! You've successfully authenticated, but Gitea does not provide shell access.
# Clone a repo via SSH (Gitea shows the correct URL in the UI)
git clone [email protected]:yourusername/yourrepo.git
Want SSH on the standard port 22? That requires SSH passthrough — a more complex setup involving agituser on the host andAuthorizedKeysCommandin sshd. See our Gitea + Caddy production guide for a walkthrough of the passthrough approach.
Step 4: Tune the Configuration via Environment Variables
Most Gitea settings can be dialled in without touching the container. Just add or update variables in .env and restart. Here are the most useful production settings:
# --- Disable public registration (you control who has an account) ---
GITEA__service__DISABLE_REGISTRATION=true
# --- Require sign-in to view any content ---
GITEA__service__REQUIRE_SIGNIN_VIEW=false
# --- LFS (Large File Storage) ---
GITEA__server__LFS_START_SERVER=true
GITEA__lfs__PATH=/data/gitea/lfs
# --- Email notifications (example: SMTP) ---
GITEA__mailer__ENABLED=true
GITEA__mailer__PROTOCOL=smtps
GITEA__mailer__SMTP_ADDR=smtp.example.com
GITEA__mailer__SMTP_PORT=465
[email protected]
GITEA__mailer__PASSWD=your_smtp_password
GITEA__mailer__FROM=Gitea
# --- Actions (enable Gitea's built-in CI/CD) ---
GITEA__actions__ENABLED=true
# Apply changes
# docker compose down && docker compose up -d
After editing .env, restart the stack to apply changes:
cd /opt/gitea
docker compose down
docker compose up -d
# Verify the config was applied by checking the running app.ini
docker exec gitea cat /data/gitea/conf/app.ini | grep -A3 '\[server\]'
Step 5: Set Up Gitea Actions for CI/CD
Gitea Actions is a GitHub Actions-compatible CI/CD system built into Gitea. If you've written GitHub Actions workflows, the syntax is identical — same .gitea/workflows/ YAML, same on: triggers, same jobs: blocks.
Deploy an Act Runner
The runner is what executes your workflows. Add it to your Compose stack:
# Add to /opt/gitea/docker-compose.yml under services:
gitea-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=${RUNNER_TOKEN}
- GITEA_RUNNER_NAME=docker-runner
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- gitea_runner_data:/data
networks:
- traefik-public # needs to reach Gitea
# Add to volumes:
# gitea_runner_data:
Get the registration token from the Gitea UI: Site Administration → Runners → Create new runner. Copy the token, add it to your .env as RUNNER_TOKEN=..., then bring the runner up:
docker compose up -d gitea-runner
# Confirm runner registered
docker logs gitea-runner | grep -i "registered\|connected"
A Minimal Gitea Actions Workflow
Drop this into your repository at .gitea/workflows/ci.yml:
# .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
# e.g. npm test, go test ./..., pytest, etc.
- name: Build
run: |
echo "Building..."
# e.g. docker build -t myapp:latest .
Push to main and the runner picks it up immediately. Workflow results appear in the Actions tab of your repository.
Step 6: Troubleshooting and Common Gotchas
Here's what actually breaks and how to diagnose it fast.
Clone URLs Show the Wrong Domain or Port
Symptom: The clone URL in the Gitea UI shows http://localhost:3000/... or an IP instead of your domain.
This is a ROOT_URL misconfiguration. Check the environment variable:
- Confirm
GITEA__server__ROOT_URL=https://git.yourdomain.com/is set in.env— include the trailing slash - Confirm
GITEA__server__DOMAIN=git.yourdomain.commatches exactly - After updating, restart the stack and verify:
docker exec gitea cat /data/gitea/conf/app.ini | grep ROOT_URL
SSH Key Accepted but Git Push Fails With Permission Denied
- The SSH key must be added to your Gitea user account, not just the server. Check: Settings → SSH / GPG Keys in the Gitea UI.
- Verify your local SSH config points to port 222 (or whatever you mapped). A quick test:
ssh -v -T -p 222 [email protected]— look for "Authentication succeeded" in the verbose output. - If you get "Host key verification failed", the host key changed (e.g. after a container rebuild). Run
ssh-keygen -R [git.yourdomain.com]:222to clear the stale entry.
Gitea Fails to Start — PostgreSQL Connection Refused
Symptom: Gitea logs show dial tcp: connection refused immediately on startup.
The healthcheck in the Compose file should prevent this, but if you're on an older config:
# Check if the database container is healthy
docker inspect gitea-db | grep -A5 Health
# Check the database logs
docker logs gitea-db
# Force Gitea to wait and retry
docker restart gitea
# If it keeps failing, verify the POSTGRES credentials match between
# GITEA__database__PASSWD and POSTGRES_PASSWORD in .env
Gitea Web UI Loads but Shows 502 Bad Gateway
This means Traefik can't reach Gitea on port 3000. Checks:
- Is Gitea on the
traefik-publicnetwork?docker inspect gitea | grep -A20 Networks - Is Gitea actually listening?
docker exec gitea wget -qO- http://localhost:3000/ - Is the Traefik label port correct?
traefik.http.services.gitea.loadbalancer.server.port=3000
General Diagnostics
# Live Gitea application logs
docker compose logs -f gitea
# Filter for errors and warnings
docker compose logs gitea 2>&1 | grep -iE "error|warn|fatal|fail"
# Check the resolved app.ini to confirm env vars applied
docker exec gitea cat /data/gitea/conf/app.ini
# Confirm PostgreSQL is accepting connections
docker exec gitea-db psql -U gitea -c "\l"
Backing Up Gitea
Gitea's built-in dump command creates a complete backup including repositories, the database, and attachments:
# Create a full Gitea backup (runs inside the container)
docker exec -u 1000 gitea gitea dump \
--config /data/gitea/conf/app.ini \
--file /tmp/gitea-backup-$(date +%Y%m%d).zip
# Copy the backup out of the container
docker cp gitea:/tmp/gitea-backup-$(date +%Y%m%d).zip \
/opt/gitea/backups/
# Also back up the PostgreSQL database separately
docker exec gitea-db pg_dump -U gitea gitea | \
gzip > /opt/gitea/backups/gitea-db-$(date +%Y%m%d).sql.gz
What to Explore Next
Once your Gitea instance is running smoothly, here's where most teams go:
- Organizations and teams — create an organization, add members with repository-level permissions (read/write/admin), and manage access centrally
- Webhooks — trigger external services on push events; connect Gitea to deployment pipelines, chat notifications, or issue trackers
- Package registry — Gitea includes a built-in registry for npm, PyPI, Docker images, Helm charts, and more; host private packages alongside your code
- Mirroring — mirror repositories from GitHub or GitLab automatically so you have an always-current offline copy of dependencies
- Protected branches — require pull request reviews and passing CI checks before merging to main; same workflow as GitHub, on your own server
Production deployment guides: For detailed Caddy and Traefik reverse proxy configurations with Gitea, see our Gitea + Caddy + PostgreSQL production guide and Gitea + Traefik + PostgreSQL production guide for deeper coverage of each setup.
Need a Managed Self-Hosted Git Platform for Your Team?
Running Gitea solo is a weekend project. Running it for 20+ developers with SSO, audit logs, HA database, automated backups, and a monitored runner fleet is a different story.
If you're setting up self-hosted developer infrastructure for a growing team and want it done right, reach out to Sysbrix. We design and operate self-hosted development platforms — Git servers, CI/CD pipelines, container registries, and the infrastructure underneath them — so your team gets the control of self-hosting without the operational overhead.