Forgejo, Postgres, Nginx, Certbot, Backup routine
  • Shell 82.6%
  • Dockerfile 17.4%
Find a file
2026-06-15 15:47:10 +02:00
backup Dateien nach „/“ hochladen 2026-06-15 14:33:16 +02:00
certbot Dateien nach „/“ hochladen 2026-06-15 14:33:27 +02:00
forgejo Dateien nach „/“ hochladen 2026-06-15 14:33:37 +02:00
nginx fixed security headers 2026-06-15 15:47:10 +02:00
postgres Dateien nach „/“ hochladen 2026-06-15 14:33:55 +02:00
.env .env 2026-06-15 14:35:30 +02:00
compose.yml prevent privilege escalation via setuid binaries 2026-06-15 14:57:33 +02:00
README.md Dateien nach „/“ hochladen 2026-06-15 14:34:20 +02:00

Forgejo Stack — Deployment & Backup Guide

A self-hosted Forgejo (v15 LTS) instance built as four independent microservices — Forgejo, PostgreSQL 17, an nginx reverse proxy, and certbot — deployable with a single docker compose up -d, with automatic Let's Encrypt TLS on a subdomain and a consistent nightly backup routine based on forgejo dump and restic.

nginx terminates TLS for your subdomain (e.g. git.mydomain.tld) and is the only public HTTP(S) entry point, leaving the host free to serve other services on the same domain later. certbot obtains and renews the certificate over the ACME http-01 challenge and signals nginx to reload on each renewal. Git over SSH stays published directly on port 2222.

Project layout

forgejo-stack/
├── .env                          # All credentials & tunables (never commit)
├── compose.yml                   # Orchestration: four services, two networks
├── forgejo/
│   ├── Containerfile             # Forgejo 15 LTS + custom entrypoint
│   └── entrypoint.sh             # Validates env, waits for DB, hands off to s6
├── postgres/
│   ├── Containerfile             # PostgreSQL 17 (alpine) + custom entrypoint
│   └── entrypoint.sh             # Validates env, hands off to official init
├── nginx/
│   ├── Containerfile             # nginx (alpine) + envsubst + custom entrypoint
│   ├── default.conf.template     # HTTP+HTTPS vhost, rendered from env at runtime
│   └── entrypoint.sh             # Renders config, bootstraps before cert exists,
│                                 #   reloads on certbot's signal
├── certbot/
│   ├── Containerfile             # certbot (official) + custom entrypoint
│   └── entrypoint.sh             # Obtains cert on boot, renew loop, deploy hook
└── backup/
    ├── backup.sh                 # Stop → dump → start → restic snapshot
    ├── backup.env.example        # Backup configuration template
    ├── forgejo-backup.service    # systemd oneshot unit
    └── forgejo-backup.timer      # systemd timer (03:00 daily)

Architecture

Each service is a self-contained build unit. Neither knows anything about the other at build time; all wiring (hostnames, ports, credentials) is injected at runtime from .env through compose variable substitution. Both entrypoints fail fast with a clear message if a required variable is missing.

Networking is split in two. The database lives on an internal: true backend network with no route to the host or the outside world. Forgejo and nginx are both on the frontend network so nginx can reach forgejo:3000; Forgejo is additionally on backend to reach the database, so the only path to PostgreSQL is still through the application. nginx is the only service publishing HTTP(S) ports — 80 and 443 (HTTP_PORT / HTTPS_PORT in .env). Forgejo no longer publishes 3000; it is exposed only, reachable on the frontend network but not from the host. Git over SSH is still published directly on 2222 (FORGEJO_SSH_PORT), bypassing the proxy. certbot publishes nothing.

Forgejo runs behind a TLS-terminating proxy, so it speaks plain HTTP internally (FORGEJO__server__PROTOCOL: http) while ROOT_URL is https://; nginx forwards X-Forwarded-Proto so Forgejo generates correct HTTPS links.

