Lately GitHub hasn't had good press: questionable stability, serious vulnerabilities, and on top of that nobody can be 100% sure their code doesn't end up as training data. You can of course pick another SaaS - Bitbucket, GitLab, Gitea or Codeberg - but that still means shipping your code off to cloud providers. The alternative is a self-hosted service, which really isn't that hard to pull off, at least for personal use or smaller teams.
The proposed setup is a single virtual machine on Hetzner running Forgejo via docker-compose, deployed with Pulumi in Go. Client-side HTTPS traffic is proxied through Cloudflare, and on the server side the connection to Cloudflare is secured by Caddy. Data is backed up every night (encrypted with restic) to an S3-compatible bucket (AWS in this case).
Infrastructure as code
The deployment is handled entirely by Pulumi, but it's split into two parts: the immutable infrastructure and the application configuration.
The infrastructure is a VM on Hetzner (cx23 by default), a firewall and an SSH key. The application configuration is the "moving parts": the container image, the domain, the Forgejo settings and optionally the backup. Cloud-init in UserData (bootstrap.yaml) is only responsible for installing and starting Docker. It's deliberately static and pinned in code via ignoreChanges, because any change to it would force the machine to be rebuilt.
The Forgejo configuration itself is rendered from Pulumi values into a docker-compose.yml file and pushed over SSH by the command.remote provider, which runs docker compose up -d on the server.
The SSH key and the Hetzner token live encrypted in the stack (Pulumi Cloud), so pulumi up works from any machine - without keeping secrets locally in files.
Forgejo configuration
A fresh Forgejo instance exposes the install wizard by default, so for this deployment the entire configuration is contained in environment variables for docker-compose, and Forgejo comes up already configured:
| Setting | Default | Effect |
|---|---|---|
installLock |
true |
Blocks the install wizard |
disableRegistration |
true |
No self-service user registration |
requireSigninView |
false |
Public repositories readable without signing in |
adminUsername / adminEmail |
— | Admin account created automatically |
The admin password is generated on the server and written to a file that you read over SSH on first login (you'll be forced to change it anyway).
HTTPS: Caddy behind Cloudflare
The Pulumi code doesn't include Cloudflare settings; for the chosen domain you need to add a DNS record pointing at the Hetzner instance's IP (in proxied mode). For maximum origin protection, Caddy requires an Origin CA Certificate from Cloudflare (the cert and private key are supplied through Pulumi secrets), and Cloudflare's TLS must be set to Full (Strict). Direct access to Forgejo via the instance's IP is blocked at the firewall level, which on the web ports only lets in Cloudflare's published IP ranges.
Git access over HTTPS only
Accessing the Git server solely through the Cloudflare proxy hides the origin IP and makes it impossible to clone repositories over SSH, but HTTPS with a token gets the job done.
Backup: restic → S3
Backup is optional and is enabled by setting an S3 repository and the secrets. The script runs the SQLite backup via the Online Backup API, which allows copying without having to stop the database. The copy then becomes part of an encrypted restic snapshot (together with the repository files, LFS, attachments and the app.ini file).
Every backup is only worth as much as its tested restore, which is why Pulumi allows a quick restore - you just add another Pulumi stack in the same repository that stands up a separate Forgejo instance alongside. The procedure for installing snapshots from restic is described in README.md.
The blue-green mechanism also comes in handy for upgrading the server - any problems with the new version don't affect the running instance.
Security
The biggest safeguard here is a reduced attack surface, not patching individual holes. Registration is disabled (disableRegistration=true), so only the admin creates accounts. In this setup requireSigninView is set to false, which makes public repositories readable without logging in - so anonymous traffic sees the public repos, the login page and part of the API, but can't create an account or reach private repositories. User and organization listings are additionally hidden (disableUsersPage, disableOrganizationsPage). Cloudflare sits in front of the origin (WAF, rate limiting, IP hiding), and the firewall only lets Cloudflare's IP ranges onto the web ports - the proxy can't be bypassed.
Additional layers: SSH access narrowed to your own IP (or hidden behind a VPN), git over HTTPS only with a scoped token, globally enforced MFA. For maximum protection the whole thing can be tucked behind Cloudflare Access or a VPN, so that not even the login page is publicly reachable. The restic password and the SECRET_KEY (from app.ini) are kept off the server, so that a compromise of the machine doesn't mean losing the backups.
Summary
In my opinion the proposed solution is sufficient for personal use - it lets you host both private and public repositories. A complete setup, including the Cloudflare configuration, can be done in 30 minutes, and the cost of a cx23 instance is a few euros a month.
The code for the deployment can be found here.