No description
  • Go 76.6%
  • Go Template 17.2%
  • Shell 6.2%
Find a file
2026-07-06 23:22:41 +02:00
scripts fixes for blue green restore 2026-07-05 00:24:10 +02:00
.gitignore add forcing mfa for new users 2026-07-06 22:33:44 +02:00
BLUE-GREEN.md upgrade forgejo procedure docs 2026-07-06 08:48:04 +02:00
bootstrap.yaml init 2026-07-04 22:30:04 +02:00
Caddyfile.tmpl init 2026-07-04 22:30:04 +02:00
docker-compose.yml.tmpl init 2026-07-04 22:30:04 +02:00
go.mod init 2026-07-04 22:30:04 +02:00
go.sum init 2026-07-04 22:30:04 +02:00
LICENSE init 2026-07-04 22:30:04 +02:00
main.go add forcing mfa for new users 2026-07-06 22:33:44 +02:00
Pulumi.yaml add forcing mfa for new users 2026-07-06 22:33:44 +02:00
README.md readme updates 2026-07-06 23:22:41 +02:00

Forgejo on Hetzner (Pulumi + Go)

Provisions a Hetzner Cloud server (default cx23, all configurable) running Forgejo via Docker Compose, reachable over SSH with your key. Set a domain and it also runs a Caddy reverse proxy for HTTPS (Cloudflare proxied).

What it creates

  • hcloud.SshKey — uploads your admin public key (from the sshPublicKey config value)
  • hcloud.Firewall — inbound 22 (admin SSH), 80/443 (web)
  • hcloud.Server — Ubuntu 24.04, with cloud-init that installs Docker; Forgejo (and Caddy) are then deployed over SSH

Port mapping on the host:

Host port Goes to Purpose
22 host sshd Admin SSH
80 Caddy / container Forgejo web (HTTP, or ACME/redirect when a domain is set)
443 Caddy :443 Forgejo web over HTTPS (only when a domain is set)

Without a domain, port 80 maps straight to the Forgejo container (:3000) and 443 is unused. With a domain, Caddy owns 80/443 and proxies to the container.

Deployment model

Pulumi provisions the immutable infrastructure (server, firewall, SSH key), then uses the command.remote provider to push the rendered compose file over SSH and run docker compose up -d on the box. Config changes are applied to the running server this way — no rebuilds, data preserved (see How config is applied).

This is a deliberately simple, single-host setup: one server running Docker Compose, backed up nightly to S3. It's sized for individual or small-team use, where one box and a push-over-SSH deploy are the right amount of machinery rather than a container orchestrator.

Prerequisites

  • A Hetzner Cloud API token (Project → Security → API tokens, Read & Write)
  • pulumi and go installed
  • First-time provisioning only: an admin SSH keypair to load into the stack. Create a dedicated one if you don't have it: ssh-keygen -t ed25519 -f ~/.ssh/id_forgejo -N ''. Once it's stored in config (step 4 below), later machines pull it from the stack — no local key needed.

Starting the project for the first time

From an empty checkout to a running Forgejo:

git clone <repo>
cd forgejo-hetzner

# 1. State backend. Pulumi Cloud is recommended (see "Running from another machine").
pulumi login                 # or: pulumi login --local

# 2. Create the stack (skip if a committed Pulumi.<stack>.yaml already exists — then
#    just `pulumi stack select dev`).
pulumi stack init dev

# 3. Hetzner API token (encrypted into the stack).
pulumi config set --secret hcloud:token <YOUR_HETZNER_TOKEN>

# 4. Admin SSH key — stored (encrypted) in the stack, so pulumi up runs from any machine.
pulumi config set --secret forgejo-hetzner:sshPrivateKey < ~/.ssh/id_forgejo
pulumi config set          forgejo-hetzner:sshPublicKey "$(cat ~/.ssh/id_forgejo.pub)"

# 5. Optional overrides
# pulumi config set forgejo-hetzner:serverType cx33
# pulumi config set forgejo-hetzner:location hel1
# pulumi config set forgejo-hetzner:sshAllowedCidr 1.2.3.4/32   # lock SSH to your IP

# 6. Provision.
pulumi up

