Files
JMR-devandClaude Opus 5 c0382d5d31 Gitea on GCE: podman quadlets, Pulumi, Cloud Build
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>
2026-08-18 21:45:33 -05:00

63 lines
2.2 KiB
INI

# Rendered by vm/bootstrap.sh into /etc/containers/systemd/
#
# ${CADDY_NETWORK} is chosen by bootstrap.sh at run time, not by hand:
# the googleclouddns ACME plugin authenticates via Application Default
# Credentials, which on GCE means reaching the metadata server at
# 169.254.169.254. bootstrap.sh probes whether a container on the gitea bridge
# can do that and falls back to Network=host if it cannot. See
# gitea-probe-metadata in bootstrap.sh and docs/runbook.md.
[Unit]
Description=Caddy (TLS termination, ACME DNS-01 via Google Cloud DNS)
Documentation=https://caddyserver.com/docs/
After=gitea.service gitea-ar-auth.service network-online.target
Wants=gitea.service gitea-ar-auth.service
[Container]
ContainerName=caddy
Image=${IMAGE_CADDY}
AutoUpdate=registry
# Registry auth for Artifact Registry. Two settings, because two different
# code paths need it: PodmanArgs covers `podman run`'s pull, and the
# io.containers.autoupdate.authfile label is what `podman auto-update` reads
# when it checks the registry digest. (There is no AuthFile= key in the
# [Container] group -- that one only exists for .image and .build units.)
PodmanArgs=--authfile=/etc/containers/ar-auth.json
Label=io.containers.autoupdate.authfile=/etc/containers/ar-auth.json
Network=${CADDY_NETWORK}
LogDriver=journald
${CADDY_PUBLISH_PORTS}
Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z
Volume=caddy-data.volume:/data
Volume=caddy-config.volume:/config
User=1000:1000
# Caddy binds 80/443 as a non-root user. On the bridge this is a container-local
# sysctl; podman REJECTS net.* sysctls when Network=host, so bootstrap.sh emits
# nothing here in that mode and sets the equivalent host sysctl instead. File
# capabilities are not an option -- NoNewPrivileges blocks them.
${CADDY_SYSCTL}
# The plugin reads Application Default Credentials; the project must be explicit
# because the metadata server's project and the DNS zone's project need not match.
Environment=GCP_PROJECT=${GCP_PROJECT}
HealthCmd=curl -fsS http://127.0.0.1:2019/config/
HealthInterval=30s
HealthTimeout=5s
HealthStartPeriod=30s
HealthRetries=3
Notify=healthy
NoNewPrivileges=true
[Service]
Restart=always
RestartSec=30
StartLimitIntervalSec=0
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target