Deploying a containerized app to Google Cloud Run: what I learned
Context
A few months ago I built a private shared website for someone: a couples app with movie suggestions, 1v1 trivia, AI adventures on Gemini 2.5 Flash, and more — the full feature list sits in the README. The stack is Svelte 4 + Vite 5 on the front, Node.js + Express 4 on the back, MongoDB Atlas for storage, JWT auth, and a couple of security layers. The hosting requirements were strict: cost nothing when idle, zero maintenance, scale to zero between uses — we're a two-user app with occasional spike nights. I picked Google Cloud Run, deployed a single Docker container, and learned the serverless-container mental model the hard way. The site ran live for about a month before I scaled it down — the person I built it for stopped using it. It's still deployable as-is, it just doesn't earn its keep running 24/7.
A note on scope: this post is about the couple-website deploy, but it also draws on two other Cloud Run projects I run — the lessons from those are flagged as "separate project" where they appear.
Approach
The first decision was the container shape. I run everything in one container: Express serves both the API and the Svelte build output. No separate static hosting, no nginx, no multi-service split. One image, one service, one deploy command, and the SPA fallback just serves index.html for client-side routes. For a two-person app, a second service would have been pure tax.
The Dockerfile is three stages. Stage one builds the Svelte client on node:22-alpine. Stage two installs server dependencies with --omit=dev. Stage three copies the server plus the built client into a fresh node:22-alpine base and runs node server/index.js — the final image contains only what runs, no build tooling.
The deploy itself is one command from package.json:
gcloud run deploy couple-website --source . --region us-central1 --allow-unauthenticated
That's the command I started with. What actually shipped was better: I wired a Cloud Build trigger on the repo so every push to the branch deploys automatically. The evidence is in the service's own labels — gcb-trigger-id and commit-sha recorded on each revision, matching my git history. --allow-unauthenticated because the app has its own JWT auth layer — the public URL is the front door, the tokens are the lock. Rate limiting (express-rate-limit, 100 requests/15 min per IP) and helmet sit in front of every route.
Architecture
graph LR
A[Browser] --> B[Cloud Run: long-distance-website<br/>single container]
B --> C[Express static<br/>Svelte SPA build]
B --> D[Express API<br/>20 route modules]
D --> E[MongoDB Atlas<br/>Mongoose 8]
D --> F[Gemini 2.5 Flash via Vertex AI<br/>AI adventures]
D --> G[JWT auth<br/>bcryptjs]
B --> H[helmet + rate-limit<br/>100 req / 15 min / IP]
The build pipeline:
graph LR
A[git push to branch] --> B[Cloud Build trigger<br/>gcb-trigger-id label on service]
B --> C[Stage 1: Svelte client build]
C --> D[Stage 2: server deps --omit=dev]
D --> E[Stage 3: node:22-alpine prod image]
E --> F[Cloud Run<br/>scale to zero]
F --> G[env vars: MONGODB_URI, JWT_SECRET, GEMINI_API_KEY]
Evidence
The deploy command lives in package.json, and the container recipe is the three-stage Dockerfile. The container binds PORT=8080 (Cloud Run's expected port), sets GCP_PROJECT=long-distanced-website, and the service runs with scale-to-zero (no min-instances pinned — the default).
The git history shows the deploy evolving, not just happening. The most instructive commit is 65aa781, "chore: add GCP_PROJECT env var to Dockerfile" — the title undersells it, because this is the commit where I moved the AI features off the raw Gemini API and onto Vertex AI, which is why the container needed a project identifier baked in:
65aa781 chore: add GCP_PROJECT env var to Dockerfile
Dockerfile | 1 +
server/package.json | 1 +
server/services/gemini.js | 52 +++---
server/routes/ai-adventures.js | 12 +-
server/package-lock.json | 626 ++++++++++++++++++++++++++++++++++
5 files changed, 655 insertions(+), 37 deletions(-)
The follow-up 37671ce "vertex update" polished that migration across the API client and two routes (3 files, +17/−2) — evidence that the Vertex switch was a real code change with a real diff, not a config toggle. The deploy wasn't one magical command; it was an application change with deployment consequences.
The service is still live, and gcloud gives the honest receipt. The service was created 2026-06-26 and carries 17 revisions — the deploy history, not just the end state:
REVISION CREATED
long-distance-website-00017-4vq 2026-09-16 ← latest, 100% traffic
long-distance-website-00016-c62 2026-07-03
long-distance-website-00015-z6m 2026-07-03
long-distance-website-00014-x85 2026-07-03
... 13 more revisions back to 00001-7kv (2026-06-26)
Each revision's labels carry gcb-trigger-id and commit-sha — proof these were push-triggered deploys, not console clicks. The container runs 1 vCPU / 512 MiB with a 300-second timeout, and the environment reads exactly the three secrets the app needs: MONGODB_URI, JWT_SECRET, GEMINI_API_KEY. 17 deploys in ~2.5 months, then the site was scaled down (no traffic; the service still exists, ready to take a revision when needed).
What went wrong
Three things cost me real debugging time on this app, all Cloud Run-specific. Two more lessons come from separate projects I run on the same platform.
Cold starts are real. With scale-to-zero, the first request after idle pays instance spin-up: a 2–4 second gap on the first hit, then sub-100ms responses. Cloud Run keeps an instance warm only if traffic justifies it. I'd like to say I tuned min-instances and fixed it — I didn't. For our usage pattern, the free tier and the occasional cold start beat paying for an always-on VM. It's a tradeoff I accepted, not a bug I fixed.
Environment variables and the console round-trip. The .env file is excluded from the image, which is correct — but every secret change is a console round-trip: set the env var in the service settings, redeploy, verify. The first time I forgot GEMINI_API_KEY, the AI adventures feature returned 500s in production while working perfectly locally. The lesson: "your local and cloud configs are two separate systems and they will drift."
Uploads to /tmp do not survive. One feature writes uploaded EPUBs to /tmp/opencode/epub-uploads/. On Cloud Run, /tmp is ephemeral instance-local storage — wiped when the instance is recycled. I found this out when an upload worked, then vanished after a redeploy. For any persistent file, the answer is Cloud Storage behind the API, not the container filesystem. I still owe the app that refactor.
Env updates replace, they don't merge. On a separate project, I ran gcloud run services update --set-env-vars=... to change one variable and it replaced the entire environment — wiped sixteen existing vars, took the service down (a NoneType crash, then 503s) until I redeployed with --env-vars-file. The mental model: the env is one atomic unit per service.
Cloud Run has no static egress IP. Outbound traffic leaves from Google's shared regional pool, not from your container. On a separate project that broke my database allowlist: I opened the published regional ranges, Cloud Run's real egress still didn't match, and I had to probe the actual egress address out of the logs. If your backend talks to something that filters by source IP, serverless egress is a moving target.
Known limitations
- Cold starts after idle (accepted tradeoff for scale-to-zero and $0 idle cost).
- Files written to the container filesystem are ephemeral —
/tmpuploads must move to Cloud Storage. -
cors()is pinned tohttp://localhost:5173in the server config — harmless because the SPA is same-origin in production, but it's a latent foot-gun if the client ever moves off the container. - The app targets the Cloud Run free tier (2M requests/month, 360K vCPU-seconds, free SSL) — beyond that it's pay-per-request.
Lessons learned
- A single container serving static files and API is the simplest deploy story. For a small app, don't invent a second service.
- Container images should be build-free. Multi-stage Dockerfiles that ship only runtime artifacts are the difference between a 900MB image and a lean one.
- Secrets live in the platform, not the image.
.dockerignore+.gcloudignoreexcluding.envis the load-bearing wall — and env changes are console operations, plan for that. -
/tmpis a cache, not a filesystem. On serverless, assume anything you write to disk disappears. - Scale-to-zero is a cost model, not just a feature. "$0 when idle" is unbeatable for a hobby app, and it's worth a 2-second cold start once per session.
-
Cloud Run's cost advantage only exists with scale-to-zero. On a separate project,
min-instances=1Cloud Run ran ~$115–120/mo vs ~$16–23/mo for a small VM — a 5–10× gap from serverless taxes. If the workload can sleep, Cloud Run wins; if it must always be warm, price a VM first. - Regions are a deployment decision, not a default. I once found a service accidentally deployed to a distant default region instead of the one where the database lived. Pin the region in the deploy command and in CI.
Links
- Repo: Shaarkymoo/Long-Distance-Website — Svelte 4 + Express 4 + MongoDB Atlas, single-container Cloud Run deploy
- Google Cloud Run docs — the serverless container model, scaling, env vars and secrets
- Mete Atamel — Google's Cloud Run specialist; his run-throughs are the best mental-model resources on the service
- Kelsey Hightower — "understand the entire system" is why I traced the whole path from image build to cold start instead of stopping at "it deploys"
I'm open to Software Engineer and SecDevOps roles.
Top comments (0)