How to put ELMS on a public HTTPS URL for $0/month, with a push to develop
redeploying it.
| 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.
Two connections are easy to conflate. They are unrelated.
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.
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.
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.
-
Create a project at https://neon.tech. Pick a US East region to sit near
us-east1. -
Copy the connection string:
postgresql://USER:PASSWORD@ep-xxxx.us-east-2.aws.neon.tech/neondb?sslmode=require -
sslmode=requireis mandatory — Neon refuses plaintext. Local compose usessslmode=disable; do not carry that over. -
Use the direct endpoint, not the pooled one — the host without
-pooler. Migrations run through golang-migrate (server/go.mod), which guards them withpg_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.
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 seedGreen 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.
.env is a docker-compose file, and .gitignore keeps it out of the repository.
Two things follow, both of which surprise people:
docker compose upignoresDATABASE_URLin.env.docker-compose.ymlcomposes the api container's URL fromPOSTGRES_USER/PASSWORD/DBagainst the localpostgresservice. SettingDATABASE_URLin.envchanges nothing.make dev/make seedalso ignore.env. TheMakefileuses?=, 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.
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.comOnly 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.secretAccessorThis 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;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.
Console → Cloud Run → the elms service → Set up continuous deployment:
- Authorise the Cloud Build GitHub app against the repository.
- Branch:
^develop$—developis the working branch here, andmaintrails it. Watch whichever branch actually carries the code, or Cloud Build will cheerfully build the wrong tree. - 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.
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.
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 asadminwith the generated password. This is the real test: if login appears to succeed but every later request 401s, theSecurecookie 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.
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:cloudrunhttp://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.
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:
- In Cloudflare DNS, a
CNAMEforelms-demo→ theghs.googlehosted.comtarget Cloud Run gives you under Manage custom domains → Add mapping (proxied is fine — the origin is HTTPS either way). - 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.
| 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. |
gcloud run services delete elms --region us-east1
gcloud projects delete elms-demo # also removes secrets and build artifactsDelete the Neon project from its own console — it is not part of the GCP project.
- Run it locally —
docker compose up, no cloud involved. - Technical design §12 — configuration and environment variables.
- Task breakdown — M8 ship polish.