Skip to content

Latest commit

 

History

History
367 lines (275 loc) · 14.9 KB

demo.md

File metadata and controls

367 lines (275 loc) · 14.9 KB

Deploying the demo — Cloud Run + Neon

How to put ELMS on a public HTTPS URL for $0/month, with a push to develop redeploying it.

Why these two

Google Cloud Run 2M requests + 180k vCPU-seconds/month always free, scales to zero, free HTTPS on *.run.app. The runtime image is a 29 MB distroless Go binary, so a cold start costs the visitor 1–2 seconds.
Neon Permanently free Postgres — 0.5 GB, 100 CU-hours/month, no credit card, does not expire.

The obvious alternatives were checked and rejected: Fly.io and Railway have no free tier for new accounts, Koyeb charges $29/mo for compute, and Render's own free Postgres expires 30 days after creation and is deleted 14 days later — fine for the SPA and the API, fatal for the database. Neon is the free database that stays.

Cloud Run does need a billing account with a card on file even to use the free tier. If that is a hard no, the fallback is Render (static site + web service, with an /api/* rewrite to keep one origin) over Neon, at the cost of a ~60 second cold start on the first API call after idle.


1. How the pieces connect

Two connections are easy to conflate. They are unrelated.

Browser → API: nothing to configure

web/src/api/client.ts hardcodes a relative base:

const API_BASE = '/api/v1'

A relative path, never a host — so the same bundle works everywhere and only the router in front of it changes:

Environment Who routes /api/v1
make dev Vite proxy → localhost:8080 (web/vite.config.ts)
docker compose up nginx → api:8080 (web/nginx.conf)
Cloud Run the Go binary itself (server/internal/webui)

There is deliberately no frontend environment variable for the API URL. Same-origin is what lets the Secure; HttpOnly; SameSite=Lax session cookie work with no CORS and no token juggling. A split origin would break /agent and /admin login while leaving the kiosk apparently fine — the worst kind of failure to diagnose.

API → Neon: DATABASE_URL, injected by Cloud Run

Cloud Run never reads .env. It reads environment variables set on the service, sourced from Secret Manager in step 4. See Local .env does not reach any of this for why the connection string does not belong in .env.

Three Dockerfiles, not two

docker compose up still builds two containers. Cloud Run's GitHub integration builds one image from one Dockerfile and cannot express that pair — Cloud Run does support sidecars, but only via gcloud run services replace service.yaml, which the repo-connect flow does not use.

So the deployed artifact collapses into a single container: the Go binary serves the SPA itself via go:embed.

File Used by Produces
server/Dockerfile docker compose API container
web/Dockerfile docker compose nginx + SPA container
Dockerfile (root) Cloud Run one image: SPA embedded in the binary
Local (docker compose) Deployed (Cloud Run)
SPA nginx Go binary, go:embed
/api/v1/* nginx → api:8080 same process
Origin one (nginx) one (the binary)
TLS none Cloud Run, automatic

This part is built. server/internal/webui embeds the bundle and mirrors web/nginx.conf's rules: /assets/* cached immutable for a year, a missing asset is a real 404 rather than HTML under a .js content type, and any other unmatched path serves index.html so the SPA router can decide. index.html itself is no-cache, or a deploy stays invisible. The root Dockerfile copies web/dist into the Go package tree before building, because go:embed cannot reach outside its own directory; a placeholder index.html is committed there so go build works without a bundle.

cmd/api then splits one origin two ways — /api/ to chi, everything else to the SPA. Compose and make dev are untouched.


2. Neon — the database

  1. Create a project at https://neon.tech. Pick a US East region to sit near us-east1.

  2. Copy the connection string:

    postgresql://USER:PASSWORD@ep-xxxx.us-east-2.aws.neon.tech/neondb?sslmode=require
    
  3. sslmode=require is mandatory — Neon refuses plaintext. Local compose uses sslmode=disable; do not carry that over.

  4. Use the direct endpoint, not the pooled one — the host without -pooler. Migrations run through golang-migrate (server/go.mod), which guards them with pg_advisory_lock. Advisory locks are session-scoped, and Neon's pooler runs PgBouncer in transaction mode, so consecutive statements can land on different backends and the lock leaks or fails to release. A demo will never need the pooler's connection count.

Migrations and seeding run on boot, so an empty Neon database is all that is needed. There is no import step.

Pre-flight — prove Neon works before touching GCP

cmd/seed migrates first, so this creates the schema and the demo data in one shot:

DATABASE_URL='postgresql://USER:PASS@ep-xxxx.us-east-2.aws.neon.tech/neondb?sslmode=require' \
  make seed

Green means Neon is reachable, TLS is right and migrations apply — which removes the database from the list of suspects before Cloud Run is in the picture.

Local .env does not reach any of this

.env is a docker-compose file, and .gitignore keeps it out of the repository. Two things follow, both of which surprise people:

  1. docker compose up ignores DATABASE_URL in .env. docker-compose.yml composes the api container's URL from POSTGRES_USER/PASSWORD/DB against the local postgres service. Setting DATABASE_URL in .env changes nothing.
  2. make dev / make seed also ignore .env. The Makefile uses ?=, which reads the shell environment; it never sources .env.

So point host-run commands at another database with an inline variable, as in the pre-flight above — not through .env.

3. Google Cloud — project and APIs

gcloud auth login
gcloud projects create elms-demo --name="ELMS demo"
gcloud config set project elms-demo

# Free tier still requires a linked billing account.
gcloud billing accounts list
gcloud billing projects link elms-demo --billing-account=XXXXXX-XXXXXX-XXXXXX

gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  artifactregistry.googleapis.com \
  secretmanager.googleapis.com

4. Secrets

Only the connection string needs to be a secret:

printf '%s' 'postgresql://…?sslmode=require' \
  | gcloud secrets create elms-database-url --data-file=-

Let the Cloud Run runtime service account read it:

PROJECT_NUMBER=$(gcloud projects describe elms-demo --format='value(projectNumber)')

gcloud secrets add-iam-policy-binding elms-database-url \
  --member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \
  --role=roles/secretmanager.secretAccessor

Demo logins

This deployment keeps the seeded admin123 / agent123 from the README, which is a deliberate choice for a challenge demo: the credentials are meant to be found. Understand what it buys anyone who reads the repository — the admin role can rewrite pricing, delete lockers and take a station out of service.

To use real passwords instead, add two more secrets and pass them as SEED_ADMIN_PASSWORD / SEED_AGENT_PASSWORD.

Set them before the first boot. SeedDemoData inserts users through CreateUserIfAbsent (internal/adapters/postgres/seed.go), so once admin exists its password is frozen: adding the variables later changes nothing and the deployment silently keeps accepting admin123. Recovering from that means deleting the two rows and letting the next boot reseed:

DELETE FROM sessions; DELETE FROM users;

5. First deploy

Deploy once by hand before wiring automation — it separates "the build is wrong" from "the GitHub connection is wrong".

gcloud run deploy elms \
  --source . \
  --region us-east1 \
  --allow-unauthenticated \
  --min-instances 0 \
  --max-instances 1 \
  --set-env-vars SEED=true,SESSION_TTL=24h \
  --set-secrets DATABASE_URL=elms-database-url:latest

--region us-east1 is not cosmetic. The free tier lives in exactly three regions — us-central1, us-east1, us-west1. A service anywhere else still runs, but none of its usage counts against the free allowance, so it bills from the first request. us-east1 (South Carolina) is the closest of the three to a Neon project in AWS us-east-2.

--source . picks up the root Dockerfile. PORT needs no setting — Cloud Run injects it and cmd/api honours it.

The command prints a https://elms-….run.app URL. That is the deployment this repository points at, published at https://elms-demo.sp3ck.com — see §8.

6. Continuous deployment from GitHub

Console → Cloud Run → the elms service → Set up continuous deployment:

  1. Authorise the Cloud Build GitHub app against the repository.
  2. Branch: ^develop$ — develop is the working branch here, and main trails it. Watch whichever branch actually carries the code, or Cloud Build will cheerfully build the wrong tree.
  3. Build type: Dockerfile, location /Dockerfile.

Every push to that branch now builds and rolls out a revision. Environment variables and secrets set in step 5 carry across revisions and need no repeating.

Doing it entirely in the console, without gcloud

The create-service wizard sets everything in one form, so steps 3–6 collapse into it: Cloud Run → Create service → Continuously deploy from a repository, then before creating, fill in the rest of the same form —

Form field Value
Region us-east1
Authentication Allow unauthenticated invocations
Container port 8080 (the default; PORT is injected)
Revision scaling min 0, max 3
Variables & Secrets → variables SEED=true, SESSION_TTL=24h
Variables & Secrets → secrets DATABASE_URL, SEED_ADMIN_PASSWORD, SEED_AGENT_PASSWORD

Referencing a Secret Manager secret there prompts to grant the runtime service account access, which covers the IAM binding in step 4.

Connecting the repository alone is not enough. cmd/api has no baked-in DATABASE_URL — credentials belong in the environment, never in the binary — so a revision deployed without one exits non-zero on boot and Cloud Run reports a failed revision against a perfectly good build.

7. Verify

Passing tests do not prove a deployment. Walk the journeys:

curl -sf https://elms-demo.sp3ck.com/api/v1/healthz && echo ok
# {"database":"up","status":"ok"}

Then in a browser:

  • / — kiosk loads, station list populates.
  • /admin — log in as admin with the generated password. This is the real test: if login appears to succeed but every later request 401s, the Secure cookie is not surviving, which means the origin split.
  • /agent — store a package, capture the one-time code.
  • / — kiosk: that locker label + that code → quote → pay → locker opens.
  • /admin — dashboard reflects the movement; change X and confirm the next quote uses the new rate.

A cold request after ~15 minutes idle should return in about 1–2 seconds. Neon also suspends after 5 minutes idle and takes a few hundred milliseconds to wake, so the first query of the day is the slow one.

Running the deploy image locally

The same image can be smoke-tested against the compose Postgres, which is how the routing rules above were confirmed:

docker build -t elms:cloudrun .
docker compose up -d postgres
docker run --rm --network elms_default -p 8099:8080 \
  -e DATABASE_URL='postgres://elms:elms@postgres:5432/elms?sslmode=disable' \
  -e SEED=true elms:cloudrun

http://localhost:8099 then serves the SPA and /api/v1 from one process. Session cookies work there despite plain HTTP because browsers treat localhost as a secure context.


8. A custom domain

The run.app hostname works, but it names a revision of infrastructure rather than a thing, and it changes if the service is ever recreated. This deployment answers on https://elms-demo.sp3ck.com instead, with Cloudflare in front of Cloud Run:

  1. In Cloudflare DNS, a CNAME for elms-demo → the ghs.googlehosted.com target Cloud Run gives you under Manage custom domains → Add mapping (proxied is fine — the origin is HTTPS either way).
  2. Cloud Run verifies ownership and issues its own certificate for the mapping, so there are two TLS hops and neither needs a certificate from you.

Nothing in the application changes. web/src/api/client.ts has always used the relative '/api/v1' base, so the SPA follows whatever hostname served it — which is also why the session cookie keeps working: one origin, Secure over real HTTPS, no CORS anywhere.

One consequence worth knowing before debugging a deploy through it: Cloudflare caches and reports as server: cloudflare, so a stale response or a 5xx may be the edge rather than the service. curl the run.app URL directly to tell them apart, and read x-cloud-trace-context — present only on responses that actually reached Cloud Run.


Staying at $0

Guardrail Why
Region us-east1 The free tier covers us-central1, us-east1, us-west1 and nothing else. Elsewhere the service runs but bills from the first request.
--min-instances 0 The default. Any floor above zero bills for a running instance 24/7, and is the most common way a free Cloud Run demo starts costing money.
--max-instances 1 Caps the blast radius of a crawler or a redirect loop.
Artifact Registry cleanup The meter that actually creeps: every push builds a new ~29 MB image against a 0.5 GB free allowance, so roughly 17 revisions before it bites. A policy keeping the last 3 settles it.
Container Scanning off containerscanning.googleapis.com bills $0.26 per image on first push. containeranalysis.googleapis.com is the free metadata store — the console likes to offer them together.
No keep-warm pinger Tempting, but it breaks Neon: 0.25 CU × 730 h = 182 CU-hours against a 100 CU-hour cap, and Neon hard-suspends at the limit. Take the cold start.
Budget alert Console → Billing → Budgets & alerts → $1, notify at 1%. An overrun should email you, not surprise you at month end.
Neon 0.5 GB storage Demo data is kilobytes. Only a runaway seed or a log table threatens this.

Teardown

gcloud run services delete elms --region us-east1
gcloud projects delete elms-demo          # also removes secrets and build artifacts

Delete the Neon project from its own console — it is not part of the GCP project.


Related