Outputs after pulumi up:

  • serverIPv4, webURL, sshCommand, gitCloneExample, adminPasswordFile

Then follow First use to read the generated admin password and log in.

SSH key

Pulumi needs SSH access to the server both to register your public key and to push the Forgejo config over the command.remote provider. The keypair is read only from config — sshPrivateKey (secret) and sshPublicKey, stored encrypted in the stack — so pulumi up works from any machine. Both are required.

Running from another machine

With the Hetzner token and the SSH private key living encrypted in the stack on Pulumi Cloud, a second machine needs no local secrets or key files:

git clone <repo>
cd forgejo-hetzner
pulumi login          # Pulumi Cloud decrypts the stack config automatically
pulumi stack select dev
pulumi up

The encrypted private key is committed in Pulumi.<stack>.yaml. That's safe only because Pulumi's secrets provider encrypts it — anyone who can decrypt the stack (your Pulumi Cloud org) can read it, exactly like the Hetzner token already there. Prefer a dedicated, passphrase-less automation key over a personal one.

Forgejo configuration (all provisioned via Pulumi)

These are rendered into the compose file (docker-compose.yml.tmpl) as Docker env vars and pushed over SSH, so Forgejo boots fully configured — no public install wizard.

Config key Default Effect
installLock true Locks the installer; no public wizard
disableRegistration true No user self-registration
requireSigninView true Login required for any page/API (hides Users/Orgs tabs from anonymous)
disableUsersPage false Hide /explore/users (for a still-public site)
disableOrganizationsPage false Hide /explore/organizations
globalTwoFactorRequirement none Require 2FA: none/all/adminneeds Forgejo ≥ v13
adminUsername forgejo-admin Auto-created admin
adminEmail admin@localhost Admin email
forgejoImage codeberg.org/forgejo/forgejo:15 Container image

Example:

pulumi config set forgejo-hetzner:requireSigninView true
pulumi config set forgejo-hetzner:adminEmail you@example.com

How config is applied (no server rebuilds)

Forgejo config is decoupled from the server lifecycle:

  • bootstrap.yaml (cloud-init / UserData) only installs Docker. It's static and pinned with ignoreChanges, so it never triggers a server replacement.
  • The compose file + Forgejo settings are rendered from config (docker-compose.yml.tmpl) and pushed over SSH by Pulumi's command.remote provider. Changing any value above and running pulumi up re-runs docker compose up -d on the existing server — no replacement, data preserved.

This requires Pulumi to reach the server over SSH using the private key from config (sshPrivateKey) — see SSH key.

These env vars are re-applied to app.ini by the container entrypoint on every boot. Do not hand-edit app.ini or the admin UI for these — the entrypoint regenerates it and your edits are lost. Change them here and pulumi up.

First use

  1. SSH in and read the generated admin password: ssh root@<ip> 'cat /root/forgejo-admin-credentials.txt'
  2. Open webURL, log in as forgejo-admin — you'll be forced to set a new password.
  3. Git clone over HTTPS: git clone https://<domain>/<owner>/<repo>.git (authenticate with a Forgejo access token). Git-over-SSH is disabled.

Cloud-init runs on first boot; Forgejo may take a couple of minutes to come up. Check with ssh root@<ip> 'docker compose -f /opt/forgejo/docker-compose.yml ps'.

Applying config changes: just edit the config values and run pulumi up. Changes are applied to the running server over SSH (docker compose up -d); the server is not replaced and Forgejo data is preserved.

Backups (restic → S3)

