DEV Community

Shaarav Agarwal
Shaarav Agarwal

Posted on

Deploying a containerized app to Google Cloud Run: what I learned

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
Enter fullscreen mode Exit fullscreen mode

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(-)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 — /tmp uploads must move to Cloud Storage.
  • cors() is pinned to http://localhost:5173 in 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 + .gcloudignore excluding .env is the load-bearing wall — and env changes are console operations, plan for that.
  • /tmp is 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=1 Cloud 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)