Skip to Content

Production Guide: Deploy Gitea with Docker Compose + Caddy + PostgreSQL on Ubuntu

Self-host a lightweight, production-ready Git service with automatic TLS, SSH access, and PostgreSQL persistence

Managing source code on GitHub or GitLab SaaS works until it doesn't — licensing costs scale with seat count, vendor lock-in blocks air-gapped deployments, and privacy requirements rule out public cloud hosting for sensitive codebases. Gitea is a lightweight, self-hosted Git service written in Go that gives you full repository hosting, issue tracking, pull requests, code review, CI/CD integration via Gitea Actions, and an OAuth2 provider — all in a single binary or Docker container that runs comfortably on a 1 GB RAM server. This guide walks through a production deployment of Gitea using Docker Compose, Caddy as the reverse proxy with automatic TLS, and PostgreSQL as the persistent database on Ubuntu 22.04 or 24.04.

Architecture and flow overview

The production stack consists of three services managed by Docker Compose:

  • Gitea (app container) — the Go application server handling the Git SSH daemon (port 22), HTTP UI and API (port 3000), and background job processing on internal ports only
  • PostgreSQL — the relational database storing repositories, users, issues, pull requests, teams, and configuration; Gitea supports PostgreSQL 12+
  • Caddy — the reverse proxy handling inbound HTTPS on ports 80 and 443, automatic TLS certificate issuance via Let's Encrypt, and forwarding HTTP traffic to the Gitea container over the internal Docker bridge network

Caddy listens externally, terminates TLS, and proxies all requests to gitea:3000 on the internal gitea_net network. PostgreSQL is never exposed to the host network. Docker named volumes persist the database data directory and Gitea's data directory (repos, LFS objects, attachments, avatars) across container restarts and upgrades. SSH Git operations bypass Caddy and reach Gitea directly on host port 2222, forwarded to the container's SSH daemon on port 22.

Prerequisites

  • Ubuntu 22.04 or 24.04 server with at least 1 vCPU and 1 GB RAM (2 GB recommended for teams with many repositories)
  • Docker Engine 24+ and Docker Compose v2 installed (docker compose version should return v2.x)
  • A domain name (e.g., git.yourdomain.com) with an A record pointing to your server's public IP
  • Ports 80, 443, and 2222 open in the firewall: ufw allow 80 && ufw allow 443 && ufw allow 2222
  • A valid email address for Let's Encrypt certificate notifications and Gitea admin account

Step-by-step deployment

1. Create the project directory

mkdir -p /opt/gitea && cd /opt/gitea

2. Generate PostgreSQL credentials

Generate a strong password for the Gitea database user before writing the environment file:

openssl rand -hex 24

Save the output — it will be your POSTGRES_PASSWORD and GITEA__database__PASSWD.

3. Create the environment file

cat > /opt/gitea/.env << 'EOF'
# PostgreSQL
POSTGRES_DB=gitea
POSTGRES_USER=gitea
POSTGRES_PASSWORD=REPLACE_WITH_STRONG_PASSWORD

# Gitea database
GITEA__database__DB_TYPE=postgres
GITEA__database__HOST=db:5432
GITEA__database__NAME=gitea
GITEA__database__USER=gitea
GITEA__database__PASSWD=REPLACE_WITH_STRONG_PASSWORD

# Gitea app settings
GITEA__server__DOMAIN=git.yourdomain.com
GITEA__server__ROOT_URL=https://git.yourdomain.com/
GITEA__server__SSH_DOMAIN=git.yourdomain.com
GITEA__server__SSH_PORT=2222
GITEA__server__HTTP_PORT=3000
GITEA__service__DISABLE_REGISTRATION=false
GITEA__service__REQUIRE_SIGNIN_VIEW=false
GITEA__log__LEVEL=info
EOF

4. Write the Docker Compose file

version: "3.9"

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pg_data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  gitea:
    image: gitea/gitea:1.22
    restart: unless-stopped
    env_file: .env
    ports:
      - "127.0.0.1:3000:3000"
      - "2222:22"
    volumes:
      - gitea_data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    networks:
      - backend
      - frontend
    depends_on:
      db:
        condition: service_healthy

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - frontend

volumes:
  pg_data:
  gitea_data:
  caddy_data:
  caddy_config:

networks:
  backend:
  frontend:

5. Write the Caddyfile

git.yourdomain.com {
    reverse_proxy gitea:3000
    encode gzip

    log {
        output file /data/access.log
    }
}

6. Start the stack

docker compose up -d
docker compose logs -f

Wait for the log line Starting new Web server: tcp:0.0.0.0:3000 before proceeding.

7. Complete the web installer

Open https://git.yourdomain.com in your browser. The Gitea installer page appears on first boot. Verify the database settings are pre-filled from your environment variables. Set an administrator account name, email, and password, then click Install Gitea. Gitea creates the schema and redirects to the dashboard.

Configuration and secrets handling

All sensitive values live in /opt/gitea/.env. Restrict access to this file immediately after setup:

chmod 600 /opt/gitea/.env
chown root:root /opt/gitea/.env

The environment file is never committed to a repository. For production teams, manage secrets with a dedicated secrets manager (HashiCorp Vault, AWS Secrets Manager, or Doppler) and inject them at container startup via the --env-file flag or a CI/CD secret injection layer.

To disable open registration after creating your initial accounts (strongly recommended for internet-facing instances), set:

echo "GITEA__service__DISABLE_REGISTRATION=true" >> /opt/gitea/.env
docker compose up -d gitea

