# Ephemera — Static website on Google Cloud: GCS + Cloud CDN + external HTTPS LB (gcloud)

> **Requires** `delegated-zone(${DOMAIN_NAME})` (any registrar binding — [`domain.aws.md`](./domain.aws.md) or
> [`domain.cloudflare.md`](./domain.cloudflare.md)) **only when `DOMAIN_MODE != none`**; the `none` branch uses the
> load balancer's own global IP over a Google-managed cert on a `nip.io`-style host or the bare IP (HTTP→HTTPS).
> **Provides** `site(${DOMAIN_NAME:-$LB_IP})` — a live, CDN-fronted, HTTPS static site. The third binding of the
> **same web intent** as [`web.aws.md`](./web.aws.md) and [`web.cloudflare.md`](./web.cloudflare.md): it exists to
> prove the model generalizes past AWS/Cloudflare, and it passes the *same* acceptance contract.

## 🤖 Director prompt

You are the runtime. Resolve the Provisioning Inputs once, write them into Live State, then walk the steps —
stopping at every 🔴/💥 for human go and writing Live State back after every mutation and verify. The cloud is
the source of truth; never trust this file's Live State over a live `gcloud … describe`. **Status: dogfooded live
across both branches.** Run 1 (2026-06-27): `static` + `DOMAIN_MODE=none` (HTTP-on-IP) end-to-end — bucket → Cloud
CDN → external LB served `200`+marker and a real `404`, then torn down (all GCP labels are **tag-after**; the
none-branch HTTP path is written out; the LB step is cost-flagged). Run 2 (2026-06-30): `spa` + `DOMAIN_MODE=subdomain`
full-HTTPS — Google-managed cert ACTIVE ~42 min, root `200`+marker over a valid Google cert, deep route → `200`
app-shell, then torn down. That run surfaced **3 real bugs in the `spa` LB path, now fixed in §4**: the app-shell
custom-error-response needs the **`EXTERNAL_MANAGED`** scheme (the classic `EXTERNAL` rejects it), `matchResponseCodes`
must be a **quoted string** `["404"]`, and the url-map must be imported via **`gcloud beta`** (GA silently drops the
policy). The model-review-missed-it lesson repeated — only running it surfaced the EXTERNAL-vs-EXTERNAL_MANAGED limit.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## Intent

Serve a **static website** over HTTPS from a global CDN on Google Cloud: objects in a **Google Cloud Storage**
bucket, fronted by an **external Application Load Balancer** with **Cloud CDN** enabled, on a Google-managed TLS
certificate. **`SITE_TYPE` decides routing** — identical knob to the AWS/Cloudflare plans: `spa` returns the app
shell for unknown paths; `static` serves real files and returns a real `404` for misses. The serving hostname is
a **Provisioning Input**: `none` uses the load balancer's global IP; an `apex`/`subdomain` domain adds a
Google-managed cert and (unless DNS is external) a DNS A-record at the delegated zone. **Identical intent to the
AWS and Cloudflare plans — only the binding differs.**

**Shared acceptance contract** (the same test all three bindings must pass — mirrors [`web.aws.md`](./web.aws.md) / [`web.cloudflare.md`](./web.cloudflare.md)):
1. `GET /` → `200` + body marker `EPHEMERA-OK`
2. deep link — `spa`: `GET /some/deep/route` → `200` (app-shell fallback); `static`: a real path → `200`, a miss → `404`
3. HTTPS enforced

> **Two honest binding asymmetries** (the same kind of portability insight web.cloudflare records about private origin):
> - **Private origin (the AWS bullet 4):** a GCS **backend bucket** serves objects the load balancer can read, which
>   means `allUsers:objectViewer` — the bucket is *public-read by design*, so "direct object URL → 403" does **not**
>   hold here the way it does on AWS OAC. The privacy posture is "no secrets in the bucket; the LB is the front door."
>   A *true* private origin on GCP needs a Cloud Run/Function proxy with `ingress=internal-and-cloud-load-balancing`
>   — out of scope (see Deliberately not included), and named so the omission is a decision, not an oversight.
> - **SPA app-shell status (bullet 2, `spa` only):** GCS website hosting returns its `NotFoundPage` with a **404**
>   status, not `200`. To satisfy "deep link → `200`" for `spa`, the app-shell fallback is done at the **load
>   balancer URL map** (a route rule rewriting unmatched paths to `/index.html`), not at the bucket. `static` has no
>   such wrinkle — the bucket's real-`404` is exactly what the contract wants.

