Zayda Cummings
WKNC 88.1NC State student radio

zayda.dev

How this is built

The repository for my resume is private. Though, the cloud definition files, including the Terraform and Dockerfile, are below and imported directly from my repository. The commit hash is included at image build time.

Commit
b70e8d3e9819
Built
2026-10-27
Region
us-east1

Architecture

Users

  1. Visitoropens zayda.dev
  2. Squarespace DNSpoints zayda.dev at Cloud Run

request

CI/CD pipeline

  1. Mergeto develop or main
  2. GitHub Actionstype check · tests · build · audit
  3. Workload Identitysigns in, no stored keys
  4. Artifact Registryimage tagged by commit

deploy: develop to UAT, main to production

Cloud RunHTTPS, then nginx serving the built site

Terraform defines the services, the registry and the keyless sign-in.

A visit to zayda.dev is looked up in its DNS, kept at Squarespace where the domain is registered, which points it at a Cloud Run service. Google's front end handles HTTPS with a certificate it manages, and nginx serves the built Astro site. A change merged to develop or main runs the checks, signs in to Google Cloud through Workload Identity Federation instead of a stored key, and publishes an image tagged with its commit. From develop it goes to UAT; from main it serves everyone who visits.

Decisions

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.

Key source files

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.tf189 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",
    # Terraform reads project services and IAM through it. Applying from a
    # laptop never needed it, because gcloud bills those calls to its own
    # project; CI's service account bills them here, so planning in CI failed
    # with "Cloud Resource Manager API has not been used in project" until it
    # was on.
    "cloudresourcemanager.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"

+125 more lines in the repository

site/Dockerfile59 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
# Not into conf.d/ — nginx auto-includes everything there as a server block,
# and this file is a bare list of add_header directives. It is included
# explicitly by each location in default.conf.
COPY site/security-headers.conf /etc/nginx/security-headers.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;"]