Skip to Content

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

Run a lightweight Git service with external HTTPS, SSH clone support, backups, and operational checks.

Forgejo is a strong fit when a team wants Git hosting, pull requests, issues, packages, and SSH clone workflows without operating a heavy enterprise platform. A common real-world use case is a small engineering group or internal platform team that wants private repositories close to its deployment environment, with predictable backups and straightforward recovery. This guide follows the same production-oriented SysBrix pattern used across recent Guides: start with architecture, put secrets outside the compose file, expose only the ports that must be public, verify the stack, and document the recovery path before the first important repository lands on the server.

The deployment below runs Forgejo on Ubuntu with Docker Compose, PostgreSQL for durable metadata, Caddy for automatic HTTPS, and Forgejo's built-in SSH server exposed on port 2222. The approach is intentionally boring: fewer moving parts, clear data directories, health checks, repeatable upgrades, and backup commands that can be rehearsed. Replace git.example.com with your real DNS name before running the commands.

Architecture and flow overview

Traffic enters through two paths. Browser and API requests go to Caddy on ports 80 and 443, then Caddy reverse-proxies to the Forgejo web container on the private Docker network. Git-over-SSH requests connect directly to port 2222 on the host, which is mapped to Forgejo's internal SSH listener. PostgreSQL is not published to the internet; only Forgejo can reach it through the Compose network. Persistent state lives in /opt/forgejo/data for repositories, attachments, packages, and configuration, plus /opt/forgejo/postgres for the database.

This layout is easy to operate because every critical object has a single ownership boundary. Caddy owns certificates and HTTPS headers. Forgejo owns the Git application and SSH service. PostgreSQL owns relational data. The host firewall owns public exposure. Backups capture both database dumps and application data archives so a restore does not depend on a single opaque volume.

Prerequisites

  • Ubuntu 22.04 or 24.04 server with sudo access.
  • A DNS record such as git.example.com pointing to the server.
  • Ports 80, 443, and 2222 reachable from expected users.
  • At least 2 GB RAM for a small team; more if using packages, CI integrations, or large repositories.
  • A tested off-server backup destination before production use.

Install Docker, Compose, firewall tooling, and basic security packages first:

sudo apt update && sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg ufw fail2ban
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update && sudo apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

If the copy button is unavailable, select the command block and copy it manually.

Step-by-step deployment

Create a dedicated application directory. The environment file keeps editable settings in one place, while high-value random tokens are stored as Docker secrets. Keep these files out of Git and restrict permissions on the host.

sudo mkdir -p /opt/forgejo/{data,postgres,caddy,backups}
sudo chown -R $USER:$USER /opt/forgejo
cd /opt/forgejo
openssl rand -hex 32 > .internal_token
openssl rand -hex 32 > .lfs_jwt_secret
openssl rand -base64 36 > .postgres_password
cat > .env <<'EOF'
DOMAIN=git.example.com
TZ=UTC
POSTGRES_DB=forgejo
POSTGRES_USER=forgejo
POSTGRES_PASSWORD=replace-with-contents-of-.postgres_password
FORGEJO__security__INSTALL_LOCK=true
FORGEJO__service__DISABLE_REGISTRATION=true
EOF
sed -i "s|replace-with-contents-of-.postgres_password|$(cat .postgres_password)|" .env
chmod 600 .env .internal_token .lfs_jwt_secret .postgres_password

If the copy button is unavailable, select the command block and copy it manually.

Now create the Compose file. The configuration pins PostgreSQL to a current Alpine image and uses the official Forgejo image from Codeberg. Forgejo is configured through environment variables so the initial installation is locked, registration is disabled by default, and SSH clone URLs advertise the same domain users visit in the browser.

