Views expressed are my own and not those of my employer.
Why This Matters
At some point every infrastructure grows past the “I’ll remember how I set this up” threshold. Mine hit it when I found myself fixing the same hook script for the third time because I’d never written down why I’d changed it.
Git is the obvious answer — but where Git runs matters. GitHub works fine for public stuff. For infrastructure configs, Home Assistant automations, Frigate setups — anything that describes how my systems actually work — I want that on hardware I control, not on a third-party platform. Self-hosted Git is a natural extension of the same reasoning that puts everything else on self-hosted infrastructure.
I chose Forgejo. It’s a community fork of Gitea, created after Gitea’s commercialization in 2022 — actively maintained, GDPR-friendly, and comfortable on minimal resources. I run Proxmox on this server — here’s why.
Architecture
Internet → CrowdSec Bouncer (OPNsense)
→ Caddy (reverse proxy, TLS)
→ Forgejo LXC (Debian 13)
└── CrowdSec Agent
└── Reports to central LAPI
Forgejo runs as an unprivileged LXC container on Proxmox. Caddy handles TLS termination. CrowdSec runs at two layers: a bouncer on OPNsense blocks known malicious IPs before they reach Caddy, and a dedicated agent inside the Forgejo LXC detects brute-force login attempts and reports them back to the central LAPI.
Installation
The Proxmox Community Scripts project has a one-liner for Forgejo. From the Proxmox host shell:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/forgejo.sh)"
Select Advanced, then Debian 13. The script creates the LXC, installs Forgejo, and configures systemd.
Resources I allocated:
- 2 CPU cores
- 2048 MB RAM
- 10 GB disk
- Static internal IP
Initial Configuration
After installation, Forgejo is accessible on port 3000. The first-run wizard handles everything.
Database: SQLite3, not MySQL. For a single-person instance this is the right call — simpler backups, no separate service to maintain.
Settings to get right in the wizard:
- Server domain: your public domain, not the internal IP
- Base URL:
https://git.example.com/ - Disable self-registration: yes — critical for a private instance
- Admin account: create it here, not after the fact
Mistake I made: I set the Base URL to the public HTTPS domain before Caddy was configured. Forgejo then redirected the setup wizard to the public URL, which wasn’t reachable yet. Fix: set it to http://<lxc-ip>:3000/ for the initial setup, then change it in app.ini once Caddy is in place.
Caddy
Caddy is already running in its own LXC and handles all reverse proxying. Adding Forgejo is a single stanza in the Caddyfile:
git.example.com {
reverse_proxy <forgejo-lxc-ip>:3000
}
Reload Caddy. TLS is handled automatically.
One critical step after this: tell Forgejo to trust the forwarded client IP from Caddy, or all log lines will show the proxy’s internal IP. That breaks CrowdSec — it would analyse the wrong IP entirely.
In /etc/forgejo/app.ini:
[security]
REVERSE_PROXY_TRUSTED_PROXIES = <caddy-lxc-ip>
[server]
DOMAIN = git.example.com
ROOT_URL = https://git.example.com/
Without this, brute-force detection is effectively blind.
Security Hardening
2FA on the admin account. Non-negotiable. Enable it in user settings immediately after initial setup.
Branch protection on all private repos. No direct pushes to main, even for the admin account. Configured under repo Settings → Branches → Branch protection rules.
Bot users for automation have web login disabled — API token only, scoped to specific repos.
The full setup — organisations, repos, teams, users, branch protection — is scripted via the Forgejo REST API. I’ll cover that in a separate post.
CrowdSec Integration
This is where things got interesting — and where I’ll be honest about the bumps.
There is no official crowdsecurity/forgejo collection. There is a LePresidente/gitea collection, but the parser fails on Forgejo’s log format out of the box. After some back-and-forth, the working solution involves three steps:
1. Enable file logging in Forgejo. CrowdSec needs a log file to tail. By default, Forgejo logs to the systemd journal — which works poorly with the gitea parser.
In /etc/forgejo/app.ini:
[log]
MODE = console, file
LEVEL = info
ROOT_PATH = /var/lib/forgejo/log
Restart Forgejo. Verify the log file appears before continuing.
2. Install the gitea collection:
cscli collections install LePresidente/gitea
3. Create a custom parser. Copy the gitea parser, rename it local/forgejo-logs, and change the filter condition so it evaluates all log lines rather than filtering by program name. This lets the parser reach the failed authentication patterns it cares about.
Acquisition config in /etc/crowdsec/acquis.d/forgejo.yaml:
filenames:
- /var/lib/forgejo/log/forgejo.log
labels:
type: gitea
Test the result:
cscli explain --type gitea --log "$(tail -1 /var/lib/forgejo/log/forgejo.log)"
You should see parser success 🟢 and the LePresidente/gitea-bf scenario triggering on failed login lines. Five quick failed logins from an external IP resulted in a complete lockout — exactly as intended.
Automatic Updates
Every LXC in this infrastructure runs unattended-upgrades. Forgejo is no exception.
apt install -y unattended-upgrades apt-listchanges
dpkg-reconfigure -plow unattended-upgrades
Then tune /etc/apt/apt.conf.d/50unattended-upgrades:
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Mail "your-alert-address([aet])example.com";
Unattended-Upgrade::MailReport "only-on-error";
Automatic-Reboot "false" is intentional — a reboot of an LXC happens manually, at a time I choose.
Verify the config is active:
unattended-upgrades --dry-run --debug 2>&1 | grep -i "Allowed\|Checking"
Monitoring
Three layers, all three present:
UptimeRobot watches https://git.example.com from outside. If the endpoint stops responding, I get an alert before I notice it myself.
HetrixTools covers the same endpoint for blacklist monitoring. A self-hosted Git service sending mail notifications landing on a blacklist would be an unpleasant surprise.
Netdata runs on the Proxmox host and on the LXC. Container-level CPU, memory, and disk metrics are visible from the internal dashboard — useful when a runaway process or disk fill needs diagnosing without SSH-ing in first.
Branding
Forgejo supports custom templates and CSS. I replaced the default Forgejo branding with the gex.guru style — dark terminal aesthetic, monospace typography, custom accent colour. The process involves overriding a handful of template files and adding a custom CSS file under the Forgejo custom directory.
One non-obvious gotcha: the favicon for the logged-in interface comes from a different template than the public landing page. If your favicon change only works when logged out, check custom/templates/custom/header.tmpl as well.
Where My AI Assistant Struggled
I use Claude extensively for infrastructure work, and this was a productive session — but not without friction.
CrowdSec rabbit hole. We went back and forth several times on whether to run a CrowdSec agent in the Forgejo LXC at all. First skip it, then add it, then the parser didn’t work, then we tried journald, then file logging. The correct answer — custom parser, file logging, proper IP forwarding — took longer to reach than it should have. The AI kept second-guessing the architecture rather than committing to a solution.
Template confusion. The favicon issue required extracting three different templates before finding the right one. Two wrong suggestions before reaching the correct file.
The sed -i multi-expression bug. When editing a config file with multiple substitutions, the first attempt omitted the -e flags, causing sed to treat subsequent expressions as filenames. A small thing, but the kind of error that shouldn’t happen.
None of this is a dealbreaker — the end result works correctly. But if you’re following this as a guide, know that the path involved some backtracking. Real infrastructure work usually does.
What’s Next
https://git.example.com is live with organisations, repos, teams, CrowdSec protection, and custom branding. All configs are versioned in the private infrastructure repo.
Next up: the Forgejo REST API script that sets up the full org/repo/team/branch-protection structure in one run. After that: scheduled backups of the SQLite database.
Related: Why I run Proxmox