Eight open Dependabot alerts, all transitive through the Pulumi SDK: grpc < 1.82.2 / <= 1.83.0 (#3, #4 high; #5 medium) -> v1.83.2 otel/sdk, otlptrace, otlptracegrpc <= 1.44.0 (#6-8) -> v1.45.0 otel/sdk/log, otlplog/otlploggrpc < 0.21.0 (#9-10) -> v0.21.0 This supersedes Dependabot PR #1, which bumps grpc only and predates the otel alerts. otel/log v0.21 changed its API, so the otelslog bridge has to move with it: v0.18.0 no longer compiles against it. v0.20.1 is the release built for that otel line. govulncheck then reported ten reachable standard-library and x/net vulnerabilities disclosed since the last pin (net/http HTTP/2 and CONNECT handling, net/textproto, crypto/tls ECH, os on Windows), all fixed in go1.26.9 and golang.org/x/net v0.60.0. Raising the toolchain floor is the same remedy as before; x/net v0.60.0 requires go 1.26, which lifts the go directive from 1.25.11 to 1.26.0. The Pulumi SDK and pulumi-gcp direct dependencies are deliberately left where they are, so this does not also change provider behaviour ahead of the first deploy. govulncheck now reports no reachable vulnerabilities. GO-2026-5932 (x/crypto/openpgp, no fix available, not called) remains, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gitea on GCE — podman quadlets, Pulumi, Cloud Build
A self-hosted Gitea instance on a single Google Compute
Engine VM, served at gitea.jasonmross.dev.
| Layer | Choice |
|---|---|
| Host | AlmaLinux 10 (almalinux-cloud/almalinux-10), Shielded VM |
| Size | e2-small (2 shared vCPU, 2 GB RAM) in us-east1, 20 GB boot + 30 GB pd-balanced data |
| Runtime | Podman quadlets — systemd .container/.network/.volume units, not compose |
| Images | Gitea and Caddy, both built on Debian 13 (trixie) |
| TLS | Caddy with ACME DNS-01 via Google Cloud DNS (custom xcaddy build) |
| WAF | Coraza + OWASP CRS compiled into Caddy, feeding fail2ban → nftables |
| Database | SQLite in WAL mode on a dedicated persistent disk |
| Firewall | nftables + fail2ban on the host, VPC firewall outside it |
| IaC | Pulumi, Go, self-managed GCS state backend |
| CI/CD | Cloud Build — push triggers plus a weekly rebuild |
Layout
infra/ Pulumi program (Go). One package per slice of infrastructure.
image/ Dockerfiles for the Gitea and Caddy images, plus pinned versions.
vm/ Everything that lands on the VM: quadlets, systemd units,
nftables, fail2ban, config templates, and bootstrap.sh.
cloudbuild/ The two build pipelines.
scripts/ One-time project bootstrap.
docs/ Runbook, WAF tuning guide, migration-to-Gitea-SCM plan.
vm/ is uploaded to a GCS bucket by Pulumi and pulled down by the instance, so
changing a quadlet is a normal pull request.
First-time setup
Pulumi cannot create the bucket holding its own state, the identity that runs it, or Gitea's signing secrets — so there is one manual step first.
# 1. Project bootstrap: APIs, state bucket, Pulumi runner SA, Gitea secrets.
scripts/bootstrap.sh <project-id> us-east1
# 2. Install the Cloud Build GitHub App on this repository, note the
# installation id, and store a PAT (repo + read:user scope):
printf %s '<token>' | gcloud secrets versions add github-pat --data-file=- --project <project-id>
# 3. Confirm the Cloud DNS zone is authoritative. DNS-01 cannot work otherwise.
dig NS gitea.jasonmross.dev
# 4. Configure and apply.
cd infra
pulumi login gs://<project-id>-pulumi-state
pulumi stack init prod
pulumi config set gcp:project <project-id>
pulumi config set gitea:domain gitea.jasonmross.dev
pulumi config set gitea:dnsZone <cloud-dns-managed-zone-name> # gcloud dns managed-zones list
pulumi config set gitea:acmeEmail you@example.com
pulumi config set gitea:githubOwner <owner>
pulumi config set gitea:githubAppInstallationId <id>
pulumi config set gitea:infraBuildServiceAccount cb-infra@<project-id>.iam.gserviceaccount.com
# WAF starts in DetectionOnly. Tune, then switch to On -- see docs/waf.md.
pulumi config set gitea:wafMode DetectionOnly
pulumi up
# 5. First image build. Until this runs, the :prod images do not exist.
cd .. && make build
# 6. Create the admin user.
make ssh
sudo podman exec -u 1000 gitea gitea admin user create \
-c /etc/gitea/app.ini --admin --username <you> --email <you@example.com> --random-password
Expected on the first run, not a bug
Between step 4 and step 5 the :prod images do not exist yet, so gitea.service
and caddy.service crash-loop. That is intentional: the units carry
Restart=always with StartLimitIntervalSec=0, so they recover on their own
within 30 seconds of the first successful push. Likewise, app.ini is not
rendered until Gitea's secrets are readable — bootstrap.sh skips rendering
rather than writing a config with empty signing keys.
Day-to-day
make status # services, containers, timers
make logs # tail gitea + caddy
make rollout # pull the latest :prod images now
make sync # re-render VM config after a vm/ change
make backup # on-demand gitea dump to GCS
make ssh # shell via IAP
A push to main under image/** builds, pushes, rolls out, and gates on
/api/healthz. A push under infra/** or vm/** runs pulumi up and then
re-syncs the VM configuration. Anything else does nothing.
How updates happen
Three independent layers, because no single one covers everything:
- OS packages —
dnf5-automaticapplies updates nightly. It never reboots;gitea-reboot-window.timerdoes that weekly, and only whenneeds-restarting -rsays a reboot is genuinely required. - Container images —
podman-auto-update.timerpolls the:prodtag daily.Notify=healthyon the quadlets means systemd withholds "started" until the healthcheck passes, which is what arms podman's automatic rollback. - Image contents — a Cloud Scheduler job re-runs the image build every
Sunday, rebuilding from a floating
debian:13-slimso base-OS and Go security fixes reach the running containers. Without this,:prodnever changes and layer 2 has nothing to pull.
Bumping the Gitea or Caddy version itself stays a deliberate change to
image/gitea.version / image/caddy.version.
Fire the weekly rebuild once by hand after the first deploy. A broken scheduler request fails silently at 04:00 on a Sunday and stops layer 3 from feeding layer 2. See The weekly rebuild in docs/runbook.md.
Things worth knowing before you change something
- The
:prodtag is load-bearing.AutoUpdate=registrycompares digests for a tag. Pinning a digest in the quadlet silently disables auto-updates. app.iniis fully managed andINSTALL_LOCK=true. Gitea settings changed in the web UI that map toapp.iniwill not survive a config sync. Editvm/config/app.ini.tmplinstead.- The podman subnet is pinned (
10.89.10.0/24). It is whatREVERSE_PROXY_TRUSTED_PROXIESnames; an unpinned subnet would silently make fail2ban ban Caddy instead of the attacker. - Never put
flush rulesetin the nftables config. It would wipe netavark's rules and break all container networking on reload. - The DNS zone, the backup bucket, and the Gitea secrets are not Pulumi-owned
by design, so
pulumi destroycannot take them with it. - Git and LFS deliberately bypass the WAF. Remove that bypass and
git pushreturns 403 — verified, not theoretical. See docs/waf.md. image/caddy.versionandimage/coraza.versionare coupled. coraza-caddy pins a minimum Caddy version; bump them together or the build fails.us-east1has no-azone (it is b/c/d). The stack pinsus-east1-b.- 2 GB of RAM is the real constraint, not disk or CPU. A 2 GB swap file is
provisioned as ballast; sustained swap use means move to
e2-medium. Measured numbers are in docs/runbook.md.
See docs/runbook.md for verification drills, restores, and
rollbacks, and docs/waf.md for WAF tuning and the
DetectionOnly → On rollout.