cat > compose.yml <<'EOF'
services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      TZ: ${TZ}
    volumes:
      - ./postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  forgejo:
    image: codeberg.org/forgejo/forgejo:9
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      USER_UID: 1000
      USER_GID: 1000
      FORGEJO__database__DB_TYPE: postgres
      FORGEJO__database__HOST: db:5432
      FORGEJO__database__NAME: ${POSTGRES_DB}
      FORGEJO__database__USER: ${POSTGRES_USER}
      FORGEJO__database__PASSWD: ${POSTGRES_PASSWORD}
      FORGEJO__server__DOMAIN: ${DOMAIN}
      FORGEJO__server__ROOT_URL: https://${DOMAIN}/
      FORGEJO__server__SSH_DOMAIN: ${DOMAIN}
      FORGEJO__server__SSH_PORT: 2222
      FORGEJO__server__START_SSH_SERVER: true
      FORGEJO__server__SSH_LISTEN_PORT: 2222
      FORGEJO__security__INSTALL_LOCK: ${FORGEJO__security__INSTALL_LOCK}
      FORGEJO__security__INTERNAL_TOKEN_URI: file:/run/secrets/internal_token
      FORGEJO__server__LFS_JWT_SECRET_URI: file:/run/secrets/lfs_jwt_secret
      FORGEJO__service__DISABLE_REGISTRATION: ${FORGEJO__service__DISABLE_REGISTRATION}
    secrets:
      - internal_token
      - lfs_jwt_secret
    volumes:
      - ./data:/data
    ports:
      - "127.0.0.1:3000:3000"
      - "2222:2222"

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    depends_on:
      - forgejo
    environment:
      DOMAIN: ${DOMAIN}
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

secrets:
  internal_token:
    file: ./.internal_token
  lfs_jwt_secret:
    file: ./.lfs_jwt_secret
volumes:
  caddy_data:
  caddy_config:
EOF

If the copy button is unavailable, select the command block and copy it manually.

Caddy terminates TLS and adds conservative security headers. The firewall leaves the host's regular OpenSSH port open for administration, opens web ports for Caddy, and opens 2222 for Git SSH traffic. If your organization requires SSH on port 22 for Git, put host administration on another port first, then adjust the mapping deliberately.

cat > Caddyfile <<'EOF'
{$DOMAIN} {
  encode zstd gzip
  reverse_proxy forgejo:3000
  header {
    X-Content-Type-Options nosniff
    X-Frame-Options SAMEORIGIN
    Referrer-Policy strict-origin-when-cross-origin
  }
  log {
    output file /data/access.log
    format console
  }
}
EOF
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 2222/tcp
sudo ufw --force enable
docker compose up -d

If the copy button is unavailable, select the command block and copy it manually.

Configuration and secrets handling best practices

Do not paste production passwords directly into documentation, tickets, or Git repositories. Use the .env file only on the server, store generated secrets in separate files with mode 600, and rotate credentials if the file is copied into an unsafe location. Registration starts disabled because most private Git systems should onboard users explicitly. After the first administrator account is created, review site settings, mail delivery, repository visibility defaults, OAuth providers, package registry policy, and organization permissions before inviting the wider team.

For email notifications, configure SMTP in Forgejo after the web UI is reachable. For SSO, prefer an identity provider with enforced MFA and group-based access. For repository data, decide whether large binary artifacts belong in Git LFS, Forgejo packages, or an object store. The best production pattern is to keep source control lean, keep packages governed, and keep backups encrypted outside the VM.

Verification checklist

Verification should prove that HTTPS, application health, PostgreSQL connectivity, and SSH clone paths all work. Run these checks before creating important repositories:

docker compose ps
docker compose logs --tail=80 forgejo
curl -I https://git.example.com
ssh -p 2222 [email protected] || true
curl -fsS https://git.example.com/api/healthz || true

If the copy button is unavailable, select the command block and copy it manually.

  • The Compose output should show all services running or healthy.
  • The HTTPS response should return a valid status and Caddy-managed certificate.
  • The SSH test should reach Forgejo, even if authentication fails before a key is added.
  • The health endpoint or logs should not show database connection errors.
  • Create a test repository, add an SSH key, push a small commit, clone it again, and delete the test repo only after the path is proven.

