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>
129 lines
4.6 KiB
YAML
129 lines
4.6 KiB
YAML
# Build the Gitea and Caddy images, push them, and roll them out.
|
|
#
|
|
# The rollout works by kicking podman-auto-update on the VM. That is why the
|
|
# quadlets track a floating :prod tag rather than a digest: AutoUpdate=registry
|
|
# compares the local digest against the registry's digest FOR A TAG, so a
|
|
# digest-pinned image would give it nothing to poll.
|
|
#
|
|
# The :$SHORT_SHA and version tags are the audit trail and the rollback targets.
|
|
|
|
substitutions:
|
|
_REGION: us-east1
|
|
_ZONE: us-east1-b
|
|
_DOMAIN: gitea.jasonmross.dev
|
|
_REPO: gitea
|
|
_VM: gitea-vm
|
|
|
|
options:
|
|
# A user-specified service account cannot write to the legacy default log
|
|
# bucket. Without this the very first build fails on storage.objects.create.
|
|
logging: CLOUD_LOGGING_ONLY
|
|
machineType: E2_HIGHCPU_8
|
|
|
|
timeout: 2400s
|
|
|
|
steps:
|
|
- id: read-versions
|
|
name: bash
|
|
script: |
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
mkdir -p /workspace/vars
|
|
tr -d '[:space:]' < image/gitea.version > /workspace/vars/gitea_version
|
|
tr -d '[:space:]' < image/caddy.version > /workspace/vars/caddy_version
|
|
tr -d '[:space:]' < image/coraza.version > /workspace/vars/coraza_version
|
|
echo "gitea=$(cat /workspace/vars/gitea_version) caddy=$(cat /workspace/vars/caddy_version) coraza=$(cat /workspace/vars/coraza_version)"
|
|
|
|
- id: build-gitea
|
|
name: gcr.io/cloud-builders/docker
|
|
entrypoint: bash
|
|
args:
|
|
- -c
|
|
- |
|
|
set -euo pipefail
|
|
V=$(cat /workspace/vars/gitea_version)
|
|
IMG="${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/gitea"
|
|
docker build \
|
|
--build-arg "GITEA_VERSION=$${V}" \
|
|
--tag "$${IMG}:prod" \
|
|
--tag "$${IMG}:$SHORT_SHA" \
|
|
--tag "$${IMG}:$${V}" \
|
|
image/gitea
|
|
|
|
- id: build-caddy
|
|
name: gcr.io/cloud-builders/docker
|
|
entrypoint: bash
|
|
# xcaddy runs inside the Dockerfile's golang builder stage, so the plain
|
|
# docker builder is all this step needs -- no Go toolchain out here.
|
|
args:
|
|
- -c
|
|
- |
|
|
set -euo pipefail
|
|
V=$(cat /workspace/vars/caddy_version)
|
|
C=$(cat /workspace/vars/coraza_version)
|
|
IMG="${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/caddy"
|
|
# The Dockerfile asserts both the DNS and WAF modules are present, so a
|
|
# dropped --with fails the build rather than shipping an unprotected
|
|
# server. Caddy and coraza-caddy versions are coupled: coraza-caddy
|
|
# pins a minimum Caddy, and a mismatch fails here at `go get`.
|
|
docker build \
|
|
--build-arg "CADDY_VERSION=$${V}" \
|
|
--build-arg "CORAZA_VERSION=$${C}" \
|
|
--tag "$${IMG}:prod" \
|
|
--tag "$${IMG}:$SHORT_SHA" \
|
|
--tag "$${IMG}:$${V}" \
|
|
image/caddy
|
|
|
|
- id: push
|
|
name: gcr.io/cloud-builders/docker
|
|
entrypoint: bash
|
|
args:
|
|
- -c
|
|
- |
|
|
set -euo pipefail
|
|
BASE="${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}"
|
|
docker push --all-tags "$${BASE}/gitea"
|
|
docker push --all-tags "$${BASE}/caddy"
|
|
# Record the digests actually published. Pulumi does not pin these, so
|
|
# the build log is where you look to find a rollback target.
|
|
docker image inspect "$${BASE}/gitea:prod" --format '{{index .RepoDigests 0}}'
|
|
docker image inspect "$${BASE}/caddy:prod" --format '{{index .RepoDigests 0}}'
|
|
|
|
- id: rollout
|
|
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
|
|
entrypoint: bash
|
|
env:
|
|
# gcloud needs a writable HOME to generate the ephemeral SSH key it pushes
|
|
# through OS Login. The default HOME in this image is not writable.
|
|
- HOME=/workspace
|
|
args:
|
|
- -c
|
|
- |
|
|
set -euo pipefail
|
|
gcloud compute ssh "${_VM}" \
|
|
--zone="${_ZONE}" \
|
|
--tunnel-through-iap \
|
|
--quiet \
|
|
--command 'sudo systemctl start podman-auto-update.service'
|
|
|
|
- id: verify
|
|
name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
|
|
entrypoint: bash
|
|
# A build that pushes a broken image and reports success is worse than a
|
|
# failed build. Gate on the app actually answering.
|
|
args:
|
|
- -c
|
|
- |
|
|
set -euo pipefail
|
|
for i in $(seq 1 30); do
|
|
if curl -fsS --max-time 10 "https://${_DOMAIN}/api/healthz" >/dev/null; then
|
|
echo "healthz passed after $${i} attempt(s)"
|
|
exit 0
|
|
fi
|
|
echo "waiting for https://${_DOMAIN}/api/healthz ($${i}/30)"
|
|
sleep 10
|
|
done
|
|
echo "healthz never passed -- check 'journalctl -u podman-auto-update' on the VM;" >&2
|
|
echo "podman should have rolled back automatically if the new image failed to start." >&2
|
|
exit 1
|