## Provisioning Inputs

> **Same table as [`web.aws.md`](./web.aws.md)** — the inputs are identical across bindings; only realization differs.
> The Director resolves them once, writes `resolved_inputs` into Live State, and branches the steps below. GCP adds
> three binding-locals: `GCP_PROJECT`, `GCP_REGION` (bucket location), and `BUCKET` (globally-unique name).
> `DOMAIN_MODE=none` reproduces the bare-IP behavior.

| # | Input | values | default | var | used in |
|---|-------|--------|---------|-----|---------|
| 1 | Site type | `spa` / `static` | `spa` | `SITE_TYPE` | §1, §4, §6 |
| 2 | Domain mode | `none` / `apex` / `subdomain` | `none` | `DOMAIN_MODE` | §3, §5 |
| 3 | Domain name (if not none) | free-text | — | `DOMAIN_NAME` | §3, §5, §6 |
| 4 | DNS mode (if not none) | `managed` / `external` | `managed` | `DNS_MODE` | §5 |
| 5 | Content marker | free-text | `EPHEMERA-OK` | `CONTENT_MARKER` | §6 |
| 6 | GCP project | free-text | — | `GCP_PROJECT` | all |
| 7 | Bucket location | e.g. `us`, `us-central1` | `us` | `GCP_REGION` | §1 |
| 8 | Bucket name | globally-unique | `${GCP_PROJECT}-web` | `BUCKET` | §1, §3 |

## Live State

```yaml
status:        not-created      # published template - run it to realize state
                             # not-created ≠ unproven — see EPHEMERA.md § Live State. Proven live
                             # 2026-06-27 (static) and 2026-06-30 (spa+subdomain), torn down each time.
resolved_inputs:
  site_type:   —
  domain_mode: —
  domain_name: —
realized:
  BUCKET:        —
  BACKEND_BUCKET: —
  URL_MAP:       —
  SSL_CERT:      —
  LB_IP:         —            # the reserved global IP
  CERT_STATUS:   —            # PROVISIONING | ACTIVE (managed certs take minutes)
last_verified: —
verify:
| ✔ check                    | expected                        | observed | result |
|----------------------------|---------------------------------|----------|--------|
| root → 200 + marker        | 200 + EPHEMERA-OK               | —        | —      |
| deep link (per SITE_TYPE)  | spa:200 shell / static:404 miss | —        | —      |
| HTTPS enforced             | http→https, cert ACTIVE         | —        | —      |
```

## Tags & provenance (binding asymmetry)

GCP has **labels**, not AWS-style tags — `[a-z0-9_-]`, ≤63 chars, key starts with a letter, and the **content is
lowercased** (`ManagedBy`→`managedby`). The `tags_labels` renderer (in `scripts/tags.sh`, embedded in §0) normalizes
`$TAGS` to that charset. Labels go on the resources that accept them (the **GCS bucket** primarily; the global
**address** and **forwarding rule** are labeled **tag-after** — *no* create command in this plan accepts `--labels`
(`buckets/addresses/forwarding-rules create` all reject it; proven live 2026-06-27), so labels go on via
`<resource> update --update-labels`). The **url-map / target-proxy / backend-bucket do not
carry labels** — provenance for those is by **naming convention** (`${NAME_PREFIX}-*`) plus the bucket's labels, the
same documented-omission discipline the AWS plans use for un-taggable sub-resources. The full `Source` URL cannot fit
a 63-char label, so it is also written to the bucket's **description**-equivalent (a `source=` label, normalized).

## 0. Variables

