Austin Rose
Lab/Platform

Platform

LiveUpdated 2026-05-05

Spare-time tinkering shop. The same primitives I touch professionally, exercised at personal scale on my own dollars: multi-cloud, GitOps, IaC, secret discipline, content-as-code, on-prem fallback. Not enterprise-grade; breadth, not production scale.

Mission objective

Keep my hands on every primitive I use professionally, on a working personal site that ships every change through the same pipeline.

Threats
  • Drift between IaC and the running surface (3 clouds plus on-prem)
  • Secret sprawl as the surface grows (~7 integrations and counting)
  • Cost creep across three providers without governance
  • Toy-grade decisions calcifying under "it works"
Go / No-go criteria
  • Every push reaches all four origins (3 clouds plus k3s preview) within ~10 minutes
  • Zero secrets touch GitHub directly; everything mirrored from 1Password by infra/secrets
  • Cost data refreshed daily by a scheduled PR-bot
  • Every infrastructure change runs `tofu plan` in CI; only main triggers apply
Lessons learned
  • Cloudflare provider v5.19 silently lowercase-normalizes `origins[].header` keys; matching the HCL writes (`host`, not `Host`) ended a multi-PR debugging loop (#99 to #106).
  • One GCP global forwarding rule plus one backend service can host two Cloud Run regions via two regional NEGs; user-proximity routing is transparent above.
  • The deploy role gets data-plane perms but not IAM-mutation perms; trust-policy edits stay a deliberate local-apply action.
Built for this
  • 1Password to GitHub Actions secret-mirroring via infra/secrets
  • Header-based origin lockdown (Cloudflare egress rule injects X-Origin-Auth; AWS Function and GCP Cloud Armor verify)
  • PR-gated FinOps governance with daily baseline refresh that opens its own PR
  • Build-time CV PDFs via Puppeteer in CI; mode-pinning via ?mode=brief|detailed
Built on
  • Cloudflare Load Balancing, AWS, GCP
  • OpenTofu (5 modules, ~70 resources)
  • GitHub Actions (6 workflows)
  • 1Password (vault as secret SSOT) and Infracost
Origins
CloudflareDNS + weighted routinghealth checks · auth-header injectionAWSus-west-2x-origin: aws-us-west-2CloudFrontCDNS3Static originACMCertificateCloudFront FunctionLockdown (header check)GCP · GCSus-central1x-origin: gcp-us-central1Backend bucketCDNCloud StorageStatic originCloudflare Origin CertCertificateCloud Armor (edge)Lockdown (IP allowlist)GCP · Cloud Runus-central1us-west1x-origin: gcp-cloudrunCloud Run (v2)Container originServerless NEGBackend groupArtifact RegistryImage registryCloud Armor (regular)Lockdown (header check)Visitor → austinrose.me

Four origins behind one Cloudflare Load Balancer: AWS S3 + CloudFront, GCP backend bucket, GCP Cloud Run (us-central1 and us-west1 sharing one backend service via two regional NEGs), and a fourth path on the home k3s cluster that serves the staging domain. Weighted random across the three cloud pools at ~33% each. The on-prem path is documented separately at /lab/homelab. For continuity behavior across all four, see /lab/failover.

Pipelines

GitHub Actions

  • ci.github/workflows/ci.yml
    Trigger
    PRs to main or preview; pushes to either
    Role
    Lint, typecheck, build the Next.js static export
  • deploy.github/workflows/deploy.yml
    Trigger
    Push to main (paths-filtered)
    Role
    Build once on the runner; fan out to AWS (S3 + CloudFront), GCP-bucket (GCS + LB), and GCP-cloudrun (us-central1 + us-west1 sharing one backend service)
  • deploy-preview.github/workflows/deploy-preview.yml
    Trigger
    Push to preview (paths-filtered) and workflow_dispatch
    Role
    Build static export plus PDFs on the runner; multi-arch container (amd64 + arm64) push to private GHCR; Flux Image Automation in home-ops promotes to k3s
  • sync-preview.github/workflows/sync-preview.yml
    Trigger
    PR closed to main (merged)
    Role
    Force-push main HEAD to preview and dispatch deploy-preview, keeping the staging origin coherent after any promotion or hotfix
  • infra.github/workflows/infra.yml
    Trigger
    PR to infra/**, push to main on infra/**, workflow_dispatch
    Role
    tofu plan on PR (per-module matrix); tofu apply on merge or manual dispatch (max-parallel 1)
  • infracost.github/workflows/infracost.yml
    Trigger
    PR to infra/**, daily 09:00 UTC, workflow_dispatch
    Role
    PR cost-diff comment; scheduled baseline refresh that opens its own PR with refreshed data/cost-breakdown.json

Build-time scripts

  • TUI data builderscripts/build-tui-data.mjs
    Stage
    prebuild
    Consumes
    content/profile.yml, experience.yml, speaking.yml
    Emits
    data/tui.json (command outputs for /lab/tui)
  • RSS feed generatorscripts/build-rss.mjs
    Stage
    prebuild
    Consumes
    content/writing/*.md (status published only)
    Emits
    public/feed.xml (RSS 2.0)
  • Cost-breakdown mergerscripts/merge-cost-breakdown.mjs
    Stage
    scheduled via infracost.yml
    Consumes
    Infracost per-module JSON, content/cost-estimates.yml (hand overlay)
    Emits
    data/cost-breakdown.json (per-source row tagging)
  • CV PDF generatorscripts/build-pdf.mjs
    Stage
    postbuild
    Consumes
    out/ static export, headless Chromium via Puppeteer
    Emits
    out/austin-rose-cv-brief.pdf, out/austin-rose-cv-detailed.pdf (Letter, printBackground)
  • Static-export buildpackage.json (npm run build)
    Stage
    build
    Consumes
    app/, components/, content/, data/
    Emits
    out/ (Next.js static export, sitemap.xml, per-page OG cards)

The cost pipeline opens its own pull requests with refreshed line items. Walked through at /lab/finops.

Infrastructure as code
  • bootstrapinfra/bootstrap/ · ~1 resources
    Role
    Cloudflare R2 bucket for OpenTofu remote state (S3-compatible, native locking; no DynamoDB needed)
  • awsinfra/aws/ · ~13 resources
    Role
    S3 origin, CloudFront CDN, ACM cert, viewer-request Function for sub-path index resolution, IAM OIDC provider, deploy role
  • gcpinfra/gcp/ · ~35 resources
    Role
    GCS bucket, Application LB (EXTERNAL_MANAGED), two Cloud Run regions (us-central1 + us-west1) sharing one backend service via two NEGs, managed cert, Cloud Armor, Workload Identity Federation
  • cloudflareinfra/cloudflare/ · ~11 resources
    Role
    Load Balancer (3 pools, weighted random), per-pool health monitors, DNS records, www-to-apex Single Redirect, X-Origin-Auth egress ruleset
  • secretsinfra/secrets/ · ~14 resources
    Role
    Reads 1Password vault `homepage` and outputs from the other modules; mirrors values into GitHub Actions secrets and variables (Service Account auth)

OpenTofu (not Terraform). Remote state in Cloudflare R2 with native locking, no DynamoDB. The deploy role manages the data plane (S3, CloudFront, ACM, GCS, LB) but is intentionally not granted IAM-mutation perms; trust-policy edits stay a deliberate local-apply action so CI cannot rewrite its own boundary.

Identity and secrets
  • Cloudflare (LB, DNS, R2)
    Role
    Apex router (weighted random across 3 pools), DNS authority, OpenTofu remote-state bucket
    Rotation
    API token rotated in 1Password (`cloudflare-api-token`); R2 access keys in 1Password (`r2-tofu`); both mirrored by infra/secrets
    Source of truth
    infra/cloudflare/, infra/bootstrap/
  • 1Password vault `homepage`
    Role
    Single source of truth for every secret; Service Account auth; mirrored to GitHub Actions by infra/secrets
    Rotation
    All credentials rotated by editing the 1Password item and re-running infra/secrets
    Source of truth
    infra/secrets/
  • AWS IAM OIDC
    Role
    GitHub Actions federation for AWS deploys; trust policy allowlists ref:refs/heads/main, environment:production, pull_request
    Rotation
    Federation; no long-lived keys; provider thumbprint refreshed on tofu apply
    Source of truth
    infra/aws/
  • GCP Workload Identity Federation
    Role
    GitHub Actions federation for GCP deploys; attribute.repository == auzroz/austinrose-me
    Rotation
    Federation; no long-lived keys
    Source of truth
    infra/gcp/
  • GHCR private registry
    Role
    Container images for the k3s preview path; tags `:latest`, `:run-N` (numeric, automation-friendly), `:<sha>`; Flux Image Automation in home-ops watches `:run-N`
    Rotation
    GITHUB_TOKEN auto-issued per workflow run for push; pull-secret PAT rotated in 1Password (`ghcr-pull`)
    Source of truth
    .github/workflows/deploy-preview.yml, ../home-ops kubernetes/apps/portfolio/austinrose-me/
  • Infracost
    Role
    Cost estimation for OpenTofu modules; PR cost-diff comments and scheduled baseline refresh
    Rotation
    API key rotated in 1Password (`infracost-api-key`)
    Source of truth
    .github/workflows/infracost.yml, scripts/merge-cost-breakdown.mjs
  • Cloudflare Origin Certificate
    Role
    TLS cert for the GCP LB origin (wildcard *.austinrose.me + apex); trusted only by Cloudflare's edge, appropriate for "origin reachable only via Cloudflare"
    Rotation
    15-year validity; manual rotation on expiry; PEM and key in 1Password (`cloudflare-origin-cert-pem`, `cloudflare-origin-cert-key`)
    Source of truth
    infra/secrets/, infra/gcp/

No long-lived AWS or GCP credentials in GitHub. Rotation means editing the 1Password item and re-running infra/secrets/; never editing GitHub secrets by hand.

Content as code

Every content surface on this site is a curated file in the repo. Profile, experience, speaking, labs metadata, architecture, hand-estimated cost overlay, project case studies, and the writing essays all live as YAML or Markdown under content/ and are loaded at build time. Derivative artifacts (RSS feed, sitemap, TUI command outputs, the dual-mode CV PDFs) are emitted by the build-time pipelines above, not authored separately. No CMS, no runtime; every content change is a pull request.

The fourth path

The same out/ bundle that ships to AWS and GCP also ships to a small k3s cluster at home. Push to the preview branch, the workflow builds a multi-arch container and pushes to private GHCR with a numeric :run-N tag, and Flux Image Automation in the home-ops repo watches the tag, commits a HelmRelease bump, and rolls the deployment. Push-to- live in roughly 5 to 8 minutes, served from a Mac Mini and two Raspberry Pis.

The full tour of the home cluster (three-layer architecture, ~38 HelmReleases, hybrid SOPS plus 1Password secret discipline, two-tier R2 plus NFS backups) lives at /lab/homelab.