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
single container] B --> C[Express static
Svelte SPA build] B --> D[Express API
20 route modules] D --> E[MongoDB Atlas
Mongoose 8] D --> F[Gemini 2.5 Flash via Vertex AI
AI adventures] D --> G[JWT auth
bcryptjs] B --> H[helmet + rate-limit
100 req / 15 min / IP]

The build pipeline:

graph LR
    A[git push to branch] --> B[Cloud Build trigger
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
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 (5)

Collapse
 
vlad_z_16b6320e21f32bee0d profile image
Vlad Z •

Cloud Run stays cheap until min instances or always-allocated CPU is switched on, and those two settings decide whether an idle service costs zero or a steady monthly amount

Curious which of the two you ran with for this deployment

Collapse
 
shaarkymoo profile image
Shaarav Agarwal •

So i had auto-scaling on, with min-instances set to 0, and max set to 1. This specific project wasn't meant to run for a lot of people nor was it meant to store any sensitive data, so i got away with being lax on stuff like security. We mostly used it a couple times a week, and all costs (cloud run, data egress, etc) fell within the free tier for me. There was also no need to always have CPU allocated since all scripts were only designed to run upon human activity and no background maintenance required.

Collapse
 
vlad_z_16b6320e21f32bee0d profile image
Vlad Z •

That's exactly the right shape for it, min instances at zero and no CPU always allocated means you're not paying for the gaps between runs

The one thing worth a second look, even inside the free tier: max instances at 1 means two people using it at the same second queue behind each other instead of running in parallel, which is usually fine for a personal project and worth knowing about if usage ever grows

Did you ever hit that queueing in practice, or has it stayed rare enough not to notice?

Thread Thread
 
shaarkymoo profile image
Shaarav Agarwal •

Stayed rare and was unnoticeable even if it happened. All scripts and calls were short, even if they queried an external source, it stayed fast enough for us not to notice. This was a fairly small project, so unless i actually run something like DDoS on it to benchmark robustness or load, I don't think it will exhibit significant lag even with 10 users (a very small number I know, but it was meant for an even lesser amount of people).

Thread Thread
 
vlad_z_16b6320e21f32bee0d profile image
Vlad Z •

Makes sense, short calls and a handful of uses a week means the odds of two requests landing in the same second were never high to begin with, good that it held up in practice