```bash
set -euo pipefail
export GCP_PROJECT="${GCP_PROJECT:?set the project}" GCP_REGION="${GCP_REGION:-us}"
export SITE_TYPE="${SITE_TYPE:-spa}" DOMAIN_MODE="${DOMAIN_MODE:-none}" CONTENT_MARKER="${CONTENT_MARKER:-EPHEMERA-OK}"
export BUCKET="${BUCKET:-${GCP_PROJECT}-web}" NAME_PREFIX="${NAME_PREFIX:-ephemera-web}"
export ENV="${ENV:-prod}"
gcloud config set project "$GCP_PROJECT" >/dev/null

# ── TAGS — resolved once, applied everywhere (blank values drop; dup keys last-wins) ──
# Canonical source + tests: scripts/tags.sh, scripts/test/tags_render_test.sh — keep in sync.
PLAN_SOURCE="web.gcp.md"
TAGS="$(printf '%s\n' \
  "ManagedBy=ephemera" \
  "Source=${PLAN_SOURCE}" \
  "PlanVersion=2026-06-30" \
  "CostCenter=${COST_CENTER:-}" \
  "Owner=${OWNER:-}" \
  "Environment=${ENV}")"
# GCP labels: lowercase key+value content, [^a-z0-9_-]->_ , <=63 chars, key must start with a letter.
_tags_list() { printf '%s\n' "$TAGS" "$@" | awk '
  { eq=index($0,"="); if(eq==0) next; k=substr($0,1,eq-1); v=substr($0,eq+1); if(v=="") next;
    val[k]=v; if(!(k in seen)){ order[++n]=k; seen[k]=1 } }
  END { for(i=1;i<=n;i++) print order[i]"="val[order[i]] }'; }
tags_labels() { _tags_list "$@" | while IFS='=' read -r k v; do
  _lk=$(printf '%s' "$k" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9_-]/_/g' | cut -c1-63)
  _lv=$(printf '%s' "$v" | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9_-]/_/g' | cut -c1-63)
  printf '%s=%s,' "$_lk" "$_lv"
done | sed 's/,$//'; }
```

## Dependency frontier

```
DOMAIN_MODE? ─┬─ none      ─> §1 bucket ─> §2 deploy ─> §4 LB (IP only) ──────────────> §6 verify ─> Provides site(LB_IP)
              └─ apex/sub  ─> Requires delegated-zone ─> §1 ─> §2 ─> §3 cert ─> §4 LB ─> §5 DNS ─> §6 ─> Provides site(DOMAIN_NAME)
SITE_TYPE? ─── static ─> bucket web-error-page=404.html (real 404)   ·   spa ─> url-map route rule rewrites misses ─> /index.html (200)
```

## 1. Origin bucket  🟢🟡

```bash
# 🟢 create the bucket (uniform access; objects served via the LB → public-read, see asymmetry note)
gcloud storage buckets create "gs://$BUCKET" --location="$GCP_REGION" --uniform-bucket-level-access
# 🟡 label it — GCP labels are TAG-AFTER here: `buckets create` has NO --labels flag (proven live 2026-06-27).
gcloud storage buckets update "gs://$BUCKET" --update-labels="$(tags_labels)"

# 🟡 website config branches on SITE_TYPE: spa app-shell fallback is done at the LB (§4), so the bucket's
#     error page is index.html for spa (harmless) and 404.html for static (the real-404 the contract wants).
ERRPAGE=$([ "$SITE_TYPE" = static ] && echo 404.html || echo index.html)
gcloud storage buckets update "gs://$BUCKET" --web-main-page-suffix=index.html --web-error-page="$ERRPAGE"

# 🟡 backend-bucket model requires the LB to read objects → grant public object read
gcloud storage buckets add-iam-policy-binding "gs://$BUCKET" \
  --member=allUsers --role=roles/storage.objectViewer
```
> **→ write Live State:** `BUCKET`, `status: creating`.

## 2. Deploy content  🟢

```bash
# upload the built site; long-cache immutable assets, no-cache the entry HTML (two-pass cache discipline)
gcloud storage cp -r ./dist/* "gs://$BUCKET/"
gcloud storage objects update "gs://$BUCKET/index.html" --cache-control="no-cache, max-age=0"
```
> **→ write Live State:** content deployed.

## 3. TLS certificate — Google-managed  🔴 GATE  *(custom domain only; skipped when `DOMAIN_MODE=none`)*

```bash
if [ "$DOMAIN_MODE" != none ]; then
  gcloud compute ssl-certificates create "${NAME_PREFIX}-cert" \
    --domains="$DOMAIN_NAME" --global
  # managed certs go ACTIVE only AFTER the forwarding rule (§4) serves the domain + DNS (§5) resolves — minutes.
fi
```
> **→ write Live State:** `SSL_CERT`, `CERT_STATUS: PROVISIONING`.

## 4. Load balancer + Cloud CDN  🟡 💰  *(the global forwarding rule bills **hourly** — this is the cost-incurring step)*

```bash
# backend bucket with Cloud CDN
gcloud compute backend-buckets create "${NAME_PREFIX}-bb" --gcs-bucket-name="$BUCKET" --enable-cdn

# url map — default to the backend bucket (global resource → --global). spa adds a custom error response so a
# backend 404 is served as /index.html at 200 (the app-shell fallback; GCS itself returns 404 — asymmetry note).
gcloud compute url-maps create "${NAME_PREFIX}-um" --global --default-backend-bucket="${NAME_PREFIX}-bb"
if [ "$SITE_TYPE" = spa ]; then
  # no single flag for this — the documented path is export → add the policy → re-import. (errorService accepts
  # the relative form on import; an export shows the full self-link.) `static` skips this block (real 404 is wanted).
  # ⚠ Two non-obvious gotchas, both proven live 2026-06-30:
  #   1. matchResponseCodes must be a QUOTED STRING ["404"] — the integer form [404] is rejected on import.
  #   2. Use `gcloud beta` — the GA `url-maps import` SILENTLY DROPS the policy (no error; the field is just
  #      absent on a later `describe`). `beta` needs the component once (`gcloud components install beta`); set
  #      CLOUDSDK_CORE_DISABLE_PROMPTS=1 so it can't hang on the component-install prompt non-interactively.
  gcloud compute url-maps export "${NAME_PREFIX}-um" --global --destination=/tmp/um.yaml
  cat >>/tmp/um.yaml <<YAML
defaultCustomErrorResponsePolicy:
  errorResponseRules:
  - matchResponseCodes: ["404"]
    path: /index.html
    overrideResponseCode: 200
  errorService: global/backendBuckets/${NAME_PREFIX}-bb
YAML
  CLOUDSDK_CORE_DISABLE_PROMPTS=1 gcloud beta compute url-maps import "${NAME_PREFIX}-um" --global --source=/tmp/um.yaml --quiet
fi

# reserve a global IP, then label it (TAG-AFTER — `addresses create` has NO --labels flag, proven live 2026-06-27)
gcloud compute addresses create "${NAME_PREFIX}-ip" --global
gcloud compute addresses update "${NAME_PREFIX}-ip" --global --update-labels="$(tags_labels)"
LB_IP="$(gcloud compute addresses describe "${NAME_PREFIX}-ip" --global --format='value(address)')"

# spa REQUIRES the newer EXTERNAL_MANAGED global-LB scheme — the classic EXTERNAL rejects the app-shell
# custom-error-response policy ("Advanced routing rules are not supported for scheme EXTERNAL", proven live
# 2026-06-30). static wants a real 404 (no policy) so the classic default is fine; EXTERNAL_MANAGED serves it too.
LB_SCHEME=$([ "$SITE_TYPE" = spa ] && echo EXTERNAL_MANAGED || echo EXTERNAL)
if [ "$DOMAIN_MODE" != none ]; then
  # HTTPS front door (custom domain): managed cert on a target-HTTPS-proxy, forwarding rule on :443
  gcloud compute target-https-proxies create "${NAME_PREFIX}-hp" --global \
    --url-map="${NAME_PREFIX}-um" --ssl-certificates="${NAME_PREFIX}-cert"
  gcloud compute forwarding-rules create "${NAME_PREFIX}-fr" --global \
    --load-balancing-scheme="$LB_SCHEME" \
    --target-https-proxy="${NAME_PREFIX}-hp" --ports=443 --address="${NAME_PREFIX}-ip"
else
  # none branch: HTTP-only front door (no cert) — target-HTTP-proxy + forwarding rule on :80. Bare-IP HTTPS
  # needs a hostname, so `none` is HTTP for smoke-testing; real use takes a domain (contract bullet 3). [proven live]
  gcloud compute target-http-proxies create "${NAME_PREFIX}-hp" --global --url-map="${NAME_PREFIX}-um"
  gcloud compute forwarding-rules create "${NAME_PREFIX}-fr" --global \
    --load-balancing-scheme="$LB_SCHEME" \
    --target-http-proxy="${NAME_PREFIX}-hp" --ports=80 --address="${NAME_PREFIX}-ip"
fi
# 🟡 label the forwarding rule (TAG-AFTER — `forwarding-rules create` has NO --labels flag, proven live 2026-06-27)
gcloud compute forwarding-rules update "${NAME_PREFIX}-fr" --global --update-labels="$(tags_labels)"
# ⏳ a global external LB takes ~3-7 min to start serving after the forwarding rule exists (proven: 200 at ~4 min).
```
> **→ write Live State:** `BACKEND_BUCKET`, `URL_MAP`, `LB_IP`, `status: partial` until the cert is ACTIVE.