Backups and upgrade routine

Backups need both the PostgreSQL database and the Forgejo data directory. A database-only dump misses repositories and package objects; a file-only archive risks inconsistent metadata. The following timer creates a timestamped database dump and data archive. In production, sync the resulting folder to object storage or another server and test a restore on a clean VM at least quarterly.

cat > backup.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/forgejo
stamp=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p backups/${stamp}
docker compose exec -T db pg_dump -U forgejo forgejo | gzip > backups/${stamp}/forgejo.sql.gz
tar --numeric-owner -czf backups/${stamp}/forgejo-data.tar.gz data
find backups -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} +
EOF
chmod +x backup.sh
sudo tee /etc/systemd/system/forgejo-backup.service >/dev/null <<'EOF'
[Unit]
Description=Backup Forgejo
[Service]
Type=oneshot
ExecStart=/opt/forgejo/backup.sh
EOF
sudo tee /etc/systemd/system/forgejo-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Nightly Forgejo backup
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now forgejo-backup.timer

If the copy button is unavailable, select the command block and copy it manually.

For upgrades, read Forgejo release notes, snapshot or back up first, pull images, and start the stack again. Do not skip the log review after upgrades; schema migrations and configuration warnings usually appear there first.

cd /opt/forgejo
docker compose pull
docker compose up -d
docker compose logs --tail=80 forgejo
docker compose exec -T db psql -U forgejo -d forgejo -c 'select now();'

If the copy button is unavailable, select the command block and copy it manually.

Common issues and fixes

SSH clone URLs point at the wrong port

Check FORGEJO__server__SSH_DOMAIN, SSH_PORT, and START_SSH_SERVER. Restart Forgejo after changes and regenerate a test clone URL from the UI.

Caddy cannot issue certificates

Confirm DNS points to the server and ports 80 and 443 are open from the public internet. If another service is already bound to those ports, stop it or move it behind Caddy.

Forgejo starts but reports database errors

Run docker compose logs db, verify the password in .env, and confirm the database health check is passing. Avoid editing PostgreSQL files directly; restore from a known-good dump when corruption is suspected.

Pushes are slow or fail with large files

Enable and document Git LFS for large binaries, review reverse proxy body limits if using HTTPS pushes, and keep generated build artifacts out of source repositories.

Users can self-register unexpectedly

Set FORGEJO__service__DISABLE_REGISTRATION=true, restart the service, and review administrator-created accounts and OAuth settings.

FAQ

Is Forgejo suitable for production teams?

Yes, for small and mid-sized teams that want lightweight Git hosting and can operate normal Linux backups, monitoring, and patching. Larger organizations should add SSO, audit processes, and capacity planning.

Why use PostgreSQL instead of SQLite?

SQLite is convenient for labs, but PostgreSQL is a better fit for multi-user production, predictable backups, and operational tooling.

Can SSH run on port 22?

Yes, but only if host administration has a different secure path. Using port 2222 avoids conflict with the server's normal OpenSSH daemon.

Should registration stay disabled?

For private deployments, yes. Invite users deliberately, map permissions through organizations, and use SSO if the team has an identity provider.

How often should backups run?

Nightly is a reasonable baseline, but critical teams may need hourly database dumps plus repository snapshots. The important requirement is tested restore capability.

How do I monitor this stack?

Watch container health, disk usage, Caddy certificate renewal, PostgreSQL availability, backup age, and HTTP/SSH reachability from outside the server.

Can this sit behind Cloudflare or another CDN?

The HTTPS web path can, but SSH traffic requires separate handling. Confirm real client IP logging, upload limits, and allowed methods before relying on a CDN.

Internal links

Talk to us

If you want this implemented with hardened defaults, observability, and tested recovery playbooks, our team can help.

Contact Us

Production Guide: Deploy Healthchecks with Docker Compose + Caddy + PostgreSQL on Ubuntu
Run a production-ready dead-man-switch monitoring service for cron jobs, backups, and scheduled automation with TLS and PostgreSQL.