Deploy a serverless container on GCP Cloud Run with Pulumi

Build, publish, and run a small HTTP container on Cloud Run services with Artifact Registry, managed secret injection, health checks, public ingress, and autoscaling.

Switch variant

Choose a different cloud.

Download blueprint

Get this GCP Cloud Run blueprint project as a zip. Switch Pulumi language here to keep the download aligned with the install commands and blueprint program on the page.

Download the TypeScript blueprint with the matching Pulumi program, dependency files, and README.

Download TypeScript blueprint

Download the Python blueprint with the matching Pulumi program, dependency files, and README.

Download Python blueprint

Download the Go blueprint with the matching Pulumi program, dependency files, and README.

Download Go blueprint

This guide deploys a small HTTP container on Cloud Run services. It builds the image from the included app/ directory, pushes it to Artifact Registry, stores one example setting in Secret Manager, and exports a public URL after the service is ready.

Architecture

The stack creates four pieces:

  1. A local container image built from app/Dockerfile.
  2. A private image repository in Artifact Registry.
  3. A managed secret that becomes the APP_MESSAGE environment variable.
  4. A public Cloud Run services service with a Cloud Run startup probe on /health and Cloud Run min/max instance settings.

The app listens on port 80, returns JSON from /, and returns ok from /health.

GCP Cloud Run is the right choice when you want a request-driven container endpoint with managed HTTPS, revision rollout, and scale-to-zero support. This variant creates the Artifact Registry repository, Secret Manager value, Cloud Run service, public invoker binding, startup probe, concurrency setting, and min/max instance configuration.

Prerequisites

Before you start, install:

  • a Pulumi account and the Pulumi CLI
  • Docker running locally so Pulumi can build and push the container image
  • Node.js 20 or newer and npm
  • a Google Cloud project where you can create Artifact Registry, Cloud Run, Secret Manager, and IAM resources

What you get in the download

The downloadable example zip includes:

  • index.ts as the Pulumi entrypoint
  • components/container-service.ts as the reusable Cloud Run services component
  • app/ with a tiny HTTP service and Dockerfile
  • package.json and tsconfig.json for the Pulumi project
  • __main__.py as the Pulumi entrypoint
  • components/container_service.py as the reusable Cloud Run services component
  • app/ with a tiny HTTP service and Dockerfile
  • requirements.txt for the Pulumi project
  • main.go as the Pulumi entrypoint
  • containerservice/service.go as the reusable Cloud Run services component
  • app/ with a tiny HTTP service and Dockerfile
  • go.mod for the Pulumi project

Quickstart

Start from the downloaded example and run these commands from the project root.

pulumi stack init dev
pulumi config set gcp:project my-project-id
pulumi config set gcp:region us-central1
pulumi config set --secret appMessage 'hello from Secret Manager'

Install language dependencies:

npm install
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
go mod tidy

Then deploy:

pulumi up

App shape

The app is small so you can focus on the infrastructure first. app/server.js exposes /health for a Cloud Run startup probe on /health and / for a JSON response that includes the secret-backed APP_MESSAGE. Replace the app/ directory with your own container when the first deployment works.

Application code

app/server.js

The container runs this tiny Node.js HTTP server. It returns JSON from /, echoes the APP_MESSAGE secret value, and answers the platform health check at /health. The same app/ directory ships in every language starter.

const http = require("http");

const port = Number(process.env.PORT || 80);
const message = process.env.APP_MESSAGE || "hello from Pulumi";

const server = http.createServer((req, res) => {
  if (req.url === "/health") {
    res.writeHead(200, { "content-type": "text/plain" });
    res.end("ok");
    return;
  }

  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ message, path: req.url }));
});

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

Deploy and iterate

Run pulumi up whenever you change the Pulumi program or the app image. The image tag is stack-scoped, so a new build updates the running instance definition and rolls the service forward through Cloud Run services.

Outputs

After deployment, the stack exports:

  • url - the managed Cloud Run HTTPS URL
  • imageName - the pushed image reference in Artifact Registry
  • secretName - the managed secret that backs APP_MESSAGE

