Skip to content

Moving a Node.js ecommerce API off Heroku without rewriting it

The application code does not change. What changes is the deployment model: a buildpack and a dyno become a Dockerfile, an image and a revision.

From the workJuly 22, 20267 min read

A Heroku app that has run for years is not really a Node.js application. It is a Node.js application plus everything the platform was quietly doing on its behalf: choosing a runtime version, installing dependencies, injecting a port, holding the configuration, and running whatever somebody typed into Scheduler in 2021. Move only the code and you meet all of it at once, usually at eleven at night.

What follows changes nothing about the application and everything about how it is built, configured and run. It is the shape of the Heroku to Cloud Run migration we did on a Node.js ecommerce API estate, and the constraint that makes it safe is the same one that makes it boring: no rewrite.

1. Inventory the running estate first

Take this from the platform, not the repository, because the repository does not know about half of it.

  • Every process type. The web process is obvious; the workers are the ones people forget, and they do not translate one to one. A worker polling a queue becomes a Cloud Run service with a minimum instance, a Cloud Run job on a schedule, or a push subscription that turns the queue into HTTP requests.
  • Every scheduled job. Scheduler entries are invisible in the codebase. Each becomes a Cloud Scheduler job hitting an authenticated endpoint or triggering a Cloud Run job.
  • Every add-on. Decide per add-on whether it moves, stays or is replaced — and note that stays is a valid answer that removes an entire class of risk.
  • Every config var, including the ones set and unused, because you will not know which are which until something fails.
  • Every inbound caller. Which storefronts, apps, webhooks and partners have your hostname hardcoded.
  • Runtime version and native dependencies. What Node version the dyno is actually running, and whether anything links against a system library the buildpack was providing for free.

2. Containerise: write down what the buildpack was doing

The buildpack did four things: picked a Node version, installed production dependencies, ran your build script, and started your process with a port in the environment. A Dockerfile does the same four, explicitly.

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

Pin the Node version to what the dyno was actually running, at least for the first deploy. A platform migration and a runtime upgrade are two changes, and doing them together gives every failure two candidate causes.

Bind to all interfaces on the injected port. Cloud Run sets PORT the same way Heroku did, so reading it already works — but a server bound to 127.0.0.1 passes locally and fails health checks in a container.

Handle SIGTERM. Both platforms send it, and an API that does not drain in-flight requests on shutdown drops a small percentage of them on every deploy. This is one of the few places the application genuinely needs a change, and it is a dozen lines.

Then the thing containers make you confront: the filesystem is not yours. On Cloud Run the container filesystem lives in memory, so a temporary file counts against the instance memory limit and a large upload buffered to disk becomes an out-of-memory restart. Uploads and generated artefacts belong in object storage.

3. One path from a commit to a revision

The build path is deliberately linear, and there is exactly one of it: GitHub, to Cloud Build, to Artifact Registry, to Cloud Run.

A push to the deploy branch fires a Cloud Build trigger. Cloud Build builds the image and tags it with the commit SHA — not latest, which is how you lose the ability to say what is serving. The image goes to Artifact Registry, and Cloud Build deploys it, creating a new revision. That buys traceability in both directions: from a running revision you can name the image, from the image the commit, from the commit the diff.

Scroll the figure sideways to see all of it

Revisions are also the rollback mechanism, and they are better than the one you are leaving. A revision is immutable — image, environment, secret references, concurrency, memory and service account frozen as one unit — so rolling back is pointing traffic at the previous one, in seconds, with no rebuild. It also enables traffic splitting: five percent to the new revision, watch the error rate, move the rest.

Set concurrency deliberately rather than accepting the default. A dyno was one process serving whatever the router sent it; a Cloud Run instance serves up to a configured number of concurrent requests. For an API mostly awaiting a database, high concurrency is correct and cheap; for one doing CPU work per request it turns straight into latency.

4. Configuration and identity

Config vars become secret references, not literals

The tempting move is a one-to-one translation: every config var becomes a Cloud Run environment variable. Do not. Anything that is a credential — database URLs, API keys, signing and webhook secrets — goes into Secret Manager and is mounted into the revision by reference. What stays a plain environment variable is the non-secret remainder: log level, feature flags, a public base URL.

The difference is not cosmetic. A secret in Secret Manager has versions, an access policy, and an audit trail of who read it. A value in a dashboard's environment tab has none of those and gets pasted into a chat thread during an incident. Pin the version explicitly rather than tracking latest — a revision that would silently pick up a changed value is no longer immutable, and immutability was the point.

One runtime identity per service

The default outcome, if nobody decides, is that everything runs as one broadly permissioned account — the same posture as the shared credential you probably had before. Create a service account per Cloud Run service and grant it precisely what that service needs: read on the two secrets it uses, publish on the one topic it writes to.

This costs an hour. It contains the blast radius of a compromised service, and it documents what each service may touch in a place that cannot drift from reality — the same reasoning we apply to any API estate, cloud or not.

5. Run both estates, then move the DNS

Nothing above is the risky part. The risk is the moment traffic changes, and the way to make it small is to have proved the new estate works while the old one is still serving customers.

Deploy every service to Cloud Run with its own URL, point a staging hostname at it, and run real traffic shapes through it: the endpoints your storefront actually calls, the webhooks your platform actually sends and the scheduled jobs on their real schedules, behind a dry-run flag.

Watch three numbers during the overlap. Cold-start latency on the endpoints that matter: a container taking eight seconds to boot needs a minimum instance. The memory ceiling under real payload sizes. And database connection count, which is the one that bites — a platform that scales out horizontally will open more connections than Postgres accepts, and the answer is a pooler plus a small per-instance pool, decided before the cutover.

Then the cutover is one change. Drop the DNS TTL to sixty seconds a day or two beforehand so going back is fast. Move the record. Watch error rate, latency and the logs of every downstream caller.

Keep the old estate running, untouched, for days afterwards — leaving it up means rollback is a DNS change back, the cheapest rollback there is. Decommission only after a full business cycle has passed through the new estate and every scheduled job has fired at least once on the new side.

Scroll the figure sideways to see all of it

What this does not fix

Moving to Cloud Run does not make a slow endpoint fast, does not turn a monolith into services. The application is the same application, which was the point — the migration is safe precisely because it is not also a rewrite.

What changes is what you can do afterwards: roll back in seconds, split traffic, rotate a credential without redeploying everything, and say exactly which commit is serving a request. Those properties are what make the next change safe. If a rewrite is needed at all it is a separate project, and far easier to run against an estate that can already be rolled back — which is most of what this kind of migration is for, and where the rest of the Google Cloud work starts.

Blog