Deploying containerized Python and Express apps to Google Cloud Run using Docker, Artifact Registry, IAM, Secret Manager, autoscaling, and revisions.

Deploying Containerized Python & Express Apps to Google Cloud Run: A Production Walkthrough

Getting an application to run inside Docker is usually the easy part.

Getting that container into production without exposing secrets, breaking authentication, hardcoding ports, losing logs, or creating a deployment that cannot be rolled back is where the real work begins.

Google Cloud Run sits in an interesting middle ground.

You give it a container image. Cloud Run handles the underlying servers, HTTPS endpoint, autoscaling, revisions, request routing, and integration with Google Cloud IAM.

That makes it attractive for:

  • Python APIs;
  • Express.js applications;
  • webhook listeners;
  • internal tools;
  • lightweight backend services;
  • scheduled HTTP workers;
  • data-processing APIs;
  • automation services;
  • inference endpoints;
  • event-driven applications.

But Cloud Run still expects the container to follow a few important rules.

This walkthrough deploys two applications:

Python + Flask + Gunicorn

and:

Node.js + Express

using Docker, Artifact Registry, Cloud Build, Secret Manager, IAM, and Cloud Run.

Along the way, we will also cover the production decisions that are easy to miss in a simple quickstart.


Table of Contents

What Google Cloud Run Actually Does

Cloud Run is a managed container runtime.

Instead of provisioning a virtual machine and manually installing:

Linux
Python / Node.js
Nginx
Process manager
TLS certificates
Scaling logic
Load balancer

you provide a container image.

Cloud Run runs it for you.

At a high level:

Application Code
      ↓
Docker Image
      ↓
Artifact Registry
      ↓
Cloud Run Revision
      ↓
HTTPS Endpoint
      ↓
Users / Services / Webhooks

Cloud Run can automatically create additional container instances when traffic increases and reduce them again when demand falls.

For many services, it can scale down to zero instances when there is no traffic.

That is fundamentally different from a traditional VM where your server usually remains running continuously.


Cloud Run Service vs. Cloud Run Job

Before deploying anything, make sure you are using the correct Cloud Run resource.

Cloud Run Service

A service is designed to receive requests.

Examples:

REST API
Express application
Flask API
Webhook endpoint
Internal web application
Inference API

The container starts a web server and listens for incoming requests.

Cloud Run Job

A job is designed to execute work and exit.

Examples:

Nightly ETL
Batch file processing
Database cleanup
One-time migration
Scheduled report generation

A Cloud Run job should not start an HTTP server.

Its process should complete with:

exit code 0

when successful, and a non-zero exit code when it fails.

For scheduled batch workloads, Cloud Scheduler can trigger Cloud Run jobs or services depending on the architecture.

For a deeper comparison of scheduling options, see:

CRON vs GitHub Actions vs Google Cloud Scheduler

This article focuses on Cloud Run services.


The Cloud Run Container Contract

Cloud Run can run almost any Linux container, but the container must follow its runtime contract.

The most important requirement is:

Listen on the port provided by the PORT environment variable.

Cloud Run injects:

PORT

into the ingress container.

The default is normally:

8080

but production code should not assume that value permanently.

Your application must also listen on:

0.0.0.0

not:

127.0.0.1

because Cloud Run needs to reach the application from outside the container process itself.

Correct:

0.0.0.0:8080

Incorrect:

127.0.0.1:8080

Cloud Run terminates TLS before forwarding traffic to your container, so your application generally does not need to manage HTTPS certificates itself.


The Production Architecture We Will Build

The basic deployment flow will be:

Developer
   ↓
Application Source
   ↓
Dockerfile
   ↓
Cloud Build
   ↓
Artifact Registry
   ↓
Cloud Run
   ↓
HTTPS Endpoint

For secrets:

Secret Manager
      ↓
Cloud Run Revision
      ↓
Application

For Google Cloud API access:

Cloud Run Service
      ↓
Runtime Service Account
      ↓
IAM
      ↓
BigQuery / Storage / Pub/Sub / Secret Manager

No service-account JSON key file needs to be baked into the image.

That distinction matters.


Prerequisites

You need:

Google Cloud project
Google Cloud CLI
Cloud Run API
Artifact Registry API
Cloud Build API
Docker locally, if you want local container testing