## 5. DNS — A record at the delegated zone  🟡 / 🔴  *(custom domain + `DNS_MODE=managed`)*

```bash
if [ "$DOMAIN_MODE" != none ] && [ "$DNS_MODE" = managed ]; then
  # point the domain at LB_IP in whichever zone the registrar binding Provides (Route53 or Cloudflare).
  echo "Create an A record: ${DOMAIN_NAME} → ${LB_IP}  (in the delegated zone from domain.*.md)"
  # then poll the managed cert until ACTIVE:
  gcloud compute ssl-certificates describe "${NAME_PREFIX}-cert" --global --format='value(managed.status)'
fi
```
> **→ write Live State:** `CERT_STATUS: ACTIVE` once the managed cert provisions; `status: live`.

## 6. Acceptance verify  ✔

```bash
BASE="https://${DOMAIN_NAME:-$LB_IP}"
echo "Live at: $BASE/"

# ✔ root → 200 + marker
curl -s -o /dev/null -w 'root_http=%{http_code}\n' "$BASE/"
curl -s "$BASE/" | grep -o "$CONTENT_MARKER" && echo "marker=found"

# ✔ deep link — branches on SITE_TYPE
if [ "$SITE_TYPE" = static ]; then
  curl -s -o /dev/null -w 'missing_http=%{http_code}\n' "$BASE/no-such-page"     # expect 404
else
  curl -s -o /dev/null -w 'deeplink_http=%{http_code}\n' "$BASE/some/deep/route" # expect 200 (LB rewrite → shell)
fi

# ✔ HTTPS enforced (managed cert ACTIVE + serves the domain)
echo | openssl s_client -connect "${DOMAIN_NAME:-$LB_IP}:443" -servername "${DOMAIN_NAME:-$LB_IP}" 2>/dev/null \
  | openssl x509 -noout -subject 2>/dev/null
```
> **→ write Live State:** `status: live`, fill the verify ledger, set `last_verified`.

## Update (idempotent reconcile)

- New site content → re-run §2 (the `gsutil` upload + two-pass cache headers: long-cache immutable assets,
  no-cache the entry HTML). Cloud CDN serves new objects on next fetch; cache-busting filenames avoid a stale
  edge.
- Change `SITE_TYPE` (spa⇄static) → re-run §1's bucket website-config (`404.html` for static vs the LB
  fallback for spa) **and** §4's url-map (spa's custom error response that serves a backend 404 as
  `/index.html` at 200). Then re-run §6's deep-link check.
