- Go 76.6%
- Go Template 17.2%
- Shell 6.2%
| scripts | ||
| .gitignore | ||
| BLUE-GREEN.md | ||
| bootstrap.yaml | ||
| Caddyfile.tmpl | ||
| docker-compose.yml.tmpl | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| main.go | ||
| Pulumi.yaml | ||
| README.md | ||
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 thesshPublicKeyconfig 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)
pulumiandgoinstalled- 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/admin — needs 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 withignoreChanges, 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'scommand.remoteprovider. Changing any value above and runningpulumi upre-runsdocker compose up -don 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.iniby the container entrypoint on every boot. Do not hand-editapp.inior the admin UI for these — the entrypoint regenerates it and your edits are lost. Change them here andpulumi up.
First use
- SSH in and read the generated admin password:
ssh root@<ip> 'cat /root/forgejo-admin-credentials.txt' - Open
webURL, log in asforgejo-admin— you'll be forced to set a new password. - 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 holdsSECRET_KEY— needed to decrypt 2FA/mirror secrets), plusdocker-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
backupResticPasswordsomewhere 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 restorealso rewrites/opt/forgejo/docker-compose.ymlfrom the snapshot, so Forgejo comes back on the image version captured in the backup, not whateverforgejoImageyour config currently says. To move to a different version afterwards, setforgejoImageandpulumi up— the changed tag is a real diff, so the compose is re-pushed and Forgejo migrates the DB forward on boot. Note a plainpulumi upwon'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--replacefallback).
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):
- DNS:
git→ A record to the server IP, Proxied (orange cloud). - 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:
- Cloudflare dashboard → SSL/TLS → Origin Server → Create Certificate. Save the certificate and private key as local PEM files.
- 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:
Both must be set together. The key is pushed topulumi 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/opt/forgejo/caddy/origin.keywithchmod 600; rotating either value redeploys Caddy. - 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):
-
In Forgejo: Settings → Applications → Generate New Token.
-
Give it a name and the repository read/write scope, then copy the token (shown only once).
-
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
domainis set — see "Custom domain + HTTPS" above. Without a domain, Forgejo is served over plain HTTP on port 80. - Forgejo data lives in
/opt/forgejo/dataon the server (Docker volume bind mount).