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>
This commit is contained in:
2026-08-18 21:45:33 -05:00
co-authored by Claude Opus 5
commit c0382d5d31
50 changed files with 4449 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bash
#
# One-time project bootstrap, run from a workstation BEFORE the first
# `pulumi up`.
#
# Everything here exists because Pulumi cannot create it:
#
# * the GCS bucket that holds Pulumi's own state
# * the passphrase that encrypts that state
# * the service account that RUNS Pulumi in Cloud Build
# * Gitea's signing secrets -- INTERNAL_TOKEN must be a valid Gitea-issued
# JWT, so `gitea generate secret` has to produce it, and the values must
# exist before the VM first boots and tries to render app.ini
#
# Idempotent: safe to re-run.
#
# Usage: scripts/bootstrap.sh <project-id> [region] (default region: us-east1)
set -euo pipefail
PROJECT="${1:-}"
REGION="${2:-us-east1}"
if [[ -z "${PROJECT}" ]]; then
echo "usage: $0 <project-id> [region]" >&2
exit 1
fi
STATE_BUCKET="${PROJECT}-pulumi-state"
INFRA_SA="cb-infra"
INFRA_SA_EMAIL="${INFRA_SA}@${PROJECT}.iam.gserviceaccount.com"
GITEA_IMAGE="docker.io/gitea/gitea:latest"
log() { echo "==> $*"; }
command -v gcloud >/dev/null || { echo "gcloud is required" >&2; exit 1; }
# ---------------------------------------------------------------------------
log "enabling APIs"
# ---------------------------------------------------------------------------
gcloud services enable --project="${PROJECT}" \
compute.googleapis.com \
dns.googleapis.com \
artifactregistry.googleapis.com \
cloudbuild.googleapis.com \
secretmanager.googleapis.com \
iap.googleapis.com \
storage.googleapis.com \
logging.googleapis.com \
monitoring.googleapis.com \
cloudscheduler.googleapis.com \
iamcredentials.googleapis.com \
oslogin.googleapis.com
# ---------------------------------------------------------------------------
log "creating Pulumi state bucket gs://${STATE_BUCKET}"
# ---------------------------------------------------------------------------
if ! gcloud storage buckets describe "gs://${STATE_BUCKET}" --project="${PROJECT}" >/dev/null 2>&1; then
gcloud storage buckets create "gs://${STATE_BUCKET}" \
--project="${PROJECT}" \
--location="${REGION}" \
--uniform-bucket-level-access \
--public-access-prevention
fi
# Versioning is the undo button for a corrupted or truncated state file.
gcloud storage buckets update "gs://${STATE_BUCKET}" --versioning --project="${PROJECT}"
# ---------------------------------------------------------------------------
log "creating secrets"
# ---------------------------------------------------------------------------
ensure_secret() {
local name="$1"
if ! gcloud secrets describe "${name}" --project="${PROJECT}" >/dev/null 2>&1; then
gcloud secrets create "${name}" --project="${PROJECT}" \
--replication-policy=automatic --labels=app=gitea
fi
}
has_version() {
gcloud secrets versions list "$1" --project="${PROJECT}" \
--filter='state:ENABLED' --limit=1 --format='value(name)' 2>/dev/null | grep -q .
}
# Pulumi's state encryption passphrase. Generated here so it never lives in a
# shell history or a config file.
ensure_secret pulumi-config-passphrase
if ! has_version pulumi-config-passphrase; then
log "generating Pulumi state passphrase"
openssl rand -base64 48 | tr -d '\n' \
| gcloud secrets versions add pulumi-config-passphrase --project="${PROJECT}" --data-file=-
fi
# The GitHub PAT for the Cloud Build connection. Created empty on purpose --
# a PAT is an interactive artifact and cannot be generated here.
ensure_secret github-pat
if ! has_version github-pat; then
echo " NOTE: secret 'github-pat' has no value yet."
echo " Create a GitHub PAT with repo + read:user scope and run:"
echo " printf %s '<token>' | gcloud secrets versions add github-pat --project=${PROJECT} --data-file=-"
fi
# Gitea's signing secrets. These MUST come from `gitea generate secret`:
# INTERNAL_TOKEN is a JWT, and a random string there produces an instance that
# starts and then fails every internal API call in a confusing way.
declare -A GITEA_SECRETS=(
[gitea-secret-key]=SECRET_KEY
[gitea-internal-token]=INTERNAL_TOKEN
[gitea-oauth2-jwt-secret]=JWT_SECRET
[gitea-lfs-jwt-secret]=LFS_JWT_SECRET
)
runner=""
for candidate in podman docker; do
command -v "${candidate}" >/dev/null 2>&1 && { runner="${candidate}"; break; }
done
for name in "${!GITEA_SECRETS[@]}"; do
ensure_secret "${name}"
if has_version "${name}"; then
log "secret ${name} already populated -- leaving it alone"
continue
fi
if [[ -z "${runner}" ]]; then
echo " WARNING: no podman/docker available; cannot generate ${name}." >&2
echo " Install one and re-run, or the VM will skip rendering app.ini." >&2
continue
fi
log "generating ${name}"
# The upstream image is used only as a throwaway generator here; the
# deployed image is our own Debian 13 build.
"${runner}" run --rm "${GITEA_IMAGE}" gitea generate secret "${GITEA_SECRETS[$name]}" \
| tr -d '\n' \
| gcloud secrets versions add "${name}" --project="${PROJECT}" --data-file=-
done
# ---------------------------------------------------------------------------
log "creating the Pulumi runner service account ${INFRA_SA_EMAIL}"
# ---------------------------------------------------------------------------
# This is the chicken-and-egg account: it is the identity that runs `pulumi up`,
# so it cannot be created by `pulumi up`.
if ! gcloud iam service-accounts describe "${INFRA_SA_EMAIL}" --project="${PROJECT}" >/dev/null 2>&1; then
gcloud iam service-accounts create "${INFRA_SA}" \
--project="${PROJECT}" \
--display-name="Cloud Build: infrastructure (runs Pulumi)"
fi
# Broad by necessity -- Pulumi manages IAM, compute, DNS, and secrets bindings.
# Deliberately a different identity from cb-image@, which only pushes images.
INFRA_ROLES=(
roles/compute.admin
roles/dns.admin
roles/artifactregistry.admin
roles/secretmanager.admin
roles/iam.serviceAccountAdmin
roles/iam.serviceAccountUser
roles/resourcemanager.projectIamAdmin
roles/serviceusage.serviceUsageAdmin
roles/cloudscheduler.admin
roles/cloudbuild.builds.editor
roles/iap.tunnelResourceAccessor
roles/compute.osAdminLogin
roles/logging.logWriter
)
for role in "${INFRA_ROLES[@]}"; do
gcloud projects add-iam-policy-binding "${PROJECT}" \
--member="serviceAccount:${INFRA_SA_EMAIL}" \
--role="${role}" \
--condition=None \
--quiet >/dev/null
done
# Pulumi's state lives in the bucket, so the runner needs write access to it --
# scoped to that bucket rather than project-wide storage admin.
gcloud storage buckets add-iam-policy-binding "gs://${STATE_BUCKET}" \
--project="${PROJECT}" \
--member="serviceAccount:${INFRA_SA_EMAIL}" \
--role=roles/storage.admin >/dev/null
cat <<SUMMARY
Bootstrap complete.
Pulumi backend : gs://${STATE_BUCKET}
Pulumi runner : ${INFRA_SA_EMAIL}
Next:
1. Install the Cloud Build GitHub App on your repository and note the
installation id, then populate the github-pat secret (see note above).
2. Confirm DNS delegation: dig NS <your-domain>
3. cd infra
pulumi login gs://${STATE_BUCKET}
pulumi stack init prod
pulumi config set gcp:project ${PROJECT}
pulumi config set gitea:infraBuildServiceAccount ${INFRA_SA_EMAIL}
# ...plus domain, dnsZone, acmeEmail, githubOwner, githubAppInstallationId
pulumi up
4. make build # or, spelled out:
gcloud builds submit --config cloudbuild/image.yaml --project ${PROJECT} \\
--region ${REGION} \\
--service-account projects/${PROJECT}/serviceAccounts/cb-image@${PROJECT}.iam.gserviceaccount.com
SUMMARY