Gitea's application configuration is stored in /data/gitea/conf/app.ini inside the gitea_data volume. Settings injected via environment variables take precedence over app.ini on startup. If you need to tune advanced settings (mailer, OAuth2 client IDs, repository size limits), edit app.ini directly and restart the container.

Verification

Run these checks after the installer completes to confirm the stack is healthy:

# Check all containers are running
docker compose ps

# Verify HTTPS certificate issued
curl -I https://git.yourdomain.com

# Test SSH clone (replace with your username and a test repo)
git clone ssh://[email protected]:2222/admin/test-repo.git

# Check Gitea API health
curl -s https://git.yourdomain.com/api/healthz | python3 -m json.tool

The /api/healthz endpoint returns {"status":"pass"} when all internal checks pass. If the PostgreSQL health check fails, inspect the DB logs: docker compose logs db.

Common issues and fixes

SSH clone returns "Connection refused" on port 2222
Verify the firewall rule (ufw allow 2222), and confirm Gitea's SSH port is set: docker compose exec gitea env | grep SSH_PORT. If the container is listening on 22 internally and mapped to 2222 externally, your SSH clone URL must use ssh://[email protected]:2222/user/repo.git — not the default port 22.

Installer reappears after first setup
This happens when the PostgreSQL container was not yet healthy when Gitea first started. Run docker compose down gitea && docker compose up -d gitea to restart Gitea after the database is ready. The healthcheck in the Compose file prevents this on subsequent starts.

Caddy returns 502 Bad Gateway
Caddy resolves gitea by service name on the frontend network, but Gitea is only on backend. Verify your Compose file attaches Gitea to both backend and frontend networks, then restart: docker compose up -d.

Large repository pushes time out
Caddy's default read/write timeouts may be too short for large LFS objects. Add timeout directives to the Caddyfile:

git.yourdomain.com {
    reverse_proxy gitea:3000 {
        transport http {
            read_timeout  120s
            write_timeout 120s
        }
    }
    encode gzip
}

Email notifications not sending
Gitea requires explicit SMTP configuration in app.ini or via environment variables. Set the mailer section and restart:

cat >> /opt/gitea/.env << 'EOF'
GITEA__mailer__ENABLED=true
GITEA__mailer__SMTP_ADDR=smtp.yourdomain.com
GITEA__mailer__SMTP_PORT=587
[email protected]
GITEA__mailer__PASSWD=smtppassword
[email protected]
EOF
docker compose up -d gitea

FAQ

Can Gitea replace GitHub for a private team of 20+ developers?

Yes. Gitea supports organizations, teams with role-based permissions, fork-and-pull-request workflows, code review with inline comments, protected branches, merge strategies (merge, squash, rebase), and webhook integrations with popular CI/CD systems. For teams migrating from GitHub, Gitea provides a migration tool that imports repositories, issues, pull requests, milestones, and labels from GitHub, GitLab, Bitbucket, and other Gitea instances.

Does Gitea support Gitea Actions (CI/CD)?

Yes, starting with Gitea 1.19. Gitea Actions is compatible with the GitHub Actions workflow YAML syntax. You deploy act_runner containers alongside the Gitea instance; act_runner registers with Gitea and picks up workflow jobs from repositories that define .gitea/workflows/*.yaml files. The runner can run jobs in Docker isolation or directly on the host. This makes migrating GitHub Actions workflows to self-hosted Gitea low-friction.

How does Gitea handle Git LFS for large binary files?

Gitea includes a built-in Git LFS server. Enable it in your environment:

echo "GITEA__server__LFS_START_SERVER=true" >> /opt/gitea/.env
echo "GITEA__server__LFS_CONTENT_PATH=/data/gitea/lfs" >> /opt/gitea/.env
docker compose up -d gitea

LFS objects are stored in the gitea_data volume. For very large LFS stores (multi-GB), consider mounting a separate high-capacity volume to /data/gitea/lfs in the Compose file.

Can I use Gitea as an OAuth2 provider for other services?

Yes. Gitea has a built-in OAuth2 provider. Navigate to Site Administration → Applications → OAuth2 Applications, register a new application with its redirect URI, and copy the client ID and secret. Other services (Nextcloud, Grafana, Outline, etc.) accept Gitea as a custom OAuth2 provider using the token endpoint https://git.yourdomain.com/login/oauth/access_token and the userinfo endpoint https://git.yourdomain.com/api/v1/user.

How do I back up and restore the Gitea instance?

Gitea provides a built-in dump command that archives the database, config, repositories, attachments, and LFS data into a single zip:

# Create backup archive
docker compose exec gitea gitea admin dump -c /data/gitea/conf/app.ini \
  --tempdir /tmp --file /data/gitea_dump_$(date +%Y%m%d).zip

# Copy archive off the container to host
docker cp $(docker compose ps -q gitea):/data/gitea_dump_$(date +%Y%m%d).zip /backup/

To restore, deploy a fresh stack, copy the archive into the container, and run gitea admin restore. For database-only backups between upgrades, pg_dump the gitea database from the PostgreSQL container.

What is the recommended upgrade process?

Gitea follows semantic versioning. Always back up before upgrading and check the release notes for breaking changes. The upgrade process: update the image tag in docker-compose.yml (e.g., gitea/gitea:1.22gitea/gitea:1.23), run docker compose pull gitea && docker compose up -d gitea. Gitea automatically runs schema migrations on startup. Monitor logs for migration success before allowing users back in.

Internal links

Talk to us

If you want this deployed with hardened access controls, monitoring standards, and production runbooks tailored to your environment, our team can help end-to-end.

Contact Us

Production Guide: Deploy Invoice Ninja with Docker Compose + Caddy + MySQL on Ubuntu
Self-host open-source invoicing and billing with automatic TLS