Heroku was the best way to deploy a web app for years. It still is — if your team is pre-product-market-fit and spending money on infrastructure feels like waste. But once you’re past that stage, the math changes fast. A standard Heroku dyno costs $25/month. Two dynos for redundancy: $50/month. Add Heroku Postgres at Standard-0: another $50/month. You’re at $100+ before traffic spikes, addons, or real scale.
Google Cloud Run charges only for requests served. A medium-traffic startup with 10M requests/month and 256MB-second compute time runs roughly $20–40. Zero-downtime rollout and rollback are built in. And if you’re already in GCP for other services, one less cloud to manage.
This guide walks you through the actual migration — not the “read the docs and figure it out” version, but the sequence that works.
Step 1: Dockerfile audit — does your app containerize cleanly?
Cloud Run requires your app to run in a container that listens on the PORT environment variable. Before anything else, check:
# Good: reads PORT from env
CMD ["node", "server.js"] # server.js: const port = process.env.PORT || 3000;
# Bad: hardcodes a port
EXPOSE 3000
CMD ["./myapp", "-port", "3000"]
Fix: Replace any hardcoded ports with $PORT. Cloud Run sets it to 8080 by default, but your Dockerfile should not hardcode this.
Also verify:
- Stateless? Cloud Run containers can be killed and restarted at any time. If your app writes to local disk (uploads, caches, temp files), move that to Cloud Storage before migrating.
- Startup time. Cloud Run can scale to zero. If your app takes 30 seconds to start, cold starts will hurt users. Target under 10 seconds.
- Health check. Cloud Run expects the container to accept traffic on
PORTquickly. Add a simpleGET /healthendpoint that returns200 OK.
Step 2: Move env vars to Secret Manager
On Heroku you set heroku config:set DATABASE_URL=.... In Cloud Run, you have two options:
- Cloud Run environment variables — fine for non-sensitive config (feature flags, service URLs, log levels)
- GCP Secret Manager — required for anything sensitive (database passwords, API keys, OAuth secrets)
For each secret from Heroku:
# Create the secret
echo -n "your-secret-value" | gcloud secrets create DATABASE_URL
--data-file=-
# Grant Cloud Run access
gcloud secrets add-iam-policy-binding DATABASE_URL
--member="serviceAccount:your-sa@your-project.iam.gserviceaccount.com"
--role="roles/secretmanager.secretAccessor"
In your Cloud Run service configuration, mount the secret as an environment variable. App code doesn’t change — it still reads process.env.DATABASE_URL. Only the source changes from Heroku’s config layer to GCP Secret Manager.
Don’t forget to migrate Heroku Postgres. Options:
- Cloud SQL (PostgreSQL) — managed Postgres on GCP, equivalent feature set, lower cost at most tiers
- Neon, Supabase — serverless Postgres options that pair naturally with Cloud Run’s scale-to-zero model
For a zero-downtime database migration, use pg_dump and pg_restore during low-traffic hours, then flip the connection string.
Step 3: First deploy to Cloud Run
Once the container is ready and secrets are in Secret Manager:
# Build and push to Artifact Registry
gcloud builds submit --tag gcr.io/YOUR_PROJECT/myapp:latest
# Deploy to Cloud Run
gcloud run deploy myapp
--image gcr.io/YOUR_PROJECT/myapp:latest
--region us-central1
--allow-unauthenticated
--set-env-vars "NODE_ENV=production"
--update-secrets="DATABASE_URL=DATABASE_URL:latest"
--min-instances 0
--max-instances 10
Cloud Run gives you a *.run.app URL immediately. Test it thoroughly before touching DNS.
Traffic split during rollout: Cloud Run lets you split traffic between revisions. Use this for your first migration:
# Deploy new revision, send 10% of traffic to it
gcloud run services update-traffic myapp
--to-revisions=REVISION_ID=10,PREVIOUS_REVISION=90
Watch error rates for 15–30 minutes. If stable, bump to 50%, then 100%.
Step 4: Rollback in 30 seconds
If the new revision has problems, rollback is one command:
gcloud run services update-traffic myapp
--to-revisions=PREVIOUS_REVISION=100
Or click “Edit traffic” in the Cloud Console. Unlike Heroku — where a bad git push heroku main leaves you scrambling to revert and re-push — Cloud Run keeps all previous revisions alive until you explicitly delete them. Rollback is instant, not a re-deploy.
Step 5: DNS cutover
Once the new Cloud Run revision is stable and serving test traffic:
- Add a custom domain: Cloud Console → Cloud Run → your service → Manage Custom Domains
- Add the
CNAMEorArecord your DNS provider shows you - Wait for propagation (5 minutes to a few hours depending on TTL)
- Verify HTTPS is live — Cloud Run auto-provisions a managed TLS certificate
- Remove the old Heroku custom domain
Keep the Heroku app running for 48 hours after DNS cutover, in case any traffic was cached to the old endpoint.
Cost comparison: Heroku vs Cloud Run
| Setup | Heroku | Cloud Run |
|---|---|---|
| 2 dynos (Standard-2x) | $100/mo | ~$15–30/mo |
| Postgres (Standard-0, 25 GB) | $50/mo | Cloud SQL ~$25/mo |
| Custom domain + SSL | Included | Included |
| Auto-scaling | Paid add-on | Built-in |
| Zero-downtime deploy | Built-in | Built-in |
| Rollback | Manual re-deploy | Revision traffic split |
| Typical startup total | $150–300+/mo | $40–80/mo |
The gap widens at scale. Heroku’s dyno pricing is fixed regardless of traffic. Cloud Run’s per-request model means a slow weekend costs almost nothing.
Common gotchas
Sticky sessions. Cloud Run is stateless and load-balanced across instances. If your app relies on sticky sessions (common with Socket.io or server-side session stores), move session state to Redis (Cloud Memorystore) before migrating.
Scheduled jobs (Heroku Scheduler). Cloud Run has no built-in scheduler. Use Cloud Scheduler to trigger a Cloud Run job on a cron schedule, or migrate to Cloud Tasks for queue-driven work.
Background workers. If you use Heroku worker dynos for queue consumers, deploy them as a separate Cloud Run service with --min-instances 1 so it doesn’t scale to zero while waiting for queue messages.
WebSocket apps. Cloud Run supports WebSockets, but you need HTTP/2 enabled and sessions are not sticky. Architect for reconnection from the client side.
The migration in one glance
| Step | What to do | Watch for |
|---|---|---|
| 1. Dockerfile audit | PORT from env, stateless, fast startup |
Hardcoded ports, local disk writes |
| 2. Secrets | Move to Secret Manager, grant SA access | Don’t forget DB credentials |
| 3. First deploy | gcloud run deploy, test on *.run.app URL |
Cold start latency, health check |
| 4. Traffic split | 10% → 50% → 100%, watch errors | Error rate spike = rollback |
| 5. DNS cutover | Map custom domain, managed TLS | Keep Heroku alive 48 h |
What’s next
Cloud Run is a natural step up from Heroku — same simplicity, better economics, and it grows with you from 100 to 1M users without re-architecting. Once you’re on Cloud Run, the next step is wiring a CI/CD pipeline so every push to main builds, tests, and deploys automatically — without anyone running gcloud run deploy by hand.
Read Your First CI/CD Pipeline Checklist for the exact setup, and the DevOps for Startups guide for the full platform picture.
Migrating from Heroku or a VM and want someone to review your setup before you cut over DNS? Book a free intro call — we’ll audit your architecture and hand you a migration checklist.

