Self-hosted Gitea on a single e2-small AlmaLinux 10 VM in us-east1, serving gitea.jasonmross.dev. Runtime is podman quadlets (systemd .container/.network/.volume units). Both images are built on Debian 13: Gitea from a GPG-verified release binary, and Caddy from an xcaddy build carrying the Google Cloud DNS provider (ACME DNS-01) and the Coraza WAF with the OWASP CRS embedded. Infrastructure is a Pulumi program in Go against a GCS state backend. Cloud Build handles CI: a push trigger for images, one for infra, and a weekly scheduled rebuild. Everything Cloud Build touches is 2nd gen. Notable design decisions, each documented where it lives: - Quadlets track a floating :prod tag. AutoUpdate=registry compares digests for a tag, so a digest-pinned image silently disables auto-updates. - Git transport and LFS bypass the WAF. With the bypass removed, a plain git push returns 403 -- packfiles trip CRS reliably. - gitea:wafMode drives both SecRuleEngine and whether the fail2ban jail acting on WAF verdicts exists. Banning on detections that were never blocks would turn a tuning false positive into an nftables ban. - fail2ban bans at the nftables prerouting hook. Published container ports are DNAT'd and never traverse INPUT, where the stock actions install their rules. - The DNS zone, backup bucket, and Gitea signing secrets are not Pulumi-owned, so pulumi destroy cannot take them with it. - The podman subnet is pinned because it is what Gitea's REVERSE_PROXY_TRUSTED_PROXIES names. Three update layers: dnf5-automatic for the OS, podman-auto-update with health-gated rollback for containers, and a weekly image rebuild that gives the second layer something to pull. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
54 lines
1.8 KiB
Plaintext
54 lines
1.8 KiB
Plaintext
# Installed by vm/bootstrap.sh as /etc/fail2ban/jail.d/gitea.local
|
|
|
|
[DEFAULT]
|
|
# Podman's journald log driver tags container output with CONTAINER_NAME, so
|
|
# there are no log files to bind-mount, rotate, or keep in sync.
|
|
backend = systemd
|
|
|
|
# Every ban goes through the prerouting action. The stock nftables/iptables
|
|
# actions install INPUT rules, which do not see DNAT'd container traffic at all.
|
|
banaction = nft-prerouting
|
|
banaction_allports = nft-prerouting
|
|
|
|
bantime = 1h
|
|
findtime = 10m
|
|
maxretry = 5
|
|
|
|
# Never lock out loopback, the podman bridge, or the IAP range -- IAP is the
|
|
# only way back into this box.
|
|
ignoreip = 127.0.0.1/8 ::1 ${PODMAN_SUBNET} 35.235.240.0/20
|
|
|
|
[sshd]
|
|
enabled = true
|
|
port = 22
|
|
# Belt-and-braces: port 22 already only accepts the IAP range, at both the VPC
|
|
# firewall and nftables.
|
|
maxretry = 5
|
|
|
|
[gitea]
|
|
enabled = true
|
|
filter = gitea
|
|
# One jail across all three ports rather than separate web/ssh jails: Gitea
|
|
# serves them from one process with shared auth logging, and a bad actor should
|
|
# lose every door at once, not just the one they knocked on.
|
|
port = 80,443,2222
|
|
journalmatch = CONTAINER_NAME=gitea
|
|
maxretry = 5
|
|
bantime = 1h
|
|
|
|
[caddy-coraza]
|
|
# Enabled ONLY when gitea:wafMode is "On". In DetectionOnly the WAF reports what
|
|
# it would have blocked but lets it through, and escalating those reports into
|
|
# nftables bans would turn a tuning false positive into a locked-out user --
|
|
# strictly worse than the 403 that DetectionOnly was chosen to avoid.
|
|
# vm/bootstrap.sh derives this from the same config value as SecRuleEngine.
|
|
enabled = ${CORAZA_JAIL_ENABLED}
|
|
filter = caddy-coraza
|
|
port = 80,443
|
|
journalmatch = CONTAINER_NAME=caddy
|
|
# Deliberately looser than the auth jail. A WAF verdict is a weaker signal than
|
|
# a failed password, so it takes sustained hostile traffic to earn a ban.
|
|
maxretry = 10
|
|
findtime = 10m
|
|
bantime = 2h
|