Authenticate the CLI:

gcloud auth login

Set your project:

gcloud config set project YOUR_PROJECT_ID

Then define a few values.

export PROJECT_ID="YOUR_PROJECT_ID"
export REGION="asia-south1"
export REPOSITORY="cloud-run-apps"

Replace the region with the region appropriate for your workload.

Keeping the application, Artifact Registry repository, database, and related services geographically close usually reduces latency and cross-region complexity.


Enable the Required APIs

Enable Cloud Run, Artifact Registry and Cloud Build.

gcloud services enable \
  run.googleapis.com \
  artifactregistry.googleapis.com \
  cloudbuild.googleapis.com

If the application will use Secret Manager:

gcloud services enable \
  secretmanager.googleapis.com

Other applications may require APIs such as:

BigQuery
Cloud SQL
Pub/Sub
Cloud Storage
Firestore

Only enable what the application actually needs.


Create an Artifact Registry Repository

Artifact Registry stores the container images that Cloud Run will deploy.

Create a Docker repository:

gcloud artifacts repositories create cloud-run-apps \
  --repository-format=docker \
  --location=asia-south1 \
  --description="Cloud Run application images"

Your image URLs will follow this pattern:

REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/IMAGE:TAG

For example:

asia-south1-docker.pkg.dev/my-project/cloud-run-apps/python-api:v1

Using explicit image versions such as:

v1
v2
2026-10-24
git-abc123

makes production deployments easier to trace than relying exclusively on:

latest

Part 1: Deploying a Containerized Python Application

Create the Python Project

We will use:

Flask
+
Gunicorn

The project structure is:

python-api/
├── app.py
├── requirements.txt
├── Dockerfile
└── .dockerignore

Create the Flask Application

Create:

app.py
import os

from flask import Flask, jsonify

app = Flask(__name__)


@app.get("/")
def index():
    return jsonify(
        service="python-api",
        status="running"
    )


@app.get("/healthz")
def health():
    return jsonify(status="ok"), 200


if __name__ == "__main__":
    port = int(os.environ.get("PORT", "8080"))

    app.run(
        host="0.0.0.0",
        port=port
    )

The local Flask server is useful during development.

For production, we will use Gunicorn.


Add Python Dependencies

Create:

requirements.txt
Flask>=3.1,<4
gunicorn>=23,<24

For a real production repository, pin and update dependencies through your normal dependency-management process.

Avoid leaving dependencies permanently unconstrained.


Why Use Gunicorn Instead of Flask’s Development Server?

The Flask development server is designed primarily for development.

Cloud Run should run a production WSGI server.

Gunicorn is a common choice.

The process model becomes:

Cloud Run
   ↓
Gunicorn
   ↓
Flask

instead of:

Cloud Run
   ↓
Flask development server

Create the Python Dockerfile

Create:

Dockerfile
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /app

RUN addgroup --system appgroup \
    && adduser --system \
       --ingroup appgroup \
       appuser

COPY requirements.txt .

RUN pip install \
    --no-cache-dir \
    -r requirements.txt

COPY --chown=appuser:appgroup . .

USER appuser

CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:${PORT:-8080} --workers 1 --threads 8 app:app"]

There are several intentional decisions here.


Why the Dockerfile Uses ${PORT}

Cloud Run injects the port through:

PORT

Using:

${PORT:-8080}

means:

Use Cloud Run's PORT value when present.

Otherwise use 8080 locally.

The shell wrapper exists because environment-variable substitution does not happen automatically inside every JSON-form Docker CMD.


Why the Container Runs as a Non-Root User

The Dockerfile creates:

appuser

and runs the application as that user.

Cloud Run already provides container isolation and blocks privilege escalation, but running application processes as a non-root user is still a useful container-hardening practice.

Do not design the application around privileged host operations.

Cloud Run does not provide a Docker-style:

--privileged

execution mode.


Add .dockerignore

Create:

.dockerignore
.git
.gitignore
__pycache__
*.pyc
*.pyo
*.pyd
.venv
venv
.env
.env.*
Dockerfile*
README*
tests

This prevents local files from unnecessarily entering the Docker build context.

Most importantly, do not accidentally copy:

.env
credentials
local service-account keys

into production images.