- Add a custom domain after a `DOMAIN_MODE=none` stand-up → run §3 (Google-managed cert 🔴), repoint §4's
  forwarding rule at a **target-HTTPS-proxy** (the none-branch made a target-HTTP-proxy — the other no-ops),
  and add the §5 A record. Cert goes ACTIVE in ~10–60 min.
- Re-label (new `CostCenter`/`Owner`) → every GCP label here is **tag-after**: re-run the
  `… update --update-labels "$(tags_labels)"` calls in §1/§4 (bucket, IP, forwarding rule); the `create` calls
  take no `--labels` flag.

## Teardown — reverse order  💥

```bash
# delete LB front-to-back, then the bucket. Each is destructive → human go. Both proxy types are listed because
# the none branch makes a target-HTTP-proxy and the domain branch a target-HTTPS-proxy — the absent one no-ops.
for r in "forwarding-rules ${NAME_PREFIX}-fr --global" \
         "target-https-proxies ${NAME_PREFIX}-hp --global" "target-http-proxies ${NAME_PREFIX}-hp --global" \
         "url-maps ${NAME_PREFIX}-um --global" "backend-buckets ${NAME_PREFIX}-bb --global" \
         "ssl-certificates ${NAME_PREFIX}-cert --global" "addresses ${NAME_PREFIX}-ip --global"; do
  gcloud compute $r delete -q || echo "  (already gone: $r)"
done
gcloud storage rm -r "gs://$BUCKET" || echo "  (bucket already gone)"
```
> **→ write Live State:** `status: gone`.

## Portability ledger — same intent, three bindings

| concern        | web.aws.md (S3+CloudFront)     | web.cloudflare.md (Worker assets) | web.gcp.md (GCS+Cloud CDN)            |
|----------------|-------------------------------|-----------------------------------|--------------------------------------|
| origin privacy | OAC → bucket truly private    | no separate origin (edge)         | backend-bucket is **public-read**    |
| spa fallback   | CloudFront Function 404→index | Worker / SPA routing              | **LB url-map rewrite** (GCS 404s)    |
| TLS            | ACM (us-east-1)               | Cloudflare universal              | Google-managed cert                  |
| free preview URL | `*.cloudfront.net` (HTTPS)  | `*.workers.dev` (HTTPS)           | **none — a bare IP (HTTP only)** ⚠️  |
| resource count | ~6 (bucket, OAC, dist, …)     | ~1 (Worker + assets)              | ~6 (bucket, bb, um, proxy, fr, cert) |

The contract is identical; the **shape and the honest caveats** differ — which is exactly the portability story.

> **Asymmetry #3 — no free hostname (the temp-URL gap).** AWS and Cloudflare auto-mint a free, HTTPS preview hostname
> (`*.cloudfront.net` / `*.workers.dev`), so their `DOMAIN_MODE=none` is a *real* usable URL. The GCP external LB
> mints only a **bare anycast IP** — and a Google-managed cert can't bind to a bare IP — so `none` here is **HTTP-on-IP,
> smoke-test only**. To get HTTPS you point your own DNS at the LB IP (an A-record) + a managed cert: that's the
> `DOMAIN_MODE != none` path below — the **same custom-domain step AWS and Cloudflare also offer** (we keep it for
> parity), it's just *mandatory* for a usable URL on GCP rather than optional. (Quick-peek without DNS:
> `https://storage.googleapis.com/$BUCKET/index.html` — free HTTPS but object-level, not the routed site.)

## Deliberately *not* in this plan (named, so the omission is a decision)

- **True private origin** (Cloud Run/Function proxy with `ingress=internal-and-cloud-load-balancing`) — the
  GCS backend-bucket model is public-read; a private origin is a different, heavier binding. Named, not assumed.
- **HTTP→HTTPS redirect resource** for the `none` branch — bare-IP HTTPS needs a hostname; the `none` branch is
  HTTP-on-IP for smoke-testing, and real use takes a domain.
- **Cloud Armor** (the WAF analog) — parallels `EDGE_PROTECTION` in web.aws.md; add as a follow-on binding.
- **Multi-region / dual-stack IPv6** — single global IP is enough to prove the contract.