Open url in a browser or run curl $(pulumi stack output url)/health to verify the service.

Operations and cleanup

Use gcloud run services describe, Cloud Run revision status, and Cloud Logging to inspect rollout and startup probe failures.

Cloud Run can scale to zero when minInstances is 0. Artifact Registry storage and Secret Manager versions can still create small charges.

Destroy everything with:

pulumi destroy
pulumi stack rm

Blueprint Pulumi program

The entrypoint reads stack config, creates the reusable container service component, then exports the URL and registry coordinates produced by Cloud Run services.

import * as pulumi from "@pulumi/pulumi";
import { ServerlessContainerService } from "./components/container-service";

const config = new pulumi.Config();
const service = new ServerlessContainerService("app", {
    appPath: "./app",
    appMessage: config.requireSecret("appMessage"),
    containerPort: 80,
    minInstances: config.getNumber("minInstances") ?? 1,
    maxInstances: config.getNumber("maxInstances") ?? 3,
});

export const url = service.url;
export const imageName = service.imageName;
export const secretName = service.secretName;
import pulumi
from components.container_service import ServerlessContainerService

config = pulumi.Config()
service = ServerlessContainerService(
    "app",
    app_path="./app",
    app_message=config.require_secret("appMessage"),
    container_port=80,
    min_instances=config.get_int("minInstances") or 1,
    max_instances=config.get_int("maxInstances") or 3,
)

pulumi.export("url", service.url)
pulumi.export("imageName", service.image_name)
pulumi.export("secretName", service.secret_name)
package main

import (
	"fmt"

	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"

	"serverless-containers/containerservice"
)

func Program(ctx *pulumi.Context) error {
		cfg := config.New(ctx, "")
		appMessage := cfg.RequireSecret("appMessage")
		minUnits := cfg.GetInt("minInstances")
		if minUnits == 0 {
			minUnits = 1
		}
		maxUnits := cfg.GetInt("maxInstances")
		if maxUnits == 0 {
			maxUnits = 3
		}

		service, err := containerservice.NewServerlessContainerService(ctx, "app", &containerservice.ServerlessContainerServiceArgs{
			AppPath:       "./app",
			AppMessage:    appMessage,
			ContainerPort: 80,
			MinInstances:       minUnits,
			MaxInstances:       maxUnits,
		})
		if err != nil {
			return fmt.Errorf("create service: %w", err)
		}

		ctx.Export("url", service.URL)
		ctx.Export("imageName", service.ImageName)
		ctx.Export("secretName", service.SecretName)
		return nil
}

func main() {
	pulumi.Run(Program)
}

Reusable component

The component owns the registry, image build, serverless container service, health check, secret injection, and autoscaling settings for GCP Cloud Run. The downloadable starter includes this same file.

import * as pulumi from "@pulumi/pulumi";
import * as gcp from "@pulumi/gcp";
import * as docker from "@pulumi/docker";

