Skip to content
Cloud & Infra Intermediate Tutorial

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.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Jun 9, 2026 · 11 min read

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.

Advertisement

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-account on 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 replace replaces 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 submit or 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 --memory to 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 Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading