The first `make build` failed before any step ran:
INVALID_ARGUMENT: could not resolve source: cb-image@... does not
have storage.objects.get access to ... gitea-496920_cloudbuild/source/...
`gcloud builds submit` uploads the source tarball to <project>_cloudbuild
and the build, running as the user-specified cb-image@, must read it
back. Nothing grants that. Binding on that bucket is not an option: gcloud
creates it on the first submit, after `pulumi up` has already run.
Project-wide objectViewer would also open the backup, config and state
buckets.
Pulumi now owns <project>-gitea-build-source, readable by cb-image@ and
nothing else, with a 7-day delete rule since each tarball is read once.
`make build` stages there via --gcs-source-staging-dir. Triggered builds
fetch source through the GitHub connection and are unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
image.yaml tags each image :$SHORT_SHA as its audit trail and rollback
target. Cloud Build populates SHORT_SHA only for triggered builds; for
`gcloud builds submit` it substitutes an empty string, so the tag becomes
`<image>:` and docker build fails with an invalid reference format. That
is the very first build in the README's setup sequence.
Pass the current commit's short hash explicitly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pulumi.yaml sets runtime.options.binary to ./gitea-infra, which tells the
Go language host to execute that file instead of compiling the program.
Nothing produced it: `make check` ran `go build ./...`, which discards
output when building multiple packages, and the infra Cloud Build step
went straight to `pulumi up`. Both local preview/up and the first infra
trigger run would fail before planning anything.
`make check` (and therefore preview/up) now builds it with -o, and the
infra pipeline builds it at the top of the pulumi step. `go vet ./...`
still type-checks every package.
Also corrects the Makefile's ZONE fallback from <region>-a to <region>-b.
It applies whenever `pulumi config get` cannot read the stack, and
us-east1 has no -a zone, so make ssh/build would target a zone that does
not exist. -b matches the default in infra/pkg/config.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>