Test the Python Container Locally

Build:

docker build -t python-api .

Run:

docker run \
  --rm \
  -p 8080:8080 \
  -e PORT=8080 \
  python-api

Then test:

curl http://localhost:8080/

Expected response:

{
  "service": "python-api",
  "status": "running"
}

Health check:

curl http://localhost:8080/healthz

Build the Python Image with Cloud Build

From the parent directory:

gcloud builds submit \
  --tag \
  "$REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/python-api:v1" \
  ./python-api

Cloud Build:

Uploads source
      ↓
Reads Dockerfile
      ↓
Builds image
      ↓
Pushes image
      ↓
Artifact Registry

You do not need Docker running locally when Cloud Build performs the build remotely.


Part 2: Deploying a Containerized Express.js Application

Create the Express Project

Use this structure:

express-api/
├── server.js
├── package.json
├── package-lock.json
├── Dockerfile
└── .dockerignore

Initialize:

mkdir express-api
cd express-api

npm init -y
npm install express

Create the Express Server

Create:

server.js
const express = require("express");

const app = express();

app.disable("x-powered-by");

app.use(express.json({
  limit: "1mb"
}));

app.get("/", (req, res) => {
  res.status(200).json({
    service: "express-api",
    status: "running"
  });
});

app.get("/healthz", (req, res) => {
  res.status(200).json({
    status: "ok"
  });
});

const port =
  Number.parseInt(
    process.env.PORT || "8080",
    10
  );

app.listen(port, "0.0.0.0", () => {
  console.log(
    `Express server listening on port ${port}`
  );
});

Two details matter for Cloud Run:

process.env.PORT

and:

"0.0.0.0"

Add the Start Script

Update:

package.json

so it contains:

{
  "scripts": {
    "start": "node server.js"
  }
}

Your dependencies remain in the same file.


Create the Express Dockerfile

FROM node:22-slim AS dependencies

WORKDIR /app

COPY package*.json ./

RUN npm ci --omit=dev


FROM node:22-slim

ENV NODE_ENV=production

WORKDIR /app

COPY --from=dependencies \
  /app/node_modules \
  ./node_modules

COPY --chown=node:node . .

USER node

CMD ["node", "server.js"]

The first stage installs production dependencies.

The second stage contains:

application code
+
production node_modules

and runs under Node’s non-root user.


Add the Express .dockerignore

node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
coverage
test
tests
README*

You do not want your local:

node_modules

copied into the image.

The dependencies inside the image should come from:

npm ci

using the lock file.


Test the Express Container Locally

Build:

docker build -t express-api .

Run:

docker run \
  --rm \
  -p 8080:8080 \
  -e PORT=8080 \
  express-api

Test:

curl http://localhost:8080/

Expected response:

{
  "service": "express-api",
  "status": "running"
}

Build the Express Image

gcloud builds submit \
  --tag \
  "$REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/express-api:v1" \
  ./express-api

At this point, Artifact Registry contains both images.

cloud-run-apps/
├── python-api:v1
└── express-api:v1

Part 3: Create Runtime Service Accounts

Do Not Use Service-Account Key Files Inside Cloud Run

This is an important production rule.

Avoid creating:

service-account.json

and putting it inside your container.

Also avoid setting:

GOOGLE_APPLICATION_CREDENTIALS=/app/service-account.json

for normal Cloud Run access to Google Cloud APIs.

Cloud Run supports service identity.

Assign a Google Cloud service account to the service.

Cloud client libraries running inside Cloud Run can then obtain credentials through Application Default Credentials.

The architecture becomes:

Cloud Run
   ↓
Service Identity
   ↓
IAM
   ↓
Google Cloud API

rather than:

Container
   ↓
Long-lived JSON key
   ↓
Google Cloud API

Create a Dedicated Python Runtime Identity

gcloud iam service-accounts create python-api-runner \
  --display-name="Python Cloud Run runtime"

Create one for Express:

gcloud iam service-accounts create express-api-runner \
  --display-name="Express Cloud Run runtime"

Their addresses will look like:

python-api-runner@PROJECT_ID.iam.gserviceaccount.com

and:

express-api-runner@PROJECT_ID.iam.gserviceaccount.com

Apply Least Privilege

Do not automatically grant:

Editor
Owner

to application service accounts.

Give each service only the permissions it actually needs.

For example, a service that only reads objects from a Storage bucket should not receive broad project-wide administration permissions.

Similarly:

BigQuery reader
≠
BigQuery administrator

Secret accessor
≠
Secret administrator

Runtime identity and deployment identity should also be treated as separate concepts.

The account deploying Cloud Run does not have to be the account Cloud Run uses while the application is running.


Part 4: Deploy the Python Service

Deploy as a Private Service

gcloud run deploy python-api \
  --image="$REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/python-api:v1" \
  --region="$REGION" \
  --service-account="python-api-runner@$PROJECT_ID.iam.gserviceaccount.com"

Cloud Run services are protected by authentication by default.

That is normally the correct starting point for:

internal APIs
administrative tools
backend services
service-to-service communication

Deploy a Public API

If the application intentionally needs unauthenticated public access:

gcloud run deploy python-api \
  --image="$REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/python-api:v1" \
  --region="$REGION" \
  --service-account="python-api-runner@$PROJECT_ID.iam.gserviceaccount.com" \
  --allow-unauthenticated

Do not automatically add:

--allow-unauthenticated

to every tutorial deployment.

Public access should be a deliberate decision.

Some organizations also enforce policies that prevent Cloud Run services from being exposed publicly.


Part 5: Deploy the Express Service

The Express deployment is almost identical.

gcloud run deploy express-api \
  --image="$REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/express-api:v1" \
  --region="$REGION" \
  --service-account="express-api-runner@$PROJECT_ID.iam.gserviceaccount.com"

For an intentionally public service:

gcloud run deploy express-api \
  --image="$REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/express-api:v1" \
  --region="$REGION" \
  --service-account="express-api-runner@$PROJECT_ID.iam.gserviceaccount.com" \
  --allow-unauthenticated

Cloud Run returns a stable service URL after deployment.


Public Webhook Endpoints

A webhook listener is one example where public reachability may be required.

The endpoint might look like:

Provider
   ↓
HTTPS
   ↓
Cloud Run Express Service
   ↓
Signature Verification
   ↓
Queue / Database

Public reachability does not mean the payload should be trusted.

The webhook still needs:

signature verification
payload validation
idempotency
request-size limits
fast acknowledgement

For the application layer, see:

Setting Up Webhook Listeners in Express.js: Handling Real-Time Payloads Securely


Part 6: Configuration vs. Secrets

Normal Configuration Can Use Environment Variables

Examples include:

LOG_LEVEL
APP_ENV
FEATURE_FLAG
API_BASE_URL
DATASET_NAME

Set them during deployment:

gcloud run deploy python-api \
  --image="$REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/python-api:v1" \
  --region="$REGION" \
  --set-env-vars="APP_ENV=production,LOG_LEVEL=INFO"

In Python:

import os

app_env = os.environ.get(
    "APP_ENV",
    "development"
)

In Node.js:

const appEnv =
  process.env.APP_ENV || "development";

Do Not Put Secrets in Ordinary Environment Configuration

Avoid:

--set-env-vars="DB_PASSWORD=my-password"

for sensitive production data.

Use Secret Manager.


Create a Secret

Example:

printf '%s' 'super-secret-value' |
gcloud secrets create api-secret \
  --data-file=-

If the secret already exists, add another version instead:

printf '%s' 'new-secret-value' |
gcloud secrets versions add api-secret \
  --data-file=-

Grant the Runtime Service Account Access

For the Python service:

gcloud secrets add-iam-policy-binding api-secret \
  --member="serviceAccount:python-api-runner@$PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"

Now Cloud Run can access that secret through its runtime identity.


Expose the Secret to the Container

For example:

gcloud run services update python-api \
  --region="$REGION" \
  --update-secrets="API_SECRET=api-secret:1"

Inside Python:

import os

api_secret = os.environ["API_SECRET"]

Google recommends pinning an environment-variable secret to a specific version because environment-variable secrets are resolved when the instance starts.

For secrets that need smoother rotation, Secret Manager volumes are another option.


Why This Is Better Than Putting .env in Docker

This:

Docker Image
   ↓
Contains Secret

means anyone with sufficient access to the image may potentially retrieve the embedded credential.