export interface ServerlessContainerServiceArgs { appPath: string; appMessage: pulumi.Input<string>; containerPort: number; minInstances: number; maxInstances: number; }
export class ServerlessContainerService extends pulumi.ComponentResource {
    public readonly url: pulumi.Output<string>; public readonly imageName: pulumi.Output<string>; public readonly secretName: pulumi.Output<string>;
    constructor(name: string, args: ServerlessContainerServiceArgs, opts?: pulumi.ComponentResourceOptions) {
        super("guides:serverless:GcpCloudRunService", name, {}, opts);
        const config = new pulumi.Config("gcp");
        const project = config.require("project");
        const region = config.get("region") || "us-central1";
        const repo = new gcp.artifactregistry.Repository(`${name}-repo`, { location: region, repositoryId: `${name}-repo`, format: "DOCKER" }, { parent: this });
        const client = gcp.organizations.getClientConfigOutput({});
        const imageName = pulumi.interpolate`${region}-docker.pkg.dev/${project}/${repo.repositoryId}/app:latest`;
        const image = new docker.Image(`${name}-image`, { build: { context: args.appPath, platform: "linux/amd64" }, imageName, registry: { server: pulumi.interpolate`${region}-docker.pkg.dev`, username: "oauth2accesstoken", password: client.accessToken } }, { parent: this });
        const secret = new gcp.secretmanager.Secret(`${name}-message`, { secretId: `${name}-message`, replication: { auto: {} } }, { parent: this });
        new gcp.secretmanager.SecretVersion(`${name}-message-version`, { secret: secret.id, secretData: args.appMessage }, { parent: this });
        const projectInfo = gcp.organizations.getProjectOutput({ projectId: project });
        const runtimeMember = projectInfo.number.apply(n => `serviceAccount:${n}-compute@developer.gserviceaccount.com`);
        new gcp.secretmanager.SecretIamMember(`${name}-secret-access`, { secretId: secret.secretId, role: "roles/secretmanager.secretAccessor", member: runtimeMember }, { parent: this });
        const service = new gcp.cloudrunv2.Service(`${name}-service`, { location: region, ingress: "INGRESS_TRAFFIC_ALL", template: { scaling: { minInstanceCount: args.minInstances, maxInstanceCount: args.maxInstances }, maxInstanceRequestConcurrency: 50, containers: [{ image: image.imageName, ports: { containerPort: args.containerPort }, envs: [{ name: "APP_MESSAGE", valueSource: { secretKeyRef: { secret: secret.secretId, version: "latest" } } }], startupProbe: { httpGet: { path: "/health", port: args.containerPort }, periodSeconds: 10, failureThreshold: 3 } }] }, traffics: [{ type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent: 100 }] }, { parent: this });
        new gcp.cloudrunv2.ServiceIamMember(`${name}-invoker`, { location: service.location, name: service.name, role: "roles/run.invoker", member: "allUsers" }, { parent: this });
        this.url = service.uri; this.imageName = image.imageName; this.secretName = secret.secretId;
        this.registerOutputs({ url: this.url, imageName: this.imageName, secretName: this.secretName });
    }
}
import pulumi
import pulumi_gcp as gcp
import pulumi_docker as docker

class ServerlessContainerService(pulumi.ComponentResource):
    def __init__(self, name, app_path, app_message, container_port, min_instances, max_instances, opts=None):
        super().__init__("guides:serverless:GcpCloudRunService", name, None, opts)
        child = pulumi.ResourceOptions(parent=self)
        cfg = pulumi.Config("gcp")
        project = cfg.require("project")
        region = cfg.get("region") or "us-central1"
        repo = gcp.artifactregistry.Repository(f"{name}-repo", location=region, repository_id=f"{name}-repo", format="DOCKER", opts=child)
        client = gcp.organizations.get_client_config_output()
        image_name = pulumi.Output.concat(region, "-docker.pkg.dev/", project, "/", repo.repository_id, "/app:latest")
        image = docker.Image(f"{name}-image", build=docker.DockerBuildArgs(context=app_path, platform="linux/amd64"), image_name=image_name, registry=docker.RegistryArgs(server=pulumi.Output.concat(region, "-docker.pkg.dev"), username="oauth2accesstoken", password=client.access_token), opts=child)
        secret = gcp.secretmanager.Secret(f"{name}-message", secret_id=f"{name}-message", replication=gcp.secretmanager.SecretReplicationArgs(auto=gcp.secretmanager.SecretReplicationAutoArgs()), opts=child)
        gcp.secretmanager.SecretVersion(f"{name}-message-version", secret=secret.id, secret_data=app_message, opts=child)
        project_info = gcp.organizations.get_project_output(project_id=project)
        runtime_member = project_info.number.apply(lambda n: f"serviceAccount:{n}-compute@developer.gserviceaccount.com")
        gcp.secretmanager.SecretIamMember(f"{name}-secret-access", secret_id=secret.secret_id, role="roles/secretmanager.secretAccessor", member=runtime_member, opts=child)
        service = gcp.cloudrunv2.Service(f"{name}-service", location=region, ingress="INGRESS_TRAFFIC_ALL", template=gcp.cloudrunv2.ServiceTemplateArgs(scaling=gcp.cloudrunv2.ServiceTemplateScalingArgs(min_instance_count=min_instances, max_instance_count=max_instances), max_instance_request_concurrency=50, containers=[gcp.cloudrunv2.ServiceTemplateContainerArgs(image=image.image_name, ports=gcp.cloudrunv2.ServiceTemplateContainerPortsArgs(container_port=container_port), envs=[gcp.cloudrunv2.ServiceTemplateContainerEnvArgs(name="APP_MESSAGE", value_source=gcp.cloudrunv2.ServiceTemplateContainerEnvValueSourceArgs(secret_key_ref=gcp.cloudrunv2.ServiceTemplateContainerEnvValueSourceSecretKeyRefArgs(secret=secret.secret_id, version="latest")))], startup_probe=gcp.cloudrunv2.ServiceTemplateContainerStartupProbeArgs(http_get=gcp.cloudrunv2.ServiceTemplateContainerStartupProbeHttpGetArgs(path="/health", port=container_port), period_seconds=10, failure_threshold=3))]), traffics=[gcp.cloudrunv2.ServiceTrafficArgs(type="TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST", percent=100)], opts=child)
        gcp.cloudrunv2.ServiceIamMember(f"{name}-invoker", location=service.location, name=service.name, role="roles/run.invoker", member="allUsers", opts=child)
        self.url = service.uri
        self.image_name = image.image_name
        self.secret_name = secret.secret_id
        self.register_outputs({"url": self.url, "imageName": self.image_name, "secretName": self.secret_name})
