Deploy a Container to the Cloud with Google Cloud Run
Containerize a Node.js service, push it to Artifact Registry, and ship it to Cloud Run with a public HTTPS URL, env vars, secrets, and HTTP health checks.
What you'll build
You'll containerize a small Node.js web service, push the image to Google Artifact Registry, and deploy it to Cloud Run — a managed, serverless container platform. By the end you'll have a public HTTPS URL, injected environment variables, a working /healthz probe, and live logs you can stream from your terminal.
Cloud Run is a good fit here because it runs any container that listens on a port, scales to zero, and gives you TLS and a URL for free. The same concepts (registry → image → managed service → env/logs/health) transfer to AWS App Runner, Azure Container Apps, or Fly.io.
Prerequisites
- A Google Cloud account with billing enabled (Cloud Run has a generous free tier, but billing must be on).
- The gcloud CLI installed and up to date. Verify with
gcloud version— you want 470.0.0 or newer. Install docs: https://cloud.google.com/sdk/docs/install - Docker installed (
docker --version, 20.10+). On macOS/Windows use Docker Desktop; on Linux use the Docker Engine. - Node.js 18+ locally if you want to run the app before containerizing (
node --version). - Basic familiarity with the shell and Dockerfiles.
Set a couple of shell variables now so the commands below are copy-pasteable. Replace your-project-id with your real project ID (find it with gcloud projects list).
export PROJECT_ID="your-project-id"
export REGION="us-central1"
export REPO="apps"
export SERVICE="hello-cloud"
export IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${SERVICE}:1.0"
1. Create the application
Make a project folder and a minimal Express server. The two things that matter for any cloud container platform: listen on the PORT env var and bind to 0.0.0.0.
mkdir hello-cloud && cd hello-cloud
npm init -y
npm install express@4
Create server.js:
const express = require("express");
const app = express();
const PORT = process.env.PORT || 8080;
const GREETING = process.env.GREETING || "Hello";
app.get("/", (req, res) => {
res.json({ message: `${GREETING} from Cloud Run`, host: req.hostname });
});
// Lightweight health endpoint — no external dependencies
app.get("/healthz", (req, res) => {
res.status(200).send("ok");
});
app.listen(PORT, "0.0.0.0", () => {
console.log(`listening on ${PORT}`);
});
2. Write the Dockerfile
Use a slim official base image and a non-root user. Cloud Run injects PORT=8080 by default, but we don't hardcode it.
FROM node:18-slim
WORKDIR /app
# Install dependencies first for better layer caching
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# node:18-slim ships a non-root "node" user
USER node
EXPOSE 8080
CMD ["node", "server.js"]
Add a .dockerignore so you don't ship junk into the image:
node_modules
npm-debug.log
.git
.env
Build and test locally before touching the cloud:
docker build -t ${SERVICE}:local .
docker run --rm -p 8080:8080 -e GREETING="Local dev" ${SERVICE}:local
In another terminal, curl http://localhost:8080/ should return JSON, and curl http://localhost:8080/healthz should return ok. Stop the container with Ctrl-C.
3. Enable APIs and create a registry
Authenticate and point gcloud at your project:
gcloud auth login
gcloud config set project ${PROJECT_ID}
Enable the services you'll use:
gcloud services enable run.googleapis.com artifactregistry.googleapis.com
Create a Docker-format Artifact Registry repository to hold images:
gcloud artifacts repositories create ${REPO} \
--repository-format=docker \
--location=${REGION} \
--description="App images"
Configure Docker to authenticate to that registry host:
gcloud auth configure-docker ${REGION}-docker.pkg.dev
4. Build, tag, and push the image
Tag your image with the full Artifact Registry path and push it:
docker build -t ${IMAGE} .
docker push ${IMAGE}
Apple Silicon note: Cloud Run runs
linux/amd64. If you're on an M-series Mac, build for that architecture explicitly:docker build --platform linux/amd64 -t ${IMAGE} . docker push ${IMAGE}Otherwise the container may fail to start with an
exec format error.
Confirm the image landed:
gcloud artifacts docker images list ${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}
5. Deploy to Cloud Run
Deploy the image as a service, passing an environment variable and allowing public access.
There's a gotcha here: --set-env-vars splits multiple KEY=VALUE pairs on commas, so a comma inside a value (like Hello, production) is misread as a delimiter and the deploy fails with bad syntax for dict arg. To embed a comma, switch the delimiter using gcloud's ^DELIM^ prefix syntax — here we use ## as the delimiter so the comma in the value is literal:
gcloud run deploy ${SERVICE} \
--image=${IMAGE} \
--region=${REGION} \
--port=8080 \
--allow-unauthenticated \
--set-env-vars=^##^GREETING=Hello, production
(If you don't need the comma, the simpler --set-env-vars=GREETING=Hello works without the delimiter prefix.)
The command prints a Service URL like https://hello-cloud-abc123-uc.a.run.app. That endpoint is already behind managed TLS.
For secrets (API keys, DB passwords) don't use --set-env-vars. Store them in Secret Manager and reference them, so they never appear in your shell history or deploy logs.
First enable the Secret Manager API (it isn't enabled by the commands in step 3):
gcloud services enable secretmanager.googleapis.com
Create the secret:
echo -n "super-secret-value" | gcloud secrets create api-key --data-file=-
Cloud Run mounts secrets using the service's runtime service account (by default, the Compute Engine default service account). gcloud does not grant secret access automatically, so you must give that account the roles/secretmanager.secretAccessor role on the secret — otherwise the new revision fails to start. Find the project number and grant access:
PROJECT_NUMBER=$(gcloud projects describe ${PROJECT_ID} --format='value(projectNumber)')
RUNTIME_SA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
gcloud secrets add-iam-policy-binding api-key \
--member="serviceAccount:${RUNTIME_SA}" \
--role="roles/secretmanager.secretAccessor"
Then wire the secret into the service as an env var:
gcloud run services update ${SERVICE} --region=${REGION} \
--set-secrets=API_KEY=api-key:latest
If your service uses a custom runtime service account (set via
--service-accounton deploy), grant the role to that account instead of the default Compute SA.
6. Add an HTTP health check
By default Cloud Run considers a container healthy once it accepts TCP connections on the port. To make health checks meaningful, add an HTTP startup and liveness probe pointing at /healthz. These are configured through the service YAML.
Important:
gcloud run services replacereplaces the entire service spec. Any setting you don't include in the YAML — public access (IAM), the secret you mounted in step 5, scaling flags, etc. — reverts to defaults. Don't hand-write the file from scratch for an existing service. Instead, export the current config and edit it so you keep everything already configured:
gcloud run services describe ${SERVICE} --region=${REGION} \
--format=export > service.yaml
Open service.yaml and add the probes under the container entry (keep the exported image, env, resources, etc. intact):
# ...existing exported fields above...
spec:
template:
spec:
containers:
- image: us-central1-docker.pkg.dev/your-project-id/apps/hello-cloud:1.0
ports:
- containerPort: 8080
env:
- name: GREETING
value: "Hello, production"
startupProbe:
httpGet:
path: /healthz
timeoutSeconds: 2
periodSeconds: 3
failureThreshold: 10
livenessProbe:
httpGet:
path: /healthz
periodSeconds: 10
Apply it:
gcloud run services replace service.yaml --region=${REGION}
Note that replace operates on the service spec only and does not touch the IAM policy, so --allow-unauthenticated set in step 5 is preserved. Now a container that starts but fails /healthz will be replaced automatically, and traffic won't shift to a revision that never became healthy.
Verify it works
Fetch the URL and curl both endpoints:
URL=$(gcloud run services describe ${SERVICE} --region=${REGION} --format='value(status.url)')
curl "$URL/"
curl "$URL/healthz"
Expected output:
{"message":"Hello, production from Cloud Run","host":"hello-cloud-abc123-uc.a.run.app"}
and ok from /healthz.
Stream logs from your terminal:
gcloud run services logs read ${SERVICE} --region=${REGION} --limit=50
You should see your listening on 8080 line plus request logs. The same logs are searchable in the Cloud Console under Logging > Logs Explorer.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Deploy fails: "Container failed to start. Failed to listen on the port defined by the PORT environment variable" | App listens on a hardcoded port or on 127.0.0.1 |
Read process.env.PORT and bind to 0.0.0.0 (see server.js). |
bad syntax for dict arg on deploy |
Comma inside an env-var value parsed as a delimiter | Use the alternate delimiter: --set-env-vars=^##^KEY=value, with comma. |
Revision fails to start after --set-secrets |
Runtime service account lacks secretmanager.secretAccessor |
Grant the role on the secret (see step 5). |
exec format error in logs |
Image built for arm64 on Apple Silicon | Rebuild with --platform linux/amd64 and push again. |
403 / denied: Permission ... artifactregistry on push |
Docker not authenticated to the registry host | Run gcloud auth configure-docker ${REGION}-docker.pkg.dev. |
Browser returns 403 Forbidden |
Service requires authentication | Redeploy with --allow-unauthenticated, or call it with curl -H "Authorization: Bearer $(gcloud auth print-identity-token)" "$URL/". |
Next steps
- Continuous deploys: wire up
gcloud builds submitor connect the service to a GitHub repo so pushes trigger a build + deploy with Cloud Build. - Scaling and cost: tune
--min-instances,--max-instances,--cpu, and--memoryto balance cold starts against cost. - Custom domains: map your own domain via Cloud Run > Manage Custom Domains or a global external load balancer.
- Observability: explore request latency and error-rate metrics in Cloud Monitoring, and add structured JSON logging so fields are queryable in Logs Explorer.
- Portability: the image you built runs unchanged on AWS App Runner or Azure Container Apps — only the deploy command and registry change.
Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.
Discussion 0
No comments yet
Be the first to weigh in.