State lives in two named volumes, not in the project directory: db-data (mounted at /var/lib/postgresql/data in the db container) and forgejo-data (mounted at /data in the Forgejo container — bare git repositories, LFS objects, avatars, and app.ini). On the host these sit under /var/lib/docker/volumes/forgejo-stack_*/_data; run docker volume inspect forgejo-stack_forgejo-data for the exact path. docker compose down keeps the volumes; only down -v destroys them.

Two more named volumes back the TLS plumbing: certbot-certs (/etc/letsencrypt in both nginx and certbot — the issued certificates plus a small reload-flag file) and certbot-webroot (/var/www/certbot — the .well-known/acme-challenge files certbot writes and nginx serves during the http-01 challenge).

Deployment

Prerequisites. Before you start, two things must be true for TLS to work:

  1. A DNS A/AAAA record for your subdomain (e.g. git.mydomain.tld) points at this host's public IP.
  2. Ports 80 and 443 are reachable from the internet (open the firewall; forward them if behind NAT). Port 80 is required for the ACME http-01 challenge — Let's Encrypt fetches a token from http://git.mydomain.tld/.well-known/acme-challenge/....

Edit .env first. At minimum set a real DB_PASS, your subdomain in FORGEJO_DOMAIN / FORGEJO_ROOT_URL, and CERTBOT_EMAIL. Leave CERTBOT_STAGING=1 for the first run — Let's Encrypt's production endpoint has strict rate limits, and staging lets you prove the whole flow without burning them. Then:

cd forgejo-stack
docker compose up -d --build

What happens on first boot. nginx finds no certificate yet and starts in a HTTP-only "bootstrap" mode that can answer the ACME challenge (everything else returns 503). certbot sees no cert for the domain and requests one via the shared webroot; on success it writes the cert to the certbot-certs volume and bumps a reload flag. nginx's watcher notices the cert, switches to the full HTTPS config, and reloads — no restart, no manual step. Watch it happen:

docker compose logs -f certbot nginx

Once you see a successful issuance, switch to real certificates: set CERTBOT_STAGING=0 in .env, then force certbot to replace the staging cert:

docker compose up -d --build --force-recreate certbot

(If a staging cert is already on disk, delete it first inside the certbot container or with docker compose run --rm --entrypoint "" certbot \ rm -rf /etc/letsencrypt/live/git.mydomain.tld \ /etc/letsencrypt/archive/git.mydomain.tld \ /etc/letsencrypt/renewal/git.mydomain.tld.conf, since certbot keeps a valid-but-untrusted cert by default.)

The web UI then comes up on https://git.mydomain.tld. The first visit walks you through the install screen; the database fields arrive pre-filled from the environment. There is no longer a localhost:3000 — nginx is the only HTTP(S) entry, and 3000 is not published to the host.

Startup ordering is layered: compose's healthcheck gates dbforgejo depends_on, the Forgejo entrypoint independently polls the DB port, and nginx's bootstrap mode means it never fails to start just because the cert isn't there yet — so the stack survives restarts and cold starts where compose ordering alone wouldn't be enough.

Everyday operations: docker compose logs -f forgejo to tail logs, docker compose pull && docker compose build --pull && docker compose up -d to upgrade within the pinned major version, docker compose down to stop without losing data.

Adding more services on the same domain later

nginx is now the front door, so putting another service on, say, ci.mydomain.tld is just another server { } block. The clean way: drop an extra template into nginx/ (or extend default.conf.template) with the new server_name and proxy_pass, add that hostname to the certbot domains (-d git.mydomain.tld -d ci.mydomain.tld, or issue a separate cert), and point its DNS at this host. The split-network pattern stays the same — attach the new backend to frontend so nginx can reach it, and keep its datastore on an internal network.

TLS & certificate renewal

The certificate lifecycle is split between the two new services so that neither needs a docker socket or knowledge of the other's container name — they coordinate only through two shared volumes.

certbot obtains the first certificate on startup (via the http-01 webroot challenge) if none exists, then enters a loop that runs certbot renew every 12 hours. renew is a no-op until a cert is within 30 days of expiry, so this cadence is cheap and is the documented recommendation. Every successful issue or renewal triggers certbot's deploy hook, which writes a timestamp into /etc/letsencrypt/reload.flag.