package containerservice

import (
	"fmt"

	"github.com/pulumi/pulumi-docker/sdk/v4/go/docker"
	"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/artifactregistry"
	"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/cloudrunv2"
	"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/organizations"
	"github.com/pulumi/pulumi-gcp/sdk/v9/go/gcp/secretmanager"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
)

type ServerlessContainerServiceArgs struct { AppPath string; AppMessage pulumi.StringOutput; ContainerPort int; MinInstances int; MaxInstances int }
type ServerlessContainerService struct { pulumi.ResourceState; URL pulumi.StringOutput; ImageName pulumi.StringOutput; SecretName pulumi.StringOutput }
func NewServerlessContainerService(ctx *pulumi.Context, name string, args *ServerlessContainerServiceArgs, opts ...pulumi.ResourceOption) (*ServerlessContainerService, error) {
	component := &ServerlessContainerService{}
	if err := ctx.RegisterComponentResource("guides:serverless:GcpCloudRunService", name, component, opts...); err != nil { return nil, err }
	child := pulumi.Parent(component)
	cfg := config.New(ctx, "gcp")
	project := cfg.Require("project")
	region := cfg.Get("region"); if region == "" { region = "us-central1" }
	repo, err := artifactregistry.NewRepository(ctx, name+"-repo", &artifactregistry.RepositoryArgs{Location: pulumi.String(region), RepositoryId: pulumi.String(name+"-repo"), Format: pulumi.String("DOCKER")}, child); if err != nil { return nil, err }
	client := organizations.GetClientConfigOutput(ctx)
	imageName := pulumi.Sprintf("%s-docker.pkg.dev/%s/%s/app:latest", region, project, repo.RepositoryId)
	image, err := docker.NewImage(ctx, name+"-image", &docker.ImageArgs{Build: &docker.DockerBuildArgs{Context: pulumi.String(args.AppPath), Platform: pulumi.String("linux/amd64")}, ImageName: imageName, Registry: &docker.RegistryArgs{Server: pulumi.String(fmt.Sprintf("%s-docker.pkg.dev", region)), Username: pulumi.String("oauth2accesstoken"), Password: client.AccessToken()}}, child); if err != nil { return nil, err }
	secret, err := secretmanager.NewSecret(ctx, name+"-message", &secretmanager.SecretArgs{SecretId: pulumi.String(name+"-message"), Replication: &secretmanager.SecretReplicationArgs{Auto: &secretmanager.SecretReplicationAutoArgs{}}}, child); if err != nil { return nil, err }
	_, err = secretmanager.NewSecretVersion(ctx, name+"-message-version", &secretmanager.SecretVersionArgs{Secret: secret.ID(), SecretData: args.AppMessage}, child); if err != nil { return nil, err }
	projectInfo := organizations.LookupProjectOutput(ctx, organizations.LookupProjectOutputArgs{ProjectId: pulumi.String(project)})
	_, err = secretmanager.NewSecretIamMember(ctx, name+"-secret-access", &secretmanager.SecretIamMemberArgs{SecretId: secret.SecretId, Role: pulumi.String("roles/secretmanager.secretAccessor"), Member: projectInfo.Number().ApplyT(func(n string) string { return "serviceAccount:" + n + "-compute@developer.gserviceaccount.com" }).(pulumi.StringOutput)}, child); if err != nil { return nil, err }
	service, err := cloudrunv2.NewService(ctx, name+"-service", &cloudrunv2.ServiceArgs{Location: pulumi.String(region), Ingress: pulumi.String("INGRESS_TRAFFIC_ALL"), Template: &cloudrunv2.ServiceTemplateArgs{Scaling: &cloudrunv2.ServiceTemplateScalingArgs{MinInstanceCount: pulumi.Int(args.MinInstances), MaxInstanceCount: pulumi.Int(args.MaxInstances)}, MaxInstanceRequestConcurrency: pulumi.Int(50), Containers: cloudrunv2.ServiceTemplateContainerArray{&cloudrunv2.ServiceTemplateContainerArgs{Image: image.ImageName, Ports: &cloudrunv2.ServiceTemplateContainerPortsArgs{ContainerPort: pulumi.Int(args.ContainerPort)}, Envs: cloudrunv2.ServiceTemplateContainerEnvArray{&cloudrunv2.ServiceTemplateContainerEnvArgs{Name: pulumi.String("APP_MESSAGE"), ValueSource: &cloudrunv2.ServiceTemplateContainerEnvValueSourceArgs{SecretKeyRef: &cloudrunv2.ServiceTemplateContainerEnvValueSourceSecretKeyRefArgs{Secret: secret.SecretId, Version: pulumi.String("latest")}}}}, StartupProbe: &cloudrunv2.ServiceTemplateContainerStartupProbeArgs{HttpGet: &cloudrunv2.ServiceTemplateContainerStartupProbeHttpGetArgs{Path: pulumi.String("/health"), Port: pulumi.Int(args.ContainerPort)}, PeriodSeconds: pulumi.Int(10), FailureThreshold: pulumi.Int(3)}}}}, Traffics: cloudrunv2.ServiceTrafficArray{&cloudrunv2.ServiceTrafficArgs{Type: pulumi.String("TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"), Percent: pulumi.Int(100)}}}, child); if err != nil { return nil, err }
	_, err = cloudrunv2.NewServiceIamMember(ctx, name+"-invoker", &cloudrunv2.ServiceIamMemberArgs{Location: service.Location, Name: service.Name, Role: pulumi.String("roles/run.invoker"), Member: pulumi.String("allUsers")}, child); if err != nil { return nil, err }
	component.URL = service.Uri
	component.ImageName = image.ImageName
	component.SecretName = secret.SecretId
	return component, ctx.RegisterResourceOutputs(component, pulumi.Map{"url": component.URL, "imageName": component.ImageName, "secretName": component.SecretName})
}

Frequently asked questions

What does this guide deploy?
It deploys a tiny HTTP app with / and /health, builds a container image locally, pushes it to a managed container registry, and runs it behind the cloud’s managed public ingress.
Does the starter include a real app?
Yes. The download includes a small Node.js service and Dockerfile so pulumi up can build and publish a working image without bringing your own app first.
How are secrets handled?
The stack creates one sample managed secret and injects it into the container as APP_MESSAGE. Replace the value with your own stack secret before using the starter for a real service.
How does scaling work?
Each variant exposes minimum and maximum scale settings, plus request concurrency or utilization settings where the platform supports them, then maps those values to the platform-native autoscaling resource.
What does it cost?
Expect registry storage, build/push transfer, and container runtime costs. AWS also creates an Application Load Balancer, which has a baseline hourly cost. Destroy the stack when you are done experimenting.