Instead:

Docker Image
      +
Runtime IAM
      +
Secret Manager

keeps code and credentials separate.


Part 7: Health Checks

Why Add a Health Endpoint?

An HTTP process can technically be running while the application itself is unusable.

A health endpoint gives infrastructure a simple way to ask:

Are you ready to serve traffic?

Our examples include:

/healthz

For Flask:

@app.get("/healthz")
def health():
    return {"status": "ok"}, 200

For Express:

app.get("/healthz", (req, res) => {
  res.status(200).json({
    status: "ok"
  });
});

Startup, Liveness and Readiness

Cloud Run supports several health-check concepts.

Startup Probe

Answers:

Has the application successfully started?

This is useful for services that need time to initialize.

Liveness Probe

Answers:

Is this instance still healthy enough to continue running?

A repeatedly failing liveness probe can cause the container instance to be restarted.

Readiness Probe

Answers:

Should this instance currently receive new traffic?

Readiness behavior should be designed carefully because a dependency outage should not necessarily make every application instance unavailable.


Keep Health Checks Lightweight

Avoid writing:

/healthz
   ↓
Query 10 databases
   ↓
Call external API
   ↓
Read entire storage bucket

on every health check.

That can turn the health endpoint itself into a source of instability.

A health check should normally validate the minimum state required to safely serve traffic.


Part 8: Scaling

Scale-to-Zero

Cloud Run can reduce a service to zero instances when there is no traffic if minimum instances are not configured.

This is useful for:

low-traffic APIs
development environments
internal tools
occasional webhooks

because idle infrastructure can be reduced substantially.

The trade-off is that a request may occasionally have to wait for a new instance to start.

That is commonly called a:

cold start

Minimum Instances

If latency is important, you can keep instances warm.

For example:

gcloud run services update python-api \
  --region="$REGION" \
  --min=1

Now Cloud Run keeps at least one instance ready.

This may improve cold-start behavior, but it also changes the cost profile.

Do not set:

min instances = 10

simply because “more must be faster.”

Measure first.


Maximum Instances

A maximum-instance limit can protect downstream systems.

Imagine:

Cloud Run
   ↓
Scales to hundreds of instances
   ↓
Every instance opens database connections

The database may fail before Cloud Run does.

For example:

gcloud run services update python-api \
  --region="$REGION" \
  --max=20

Scaling limits are part of application architecture, not just Cloud Run configuration.


Part 9: Concurrency

One Cloud Run instance can serve multiple requests concurrently.

That can be efficient.

It can also expose problems in applications that were written as though only one request could execute at a time.

Consider:

Request A
Request B
Request C

all reaching the same application instance.

Shared global state can now become unsafe.


Python Concurrency

Our Gunicorn example uses:

1 worker
8 threads

That is only a starting point.

The correct setup depends on whether the application is:

CPU-heavy
I/O-heavy
memory-heavy
thread-safe
database-bound

Benchmark the actual application.

Do not copy worker formulas from unrelated VM deployments without considering Cloud Run’s CPU and concurrency model.


Express Concurrency

Node.js naturally handles many I/O-bound operations concurrently through its event loop.

That does not mean every Express application can handle unlimited concurrency.

Examples of bottlenecks include:

database pool size
CPU-heavy JSON transformations
image processing
synchronous code
external API limits
shared memory

Cloud Run concurrency should never be tuned in isolation from the application itself.


Lower Concurrency for Heavy Requests

A CPU-intensive inference endpoint may perform better with:

concurrency = 1

while a lightweight REST API may efficiently handle considerably more simultaneous requests.

A higher concurrency can reduce the number of required instances.

A lower concurrency can provide stronger workload isolation.

The correct value is measurable, not ideological.


Part 10: Request Timeouts

Cloud Run services have a configurable request timeout.

The current default is five minutes, and it can be extended up to 60 minutes.

That does not mean every long-running workload belongs inside an HTTP request.

Suppose an endpoint performs:

45-minute batch transformation

Keeping the HTTP request open may create unnecessary reliability problems.

A better architecture may be:

HTTP Request
    ↓
Create Job
    ↓
Return 202
    ↓
Queue / Cloud Run Job
    ↓
Long Processing

For asynchronous workflows, separate:

request acceptance

from:

work completion

whenever practical.


Part 11: Logging

Cloud Run automatically integrates container logs with Cloud Logging when the application writes to:

stdout

or:

stderr

That means this Python code:

import logging

logging.info("Order processed")

and this Node.js code:

console.log("Order processed");

can be collected without writing logs into local application files.


Prefer Structured Logs

Instead of:

Something failed

produce context.

For example:

{
  "severity": "ERROR",
  "message": "Webhook processing failed",
  "eventId": "evt_123",
  "eventType": "order.completed"
}

Structured logs become much easier to:

filter
aggregate
alert on
investigate

Don’t Log Secrets

Never casually log:

Authorization headers
passwords
access tokens
webhook secrets
database credentials
full payment payloads

Observability and data leakage are not the same thing.


Part 12: The Local Filesystem Is Not Durable Application Storage

Containers have a writable filesystem while they are running.

That does not make it a reliable persistent database.

Do not design the application like this:

User uploads file
      ↓
Save permanently to /app/uploads
      ↓
Assume it will exist forever

Cloud Run instances can be created and destroyed.

A request may later reach another instance.

Use external durable storage for persistent data.

Examples:

Cloud Storage
Cloud SQL
Firestore
BigQuery
AlloyDB
External managed database

Temporary files can still be useful during request processing.

Just do not confuse instance-local files with durable shared storage.


Part 13: Revisions

One of Cloud Run’s most useful production concepts is the revision.

Every configuration change creates a new revision.

For example:

python-api-00001
python-api-00002
python-api-00003

A revision represents an immutable deployment configuration.

That includes things such as:

container image
environment variables
service account
CPU
memory
concurrency
secrets

This is much safer than modifying a running server manually.


Deployment History

Suppose:

Revision 12
↓
Works correctly

Revision 13
↓
Introduces bug

Cloud Run can route traffic back to revision 12.

You do not have to rebuild the previous container just to recover.


Part 14: Traffic Splitting and Gradual Rollouts

Cloud Run can distribute traffic across revisions.

For example:

Revision A
90%

Revision B
10%

This lets you observe a new version before sending all users to it.

Once you are confident:

Revision B
100%

This approach is useful for:

canary deployment
gradual rollout
risk reduction
production validation

If the new revision behaves badly, traffic can be directed back to the previous revision.


Deployment Health Checks

Cloud Run performs a deployment health check by starting an instance and waiting for its startup condition to succeed before routing traffic.

If the new revision cannot start correctly, Cloud Run can mark it unhealthy instead of immediately routing production traffic to it.

That is one reason startup behavior matters.

Do not make application initialization depend unnecessarily on fragile external operations.


Part 15: Public vs. Private Services

This choice deserves deliberate attention.

Public

Use public access for services genuinely intended to be called from the internet without Google IAM authentication.

Examples:

public website
public API
third-party webhook endpoint

The application may still implement its own authentication.


Private

Use IAM-protected Cloud Run services for:

internal APIs
admin tools
backend microservices
Cloud Scheduler targets
service-to-service traffic

Private is the safer default.


Service-to-Service Authentication

Imagine:

frontend-service
      ↓
backend-service

where both run on Cloud Run.

The backend can remain private.

The frontend’s runtime service account receives permission to invoke the backend.

The frontend then sends an identity token when calling it.

This is much better than inventing a shared static password between every internal service.


Part 16: Cloud Scheduler + Cloud Run

One common pattern is:

Cloud Scheduler
      ↓
Authenticated HTTP
      ↓
Private Cloud Run Service

Cloud Scheduler can use an OIDC identity token to call the service securely.

For example:

06:00 every day
      ↓
Cloud Scheduler
      ↓
POST /daily-report
      ↓
Cloud Run

You do not have to make the scheduled endpoint publicly accessible just so the scheduler can reach it.

For the full scheduling comparison, see:

CRON vs GitHub Actions vs Google Cloud Scheduler


Part 17: Containerized Data-Processing APIs

Cloud Run also works well when an HTTP request launches a relatively short data-processing operation.

For example:

Upload notification
      ↓
Cloud Run
      ↓
Parse JSON
      ↓
Validate
      ↓
Transform CSV
      ↓
Cloud Storage / BigQuery