nginx renders its vhost from default.conf.template with envsubst at startup, picking the server name and upstream out of the environment. A small background watcher in its entrypoint polls every 30 seconds: it promotes the HTTP-only bootstrap config to the full HTTPS config the moment the first cert appears, and on any later change to the reload flag it runs nginx -s reload, picking up the renewed cert with zero downtime.

Useful commands:

# See current certs and expiry (real or staging)
docker compose exec certbot certbot certificates

# Force a renewal now, ignoring the 30-day window (for testing)
docker compose exec certbot certbot renew --force-renewal \
    --webroot -w /var/www/certbot \
    --deploy-hook 'echo $(date +%s) > /etc/letsencrypt/reload.flag'

# Reload nginx by hand if ever needed
docker compose exec nginx nginx -s reload

forgejo dump only produces full archives — it has no incremental mode. The routine therefore takes a full dump every night and hands it to restic, a deduplicating backup tool. Restic splits the archive into content-defined chunks and uploads only chunks it has not seen before, so unchanged git data costs almost nothing per night. Storage growth is roughly one full dump plus the daily churn, while every snapshot remains restorable as a complete point-in-time copy.

Two decisions in backup.sh matter for correctness and efficiency:

The dump is uncompressed tar (--type tar), never zip. Compression shuffles bytes on every run and defeats deduplication; plain tar of mostly unchanged repositories dedupes extremely well.

