Deploy a Node.js Express App to Google Cloud Run from Source — Dockerfile, PORT, Scale to Zero, and the npm Script That Makes Redeploys One Command
A minimal, production-ready way to run an Express app on Cloud Run: the Dockerfile, reading PORT, a deploy script in package.json, scaling to zero with a small memory limit, and the mistakes that cost the first hour.
Cloud Run is the least-effort way to put a small Node.js site on the internet with HTTPS, autoscaling and a bill that rounds to zero when nobody visits. This guide shows the exact setup we use for a family of Express sites, deployed straight from source with one command.
The app must read PORT
Cloud Run injects the port at runtime. Hard-coding 3000 is the most common first failure ("container failed to start and listen on the port defined by PORT").
const PORT = parseInt(process.env.PORT, 10) || 3000;
app.listen(PORT, () => console.log(`listening on ${PORT}`));
Also add a cheap health route. Do not call it /healthz: on Cloud Run that exact path is intercepted at Google's edge and never reaches your container (see the /healthz gotcha). /health works.
Dockerfile
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "app.js"]
Copying package*.json first lets Docker cache the npm ci layer, so a content-only change rebuilds in seconds. Add a .dockerignore with node_modules, .env, .git and anything else that should not ship.
One-command deploy from package.json
"scripts": {
"deploy": "gcloud run deploy my-site --source . --project my-project --region us-central1 --allow-unauthenticated --set-env-vars SITE_HOST=example.com --min-instances 0 --max-instances 2 --memory 256Mi --quiet"
}
--source . uploads the folder, builds the image with Cloud Build, pushes it and deploys, all in one step. --quiet suppresses prompts so it runs from scripts. --min-instances 0 scales to zero; --memory 256Mi is plenty for an Express site serving Markdown. Then:
npm run deploy
The first run takes a few minutes; later runs usually finish in about two.
Environment variables and secrets
Pass non-secret config with --set-env-vars. Put real secrets in Secret Manager and reference them with --set-secrets NAME=secret:latest; never bake them into the image or commit .env. Locally, load .env with dotenv as the first line of the app so the same code works in both places.
Verify
curl -sI https://my-site-xxxx.us-central1.run.app/health
Look for HTTP/2 200 and the x-cloud-trace-context header, which proves the response came from your container rather than Google's edge.
Costs
With scale to zero, a low-traffic site costs a few cents a month at most: you pay only for request time and a small amount for container image storage. Keeping --max-instances small protects you from a traffic spike or a bot turning into a bill.
Common mistakes
- Hard-coding the port. Read
process.env.PORT. - Naming the health route
/healthz. Use/health. - Shipping
node_modulesin the upload. Add it to.dockerignore; Cloud Build installs from the lockfile. - Forgetting
--allow-unauthenticated. Without it the service returns 403 to the public.
Summary
Read PORT, add a /health route, use a two-stage-copy Dockerfile, and put the full gcloud run deploy --source . command in an npm script. From then on every change is npm run deploy.