The code performing the transformation does not need to know whether it is running on a VM or Cloud Run.

The main design consideration is ensuring that persistent state lives outside the ephemeral container.

For Python file-processing patterns, see:

Automating File Processing with Python: Parsing Complex JSON and Nested CSVs at Scale


Part 18: Cloud Run for Browser Automation

Technically, containerized browser automation can also run in serverless infrastructure.

A service might execute:

Playwright
Chromium
Validation script

inside a container.

However, browser workloads tend to consume more:

memory
CPU
startup time

than lightweight REST APIs.

Concurrency therefore needs to be tuned carefully.

For browser automation architecture, see:

Playwright vs Selenium for Headless Browser Automation


Part 19: Handling Downstream API Failures

Cloud Run autoscaling can unintentionally increase pressure on an external API.

Imagine:

Traffic spike
      ↓
Cloud Run scales out
      ↓
20 instances
      ↓
Each calls external API
      ↓
429 Too Many Requests

The application still needs:

rate limiting
timeouts
retry limits
exponential backoff
jitter

Cloud Run scaling does not replace application-level resiliency.

For a deeper walkthrough, see:

REST API Rate Limiting and Exponential Backoff in Python


Part 20: Production Deployment Example

A more realistic deployment command might look like:

gcloud run deploy python-api \
  --image="$REGION-docker.pkg.dev/$PROJECT_ID/$REPOSITORY/python-api:v2" \
  --region="$REGION" \
  --service-account="python-api-runner@$PROJECT_ID.iam.gserviceaccount.com" \
  --set-env-vars="APP_ENV=production,LOG_LEVEL=INFO" \
  --update-secrets="API_SECRET=api-secret:1" \
  --min=1 \
  --max=20 \
  --concurrency=20 \
  --memory=1Gi \
  --cpu=1

Do not copy those scaling values blindly.

They are examples of configuration, not universally correct defaults.

The correct values depend on measurements from your own service.


Part 21: Build Once, Deploy the Same Image

A useful production principle is:

Build once.
Promote the same image.

For example:

Image:
python-api:git-a91c42f

could move through:

Development
     ↓
Staging
     ↓
Production

without being rebuilt between environments.

Configuration changes by environment.

The image does not.

This improves reproducibility.


Avoid Environment-Specific Code Inside the Image

Bad:

if production:
    database = "hardcoded-prod-db"

if staging:
    database = "hardcoded-stage-db"

Better:

database = os.environ["DATABASE_NAME"]

Then deploy:

Same image
+
different configuration

Part 22: CI/CD Deployment

Once manual deployment works, move the process into CI/CD.

The flow becomes:

git push
   ↓
CI
   ↓
Test
   ↓
Build Image
   ↓
Artifact Registry
   ↓
Deploy Cloud Run Revision
   ↓
Health Check
   ↓
Traffic

GitHub Actions can perform this workflow, or you can use Google Cloud Build triggers.

Do not automate deployment before you understand the manual process.

CI/CD should automate a known-good deployment path, not hide an unclear one.


Part 23: Common Cloud Run Deployment Failures

Container Failed to Start

A frequent error is:

Container failed to start and listen on the port
defined by the PORT environment variable

Common causes include:

Hardcoded wrong port
Listening on 127.0.0.1
Application crashed during startup
Missing dependency
Incorrect Docker CMD
Invalid environment configuration

Check Cloud Run logs first.


Works Locally but Fails on Cloud Run

Ask:

Does it listen on $PORT?
Does it bind to 0.0.0.0?
Does the image support Linux amd64?
Are required environment variables present?
Does the runtime service account have IAM access?
Does the container expect local credentials?

Containerizing an application does not automatically make its surrounding assumptions portable.


Permission Denied Calling Google APIs

If code can run but receives:

403 Permission Denied

the problem is often IAM.

Check the runtime service account.

The question is not:

What permissions does my personal Google account have?

It is:

What permissions does the Cloud Run service identity have?

Secret Is Missing

Check:

Secret exists
Secret version exists
Runtime service account has Secret Accessor
Cloud Run revision references correct secret

Remember that changing Cloud Run secret configuration creates a new revision.


Database Connections Fail Under Load

This may not be a network problem.

Cloud Run may simply have scaled beyond the database’s available connection capacity.