Optional nightly, zero-downtime backup of all Forgejo data to an S3-compatible bucket (AWS / Wasabi / Cloudflare R2 / …). It backs up everything that matters:

  • a consistent copy of the SQLite DB (via SQLite's online backup API — no container stop), and
  • the live git repos, LFS, attachments and app.ini (which holds SECRET_KEY — needed to decrypt 2FA/mirror secrets), plus docker-compose.yml.

A systemd timer runs it nightly; retention keeps 7 daily / 4 weekly / 6 monthly.

Enable

pulumi config set        forgejo-hetzner:backupS3Repository 's3:https://s3.eu-central-1.wasabisys.com/my-bucket/forgejo'
pulumi config set --secret forgejo-hetzner:backupResticPassword <a-strong-password>
pulumi config set --secret forgejo-hetzner:backupS3AccessKey    <ACCESS_KEY_ID>
pulumi config set --secret forgejo-hetzner:backupS3SecretKey    <SECRET_ACCESS_KEY>
# optional: pulumi config set forgejo-hetzner:backupSchedule '*-*-* 03:30:00'
pulumi up

Store backupResticPassword somewhere off the server (a password manager). Without it the backups cannot be decrypted or restored.

Verify / run on demand

ssh root@<ip> 'systemctl start forgejo-backup.service && journalctl -u forgejo-backup.service -n 30 --no-pager'
ssh root@<ip> 'set -a; . /opt/forgejo/backup.env; restic snapshots'

Restore (full recovery)

On a fresh server (pulumi up) — Forgejo is stopped or not yet started:

ssh root@<ip>
set -a; . /opt/forgejo/backup.env
docker compose -f /opt/forgejo/docker-compose.yml down
restic restore latest --target /                 # rewrites /opt/forgejo/data + compose
mv /opt/forgejo/.backup/forgejo.db /opt/forgejo/data/gitea/forgejo.db
rm -f /opt/forgejo/data/gitea/forgejo.db-wal /opt/forgejo/data/gitea/forgejo.db-shm
chown 1000:1000 /opt/forgejo/data/gitea/forgejo.db   # else Forgejo (uid 1000) hits "readonly database"
docker compose -f /opt/forgejo/docker-compose.yml up -d

That brings back repos, users, issues/PRs, LFS and encrypted secrets exactly as of the last snapshot. restic restore <snapshot-id> picks an earlier point in time.

restic restore also rewrites /opt/forgejo/docker-compose.yml from the snapshot, so Forgejo comes back on the image version captured in the backup, not whatever forgejoImage your config currently says. To move to a different version afterwards, set forgejoImage and pulumi up — the changed tag is a real diff, so the compose is re-pushed and Forgejo migrates the DB forward on boot. Note a plain pulumi up won't "repair" a restored (reverted) compose if the tag is unchanged — Pulumi can't see the on-disk drift. See BLUE-GREEN.md §3b (upgrade + the --replace fallback).

Test a restore locally (Docker)

Verify the backup is actually recoverable without touching the server — this is read-only against the restic repo. Do this periodically; a backup you've never restored isn't a backup.

Restore into a directory under your home folder — Docker Desktop shares ~ by default, whereas /tmp is not shared, so a bind mount there lands inside the Docker VM and the files never appear on the host.

export RESTIC_REPOSITORY='s3:https://s3.eu-central-1.amazonaws.com/<bucket>/forgejo'
export RESTIC_PASSWORD='...'
export AWS_ACCESS_KEY_ID='...'
export AWS_SECRET_ACCESS_KEY='...'

mkdir -p ~/forgejo-restore

# 1. Confirm snapshots are visible, then restore the latest (via the restic image
#    so nothing needs installing). Env vars pass through to the container.
docker run --rm -e RESTIC_REPOSITORY -e RESTIC_PASSWORD -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \
  restic/restic snapshots
docker run --rm -e RESTIC_REPOSITORY -e RESTIC_PASSWORD -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \
  -v ~/forgejo-restore:/restore \
  restic/restic restore latest --target /restore

ls ~/forgejo-restore/opt/forgejo   # expect: .backup  data  docker-compose.yml

# 2. Move the consistent DB copy into place (it's backed up separately).
R=~/forgejo-restore/opt/forgejo
mv "$R/.backup/forgejo.db" "$R/data/gitea/forgejo.db"
rm -f "$R/data/gitea/forgejo.db-wal" "$R/data/gitea/forgejo.db-shm"

# 3. Run Forgejo locally against the restored data. The ROOT_URL/DOMAIN overrides
#    just stop it redirecting to the production domain during the test (they edit
#    only this throwaway copy's app.ini).
docker run --rm -it \
  -p 3000:3000 \
  -v "$R/data:/data" \
  -e USER_UID=1000 -e USER_GID=1000 \
  -e FORGEJO__server__ROOT_URL=http://localhost:3000/ \
  -e FORGEJO__server__DOMAIN=localhost \
  codeberg.org/forgejo/forgejo:15

Open http://localhost:3000 and confirm you can log in (proves the DB + app.ini SECRET_KEY restored), your repos browse (proves data/git), and issues/PRs show up (proves DB content). Then Ctrl-C and rm -rf ~/forgejo-restore.

On macOS Docker Desktop the uid-1000 ownership is handled transparently. On a Linux host, run sudo chown -R 1000:1000 "$R/data" before step 3 if Forgejo errors on permissions.

Tear down

pulumi destroy

Custom domain + HTTPS (Cloudflare proxied)

Set domain and Pulumi adds a Caddy reverse proxy that serves HTTPS and points Forgejo's ROOT_URL/DOMAIN at it:

pulumi config set forgejo-hetzner:domain git.example.com
pulumi up

Manual Cloudflare steps (DNS is not managed by Pulumi):

  1. DNS: git → A record to the server IP, Proxied (orange cloud).
  2. SSL/TLS encryption mode: "Full". Caddy uses a self-signed internal cert (tls internal), so Flexible causes redirect loops and Full(strict) rejects the cert — Full is the correct setting.

Full (strict) with a Cloudflare Origin Certificate

Full encrypts the Cloudflare↔origin hop but does not validate the origin cert, leaving that hop open to an active MITM. Full (strict) closes it by requiring a cert Cloudflare trusts. Since the origin is only ever reached through Cloudflare, a free Origin CA certificate (trusted by CF's edge, up to 15-year validity, no renewal) is the simplest fit:

  1. Cloudflare dashboard → SSL/TLS → Origin Server → Create Certificate. Save the certificate and private key as local PEM files.
  2. Load their contents into Pulumi as secret config (stored encrypted in the stack; no loose files to keep around afterwards), then redeploy — Caddy serves this cert instead of the self-signed one:
    pulumi config set --secret forgejo-hetzner:originCert < git-origin.pem
    pulumi config set --secret forgejo-hetzner:originKey  < git-origin.key
    rm git-origin.pem git-origin.key   # optional — they now live in the stack
    pulumi up
    
    Both must be set together. The key is pushed to /opt/forgejo/caddy/origin.key with chmod 600; rotating either value redeploys Caddy.
  3. Switch the Cloudflare SSL/TLS mode to "Full (strict)". Optionally enable Authenticated Origin Pulls so the origin also rejects any non-CF client.

Unset both values to fall back to tls internal + Full.

git access

Git-over-SSH is disabled (Forgejo's SSH server is off and port 2222 is not exposed). Clone over HTTPS, which rides the Cloudflare proxy:

git clone https://git.example.com/<owner>/<repo>.git

Authenticating with an access token

Forgejo accepts your account password over HTTPS by default, but a scoped access token is recommended (and required once you enable 2FA):

  1. In Forgejo: Settings → Applications → Generate New Token.

  2. Give it a name and the repository read/write scope, then copy the token (shown only once).

  3. When git prompts, use your Forgejo username and paste the token in the password field — git's prompt always says Password: regardless:

    Username for 'https://git.example.com': <your-forgejo-username>
    Password for 'https://<user>@git.example.com': <paste token>
    

Tokens can be scoped, given an expiry, and revoked independently — so a leak doesn't expose your whole account.

Storing the token (credential helper)

So git stops prompting on every pull/push, enable a credential helper once:

  • macOS — store the token encrypted in Keychain:

    git config --global credential.helper osxkeychain
    
  • Linux — use libsecret (GNOME Keyring / KWallet):

    git config --global credential.helper libsecret
    
  • In-memory only (forgets after a timeout, good for shared machines):

    git config --global credential.helper 'cache --timeout=3600'
    

Do the first clone/pull and enter the username + token when prompted; the helper caches it for subsequent operations. To scope the helper to just this instance, use credential.https://git.example.com.helper instead of the global key.

Avoid credential.helper store (writes the token in plaintext to ~/.git-credentials) and embedding the token in the remote URL (lands in .git/config and can leak into logs).

Notes

  • HTTPS is handled by Caddy when a domain is set — see "Custom domain + HTTPS" above. Without a domain, Forgejo is served over plain HTTP on port 80.
  • Forgejo data lives in /opt/forgejo/data on the server (Docker volume bind mount).