The Forgejo service is stopped during the dump while PostgreSQL stays up (the dump reads the database through Forgejo's own connection). With the application down, no pushes or web actions can land mid-dump, so the database and the repository archive are captured at the same logical instant. Downtime is the dump duration — typically one or two minutes for a small instance, at 03:00. A trap in the script guarantees the service is restarted even if the dump fails, and the service restarts before the restic upload begins, so slow uploads never extend the outage.

Because the service container is stopped, the dump runs in a throwaway container (docker compose run --rm --no-deps --entrypoint "") created from the same image, volumes, and environment, with the s6 init and DB-wait bypassed. The result lands in a host staging directory that restic then snapshots.

Backup setup

Install restic on the host (apt install restic on Debian/Ubuntu — any recent version is fine). Then create the password file and staging area, and initialize the repository:

# Password file (pick a strong passphrase; losing it = losing all backups)
echo 'my-strong-restic-passphrase' > /root/.restic-pass
chmod 600 /root/.restic-pass

# Staging dir, writable by the container UID (FORGEJO_UID, default 1000)
mkdir -p /var/backups/forgejo/staging
chown 1000:1000 /var/backups/forgejo/staging

# Initialize the restic repository (one-time)
export RESTIC_PASSWORD_FILE=/root/.restic-pass
restic init --repo /var/backups/forgejo/restic

Configure the routine by copying the template and adjusting paths:

cd /opt/forgejo-stack/backup        # wherever you placed the project
cp backup.env.example backup.env
chmod 600 backup.env
chmod +x backup.sh
$EDITOR backup.env                  # set COMPOSE_DIR, RESTIC_REPOSITORY, ...

A repository on the same machine only protects against accidental deletion. For real protection point RESTIC_REPOSITORY at an off-host target, e.g. sftp:backup@otherhost:/srv/restic/forgejo or s3:s3.amazonaws.com/my-bucket/forgejo (S3 additionally needs AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY exported in backup.env).

Do a manual run before scheduling anything:

./backup.sh
restic snapshots        # should list one snapshot tagged "forgejo"

Then install the schedule. If ExecStart in forgejo-backup.service does not match your actual script path, edit it first:

cp forgejo-backup.service forgejo-backup.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now forgejo-backup.timer
systemctl list-timers forgejo-backup.timer    # confirm next run at 03:00

Persistent=true in the timer means a machine that was off at 03:00 runs the backup at next boot instead of silently skipping the night. If you prefer cron over systemd, the equivalent is a single line: 0 3 * * * /opt/forgejo-stack/backup/backup.sh >> /var/log/forgejo-backup.log 2>&1.

Using the backups

Day-to-day inspection:

export RESTIC_REPOSITORY=/var/backups/forgejo/restic
export RESTIC_PASSWORD_FILE=/root/.restic-pass

restic snapshots                  # list all point-in-time snapshots
restic stats                      # total repository size
journalctl -u forgejo-backup      # logs of recent runs
restic check                      # repository integrity (run monthly)

Retention is applied automatically after each run: 7 daily, 4 weekly and 6 monthly snapshots are kept (configurable in backup.env), everything older is pruned. That gives roughly six months of history.

Restore procedure

Restoring is a three-stage process: get the dump back from restic, unpack it, and feed the pieces into a fresh stack.

Stage 1 — retrieve the dump. Pick a snapshot (restic snapshots), then:

restic restore latest --target /tmp/restore      # or a snapshot ID
tar xf /tmp/restore/var/backups/forgejo/staging/forgejo-dump.tar \
    -C /tmp/restore/unpacked

The unpacked dump contains forgejo-db.sql (database), repos/ (bare git repositories), data/ (LFS, avatars, attachments) and app.ini.

Stage 2 — fresh stack, restore the database. On the target machine, deploy the stack with the same .env, then start only the database and load the SQL:

cd /opt/forgejo-stack
docker compose up -d db
docker compose exec -T db psql -U forgejo -d forgejo \
    < /tmp/restore/unpacked/forgejo-db.sql

Stage 3 — restore the data volume, start Forgejo. Copy the repositories and data into the forgejo-data volume using a throwaway container, fix ownership, then bring the application up:

docker compose run --rm --no-deps --entrypoint "" --user 0:0 \
    -v /tmp/restore/unpacked:/restore forgejo sh -c '
      mkdir -p /data/git /data/gitea/conf &&
      cp -a /restore/repos/.        /data/git/repositories/ &&
      cp -a /restore/data/.         /data/gitea/ &&
      cp    /restore/app.ini        /data/gitea/conf/app.ini &&
      chown -R 1000:1000 /data'

docker compose up -d

Finally, regenerate derived state from inside the running container — git hooks and authorized SSH keys are not part of the dump:

docker compose exec -u 1000 forgejo \
    forgejo admin regenerate hooks -c /data/gitea/conf/app.ini
docker compose exec -u 1000 forgejo \
    forgejo admin regenerate keys -c /data/gitea/conf/app.ini

Log in, open a repository, clone it, push a test commit. Do this test restore once now, on a scratch machine or VM — a backup that has never been restored is a hypothesis, not a backup.

Troubleshooting

Dump fails with permission errors — the staging directory must be writable by UID 1000 (chown 1000:1000 $STAGING_DIR).

Forgejo stayed down after a failed backup — the trap should prevent this, but docker compose start forgejo always recovers; check journalctl -u forgejo-backup for the root cause.

Restic snapshots grow more than expected — confirm the dump is tar, not zip (--type tar in backup.sh), and that nothing else writes into the staging directory.

"repository is already locked" — a previous run was interrupted; restic unlock clears stale locks safely.

Certificate never issued / nginx stuck on 503 — the http-01 challenge needs port 80 reachable from the internet and DNS for the subdomain pointing here. Check docker compose logs certbot; a "Connection refused" or "NXDOMAIN" there means DNS or the firewall, not the stack. Confirm the challenge path is served: curl http://git.mydomain.tld/.well-known/acme-challenge/test should reach nginx (404 is fine; a connection timeout is not).

Browser warns the cert is untrusted — you are still on the staging issuer. Set CERTBOT_STAGING=0, remove the staging cert (see Deployment), and recreate certbot.

"too many certificates already issued" — you hit Let's Encrypt's production rate limit, usually by recreating with CERTBOT_STAGING=0 repeatedly. Switch back to staging while you debug; the limit resets after a week.

Forgejo shows http:// links or redirect loops — make sure FORGEJO_ROOT_URL is https://... and that you didn't also leave Forgejo trying to do its own TLS; it must stay on FORGEJO__server__PROTOCOL: http behind nginx.