Use:

connection pooling
maximum instance limits
sensible concurrency
database connection limits

together.


Part 24: Production Checklist

Before calling a Cloud Run deployment production-ready, I would review at least these areas:

AreaCheck
ContainerListens on 0.0.0.0:$PORT
ImageBuilt reproducibly
DependenciesLocked/reviewed
UserNon-root where practical
SecretsStored outside the image
Runtime identityDedicated service account
IAMLeast privilege
AuthenticationPublic only when intentionally required
LogsWritten to stdout/stderr
HealthLightweight endpoint/probes
ScalingMin/max considered
ConcurrencyTested under real workload
DatabasePool size matches scaling behavior
TimeoutAppropriate for workload
PersistenceNo reliance on local disk for durable data
RevisionsRollback path understood
TrafficGradual rollout available
MonitoringErrors and latency observable
CI/CDDeployments reproducible
Container tagsTraceable to release/commit

Cloud Run vs. a Traditional VM

Cloud Run removes a lot of infrastructure management.

But that does not make it the correct platform for every workload.

RequirementCloud RunVM
Server managementManagedYou manage
Automatic scalingBuilt inMust configure
Scale to zeroYesUsually no
Persistent local diskNot for normal service stateAvailable
Long-lived OS customizationLimitedFull control
Privileged container operationsNoPossible
HTTP APIsExcellent fitSupported
Background daemonUsually not ideal as a serviceGood fit
Infrastructure controlLowerHigher

Cloud Run is especially attractive when the application is:

stateless
HTTP-driven
containerized
horizontally scalable

If the application depends heavily on one permanent machine, privileged host access, or persistent local state, another platform may be more appropriate.


A Practical Production Architecture

For a typical production API, the final architecture may look like:

                  ┌─────────────────┐
                  │ Secret Manager  │
                  └────────┬────────┘
                           │
                           ▼
Client ──HTTPS──► Cloud Run Service
                           │
                  Runtime Service Account
                           │
               ┌───────────┼───────────┐
               ▼           ▼           ▼
           Cloud SQL     Storage     BigQuery
               │
               ▼
          Persistent Data

Deployment happens separately:

Git
 ↓
CI / Cloud Build
 ↓
Container Image
 ↓
Artifact Registry
 ↓
Cloud Run Revision
 ↓
Traffic

Those two flows should remain conceptually separate:

deployment identity

and:

runtime identity

are not the same responsibility.


Final Thoughts

Cloud Run makes deploying containers easy.

Production architecture is still your responsibility.

The most important lessons are not the gcloud commands.

They are the boundaries.

The container owns:

application code
dependencies
runtime

Cloud Run owns:

instance lifecycle
HTTPS endpoint
request routing
autoscaling
revisions

IAM owns:

who can call the service
what the service can access

Secret Manager owns:

sensitive configuration

Artifact Registry owns:

container images

Cloud Logging owns:

centralized runtime logs

When those responsibilities are kept separate, Cloud Run becomes a very clean deployment platform.

For Python and Express applications in particular, the deployment pattern is straightforward:

Build container
      ↓
Push image
      ↓
Assign runtime identity
      ↓
Deploy privately by default
      ↓
Attach configuration and secrets
      ↓
Test
      ↓
Observe
      ↓
Scale
      ↓
Roll forward or roll back

That is the difference between simply getting a container online and having a deployment you can confidently operate in production.


Official Documentation and Technical References

The Cloud Run behavior and deployment practices described in this article were checked against current Google Cloud documentation.

Google Cloud Run

Cloud Run Container Runtime Contract

Deploy Container Images to Cloud Run

Deploy Cloud Run Services from Source

Cloud Run Python Quickstart

Cloud Run Node.js Quickstart

Cloud Run Service Identity

Configure Cloud Run Service Accounts

Cloud Run Authentication Overview

Allow Public Access to Cloud Run

Configure Secrets for Cloud Run

Configure Cloud Run Health Checks

Cloud Run Concurrency

Cloud Run Request Timeouts

Cloud Run Minimum Instances

Cloud Run Logging

Cloud Run Rollouts and Rollbacks

Artifact Registry

Artifact Registry Docker Images

Create Artifact Registry Repositories

Scroll to Top