The repository for my resume is private. Though, the files below are imported straight from the ones that actually run; specifically, the Dockerfile and the Terraform that defines the cloud. This page cannot drift from what is deployed.
Commit
f2987b419773
Built
2026-09-11
Region
us-east1
That commit hash is baked in at image build time. The deploy job refuses to pass until the running service reports the same SHA it just pushed, which makes this a live check.
Architecture
Two paths through the same service.
Request and deploy
flowchart LR
V["Visitor<br/>zayda.dev"] --> CF["Cloudflare<br/>DNS · CDN · TLS"] --> CR["Cloud Run<br/>nginx + Astro"]
GH["GitHub Actions<br/>push to main"] -. "OIDC, no keys" .-> WIF["Workload Identity"]
WIF -. token .-> AR["Artifact Registry<br/>image :sha"]
AR -. "deploy" .-> CR
A visitor reaches Cloudflare, which proxies to the Cloud Run service running nginx over the built Astro site. On a push to main, GitHub Actions authenticates through Workload Identity Federation, pushes an image to Artifact Registry, and deploys that same service. Terraform defines the registry, the service, the identities, and the federation.
Rationale
Decisions worth explaining.
No service-account keys
The deploy pipeline holds no long-lived credential. GitHub mints a short-lived OIDC token, Workload Identity Federation exchanges it for GCP credentials, and the provider carries an attribute condition scoping it to this repository.
It used to run on AWS
The first version was an ECS Fargate service behind an Application Load Balancer, defined in AWS CDK. It cost about eighteen dollars a month in load balancer charges alone to serve a résumé. Cloud Run scales to zero, so the same site now costs roughly nothing.
Subdomains, not path prefixes
An earlier iteration reverse-proxied each project under a path like/yapper/, and every app's absolute asset path broke because nothing rewrote them. Giving each service its own subdomain removes that whole class of bug rather than patching around it.
Source
The actual files.
Imported at build time
infra/workload_identity.tf50 lines
The whole of the keyless-auth setup. The attribute condition is not optional. Google rejects a GitHub provider without one, because an unconditioned provider would accept a token from any repository on GitHub.
# ---------------------------------------------------------------------------
# Keyless CI authentication.
#
# GitHub Actions holds no Google credential. It presents a short-lived OIDC
# token that GitHub itself signs; Workload Identity Federation verifies that
# token and exchanges it for temporary GCP credentials. There is no
# service-account JSON key in this repository, in CI secrets, or on a laptop —
# so there is nothing to leak and nothing to rotate.
# ---------------------------------------------------------------------------
resource "google_iam_workload_identity_pool" "github" {
# Pool IDs are soft-deleted for 30 days after removal, so a typo here is
# unusable for a month. Named deliberately.
workload_identity_pool_id = "github-pool"
display_name = "GitHub Actions"
project = var.project_id
depends_on = [google_project_service.enabled]
}
resource "google_iam_workload_identity_pool_provider" "github" {
project = var.project_id
workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id
workload_identity_pool_provider_id = "github-provider"
display_name = "GitHub OIDC"
# Required, not optional. Google rejects provider creation for the GitHub
# issuer without an attribute condition — and rightly so: an unconditioned
# provider would accept a token from any repository on GitHub, not just mine.
attribute_condition = "assertion.repository_owner == '${var.github_owner}'"
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.repository" = "assertion.repository"
"attribute.repository_owner" = "assertion.repository_owner"
}
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
# The condition above scopes the pool to the owner. This binding narrows it
# further to one specific repository, so no other repo in the org can assume
# the deploy identity.
resource "google_service_account_iam_member" "github_impersonates_deployer" {
service_account_id = google_service_account.deployer.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_owner}/${var.github_repo}"
}
infra/main.tf179 lines
Registry, identities, and the Cloud Run service. The image tag is deliberately kept out of Terraform's control. CI owns it, so applying infrastructure never rolls the site back to the bootstrap placeholder.
# ---------------------------------------------------------------------------
# Enabled services
# ---------------------------------------------------------------------------
locals {
services = [
"run.googleapis.com",
"artifactregistry.googleapis.com",
"iamcredentials.googleapis.com",
"sts.googleapis.com",
"secretmanager.googleapis.com",
"firestore.googleapis.com",
"iam.googleapis.com",
"storage.googleapis.com",
# Gen-2 Cloud Functions are Cloud Run services built by Cloud Build and
# wired through Eventarc, so all four are required even for a plain HTTP
# function. terraform plan does not check API enablement, so a missing one
# here surfaces only as a failed apply.
"cloudfunctions.googleapis.com",
"cloudbuild.googleapis.com",
"eventarc.googleapis.com",
"pubsub.googleapis.com",
"monitoring.googleapis.com",
"logging.googleapis.com",
]
}
resource "google_project_service" "enabled" {
for_each = toset(local.services)
project = var.project_id
service = each.value
# Leave the APIs on if this config is torn down; disabling them would break
# anything else in the project that came to depend on them.
disable_on_destroy = false
}
# ---------------------------------------------------------------------------
# Container images
# ---------------------------------------------------------------------------
resource "google_artifact_registry_repository" "images" {
location = var.region
repository_id = "zayda-dev"
description = "Container images for zayda.dev"
format = "DOCKER"
# Without this, every deploy leaves an image behind forever and storage cost
# grows without bound.
cleanup_policies {
id = "keep-recent"
action = "KEEP"
most_recent_versions {
keep_count = 10
}
}
cleanup_policies {
id = "drop-old-untagged"
action = "DELETE"
condition {
tag_state = "UNTAGGED"
older_than = "604800s" # 7 days
}
}
+115 more lines in the repository
site/Dockerfile55 lines
Multi-stage: Node builds the static output, nginx serves it as an unprivileged user. The build metadata on this page enters here as build args.
# syntax=docker/dockerfile:1
# ---- build ----------------------------------------------------------------
# The build context is the REPOSITORY ROOT, not site/. The /stack page imports
# the real infrastructure files it renders (../../../.github/..., ../../../infra/...),
# so those files have to be inside the build. The repo layout is preserved here —
# site/ as a subdirectory, with .github/ and infra/ alongside it — so the same
# relative paths that resolve on a developer's machine resolve in the container.
FROM node:22-alpine AS build
WORKDIR /app
# Manifests first so the dependency layer caches independently of source.
COPY site/package.json site/package-lock.json ./site/
WORKDIR /app/site
RUN npm ci
# App source, plus infra/ because /stack imports the Terraform by ?raw to
# display it. The deploy workflow is deliberately not copied in.
WORKDIR /app
COPY site/ ./site/
COPY infra/ ./infra/
# Build metadata is baked in here and surfaced on /stack, which is how the page
# can prove which commit is actually serving.
ARG PUBLIC_COMMIT_SHA=local
ARG PUBLIC_BUILT_AT
ARG PUBLIC_DEPLOY_REGION=unknown
ARG PUBLIC_CONTACT_ENDPOINT=
ARG PUBLIC_COUNTER_ENDPOINT=
ARG PUBLIC_TURNSTILE_SITE_KEY=
ENV PUBLIC_COMMIT_SHA=$PUBLIC_COMMIT_SHA \
PUBLIC_BUILT_AT=$PUBLIC_BUILT_AT \
PUBLIC_DEPLOY_REGION=$PUBLIC_DEPLOY_REGION \
PUBLIC_CONTACT_ENDPOINT=$PUBLIC_CONTACT_ENDPOINT \
PUBLIC_COUNTER_ENDPOINT=$PUBLIC_COUNTER_ENDPOINT \
PUBLIC_TURNSTILE_SITE_KEY=$PUBLIC_TURNSTILE_SITE_KEY
WORKDIR /app/site
RUN npm run build
# ---- runtime --------------------------------------------------------------
FROM nginx:1.27-alpine AS runtime
# 8080 matches the container_port Terraform declares, so Cloud Run's injected
# $PORT lines up without an entrypoint script rewriting the config.
COPY site/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/site/dist /usr/share/nginx/html
RUN adduser -D -H -u 10001 web \
&& touch /var/run/nginx.pid \
&& chown -R web:web /var/run/nginx.pid /var/cache/nginx /usr/share/nginx/html
USER web
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]