DEV Community

Cover image for Build Once, Deploy Many — A Staging-to-Production Pipeline with GCP Cloud Deploy
Ryo Tsugawa
Ryo Tsugawa

Posted on • Originally published at lasimban.team

Build Once, Deploy Many — A Staging-to-Production Pipeline with GCP Cloud Deploy

Introduction — "Build Once, Deploy Everywhere"

When building CI/CD pipelines, do you find yourself with a setup like this?

Build for staging    → Deploy to staging
Build for production → Deploy to production
Enter fullscreen mode Exit fullscreen mode

Rebuilding for each environment comes with several problems:

  • No reproducibility — The binary running in staging isn't guaranteed to be identical to the one in production
  • Doubled build time — Building the same source code twice is wasteful
  • Harder incident debugging — When something breaks in production, it's difficult to determine whether it's a code issue or a build discrepancy

Build Once, Deploy Many is a simple answer to this problem. You build a single immutable container image and deploy that exact same image to both staging and production.

In this article, we'll walk through a real-world implementation using the CI/CD pipeline of Lasimban (羅針盤), a Scrum task management SaaS, using GCP's Cloud Build + Cloud Deploy + Kustomize.

Architecture Overview — The Pipeline at a Glance

Let's start with the high-level flow:

GitHub Push
  ↓
Cloud Build (Build & Push Image)
  ↓
Artifact Registry (Store Immutable Images)
  ↓
Cloud Deploy (Create Release)
  ↓
Staging (Auto-deploy)
  ↓ Auto-promotion + Approval Gate
Production (Deploy after Approval)
  ↓
Cloud Run (Service Running)
Enter fullscreen mode Exit fullscreen mode

Multi-Project Structure

We separate GCP projects into three:

Project Role Contents
common Shared resources Artifact Registry, Cloud Build, Cloud Deploy pipelines
stg Staging environment Cloud Run services, Cloud SQL, Secret Manager
prod Production environment Cloud Run services, Cloud SQL, Secret Manager

This separation enables fine-grained IAM control per environment and eliminates the risk of staging operations affecting production.

Multi-Stage Dockerfile — Multiple Targets in One File

The core of Build Once is Docker's multi-stage builds. We define multiple build targets in a single Dockerfile and use them for different purposes.

Go API

The Go API Dockerfile consists of three stages:

# ============================
# Stage 1: Builder (Shared build environment)
# ============================
FROM golang:1.25.5-alpine3.21 AS builder

RUN apk add --no-cache git ca-certificates curl

WORKDIR /build

# Copy dependencies first (cache optimization)
COPY go.mod go.sum ./
RUN go mod download && go mod verify

COPY . .

# Static linking build
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -tags timetzdata \
    -ldflags="-w -s -X ...app.commit=${COMMIT} -X ...app.buildTime=${BUILD_TIME}" \
    -trimpath \
    -o myapp \
    ./cmd/myapp

# Download the migrate tool
ARG MIGRATE_VERSION=v4.17.1
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/${MIGRATE_VERSION}/migrate.linux-amd64.tar.gz | tar xvz && \
    mv migrate /usr/local/bin/migrate && \
    chmod +x /usr/local/bin/migrate
Enter fullscreen mode Exit fullscreen mode
# ============================
# Stage 2: Target 'app' (Production app — minimal)
# ============================
FROM gcr.io/distroless/static-debian12:nonroot AS app

WORKDIR /app
COPY --from=builder /build/myapp /app/myapp
COPY --from=builder /build/VERSION /app/VERSION

EXPOSE 8080
ENTRYPOINT ["/app/myapp"]
Enter fullscreen mode Exit fullscreen mode
# ============================
# Stage 3: Target 'migration' (Migration runner)
# ============================
FROM alpine:3.21 AS migration

WORKDIR /migrations
COPY --from=builder /usr/local/bin/migrate /usr/local/bin/migrate
COPY --from=builder /build/migrations/sql ./sql

# Generate the migration execution script
RUN echo '#!/bin/sh' > /migrations/run-migration.sh && \
    echo 'set -e' >> /migrations/run-migration.sh && \
    echo 'DSN="mysql://${DB_USER}:${DB_PASSWORD}@unix(/cloudsql/${CLOUD_SQL_CONNECTION_NAME})/${DB_NAME}"' >> /migrations/run-migration.sh && \
    echo '/usr/local/bin/migrate -path /migrations/sql -database "$DSN" up' >> /migrations/run-migration.sh && \
    chmod +x /migrations/run-migration.sh

CMD ["/migrations/run-migration.sh"]
Enter fullscreen mode Exit fullscreen mode

The key point is that the builder stage is shared across two targets: app and migration. By specifying --target app and --target migration in Cloud Build, we can efficiently build both images in parallel.

Next.js Web

The frontend follows the same multi-stage build pattern:

# Stage 1: Builder
FROM node:25.5-alpine3.22 AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

# ★ Do NOT bake NEXT_PUBLIC_* at build time
RUN npm run build || (echo "Build failed." && exit 1)

# Stage 2: App (Distroless Node.js)
FROM gcr.io/distroless/nodejs24-debian12:nonroot
WORKDIR /app
COPY --from=builder /build/.next/standalone ./
COPY --from=builder /build/.next/static ./.next/static
COPY --from=builder /build/public ./public

EXPOSE 8080
CMD ["server.js"]
Enter fullscreen mode Exit fullscreen mode

Important: Do NOT bake NEXT_PUBLIC_* at build time

Next.js NEXT_PUBLIC_* environment variables are normally embedded statically at build time. However, that would require rebuilding for each environment. To avoid this, we inject NEXT_PUBLIC_* at runtime instead (details below).

Why Distroless Images?

Use Case Base Image Reason
Go API gcr.io/distroless/static-debian12:nonroot Statically linked binary needs no shell; minimal attack surface
Next.js Web gcr.io/distroless/nodejs24-debian12:nonroot Minimal image containing only the Node.js runtime
Migration alpine:3.21 The migrate tool requires a shell to run

Distroless images contain no shell and no package manager. Even if an attacker gains access to the container, there's very little they can do. This is a significant security advantage and dramatically reduces image size.

Environment Separation with Kustomize — The base + overlays Pattern

To deploy the same image while applying different configurations per environment, we use Kustomize.

Directory Structure

cloud-run/
├── base/
│   ├── kustomization.yaml
│   └── service.yaml        # Shared Cloud Run service definition
└── overlays/
    ├── staging/
    │   └── kustomization.yaml  # Staging-specific patches
    └── production/
        └── kustomization.yaml  # Production-specific patches
Enter fullscreen mode Exit fullscreen mode

base/service.yaml — Shared Definition

The base contains the Cloud Run service definition shared across all environments:

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: ${serviceName}
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/minScale: "0"
        autoscaling.knative.dev/maxScale: "10"
        run.googleapis.com/cpu-throttling: "true"
        run.googleapis.com/startup-cpu-boost: "true"
    spec:
      serviceAccountName: app-runner@${projectId}.iam.gserviceaccount.com
      containers:
        - image: myapp-api
          ports:
            - name: http1
              containerPort: 8080
          env:
            - name: APP_ENV
              value: ${targetId}
            - name: GOOGLE_CLOUD_PROJECT
              value: ${projectId}
            # Environment variables from Secret Manager
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: DB_USER
                  key: latest
            # ... other environment variables
Enter fullscreen mode Exit fullscreen mode

${serviceName} and ${projectId} are replaced per environment via Cloud Deploy's Deploy Parameters. Secret Manager references are defined in the base, while the actual secret values are managed in each GCP project's Secret Manager.

overlays/production/kustomization.yaml — Production-Specific Patches

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base

patches:
  - target:
      kind: Service
      name: ".*"
    patch: |-
      - op: replace
        path: /metadata/name
        value: myapp-api-production
      - op: replace
        path: /spec/template/spec/containers/0/env/0/value
        value: production
      - op: replace
        path: /spec/template/spec/containers/0/env/1/value
        value: myapp-prod-xxxxxx
      # Always keep at least 1 instance running (avoid cold starts)
      - op: replace
        path: /spec/template/metadata/annotations/autoscaling.knative.dev~1minScale
        value: "1"
Enter fullscreen mode Exit fullscreen mode

In production, we set minScale: "1" to avoid cold starts. In staging, it stays at "0" to optimize costs.

Runtime Injection of NEXT_PUBLIC_* — We Didn't Want SSR, But...

As mentioned earlier, Next.js NEXT_PUBLIC_* variables are normally embedded at build time. To follow the Build Once principle, we adopted a runtime injection approach.

However, this wasn't an easy decision.

We originally didn't want to use SSR (Server-Side Rendering). Lasimban is a SaaS that users access after logging in — there's virtually no SEO benefit. Client-side rendering (CSR) would have been sufficient, and we wanted to avoid the overhead that SSR introduces: server-side rendering costs, response time impacts, and infrastructure complexity.

But injecting NEXT_PUBLIC_* at runtime requires the server to embed environment variables into the HTML on the initial request. In other words, as long as you commit to Build Once, you can't completely eliminate server-side processing.

This is a trade-off between Build Once, Deploy Many and "a simple architecture without SSR." If you rebuild per environment, NEXT_PUBLIC_* can be statically embedded at build time, eliminating the need for server-side processing. But that means abandoning the Build Once principle.

In the end, we chose Build Once. When we weighed reproducibility and safety against SSR overhead, the latter was an acceptable cost.

We inject NEXT_PUBLIC_* as Cloud Run environment variables at runtime and expose them to the client as window.__RUNTIME_ENV__ via server-rendered HTML:

# cloud-run/base/service.yaml (Web)
env:
  # NEXT_PUBLIC_* environment variables injected at runtime
  - name: NEXT_PUBLIC_API_DOMAIN
    valueFrom:
      secretKeyRef:
        name: NEXT_PUBLIC_API_DOMAIN
        key: latest
  - name: NEXT_PUBLIC_FIREBASE_API_KEY
    valueFrom:
      secretKeyRef:
        name: NEXT_PUBLIC_FIREBASE_API_KEY
        key: latest
  # ...
Enter fullscreen mode Exit fullscreen mode

Why store NEXT_PUBLIC_* in Secret Manager?

NEXT_PUBLIC_* values are, as the name implies, public — there's no need to keep them secret. They're visible to anyone once delivered to the browser. The reason we store them in Secret Manager is to align with Cloud Run's environment variable injection mechanism. By managing them in the same secretKeyRef format as DB passwords and API keys, we unify how all environment variables are injected, keeping the Cloud Build / Cloud Deploy pipeline configuration simple. This is a decision for operational consistency, not security.

With this approach, the build happens only once. Different API endpoints and Firebase projects for staging and production can be swapped using the same image.

Cloud Deploy + Skaffold — Automating Deployments

Now we get to the "Deploy Many" part of Build Once, Deploy Many. We use Cloud Deploy to automate deployments to both staging and production.

Cloud Build — From Build to Release Creation

Let's look at the key steps in cloudbuild.yaml:

steps:
  # 1. Build App Image and Migration Image in parallel
  - name: "gcr.io/cloud-builders/docker"
    id: "build-app"
    args:
      - "-c"
      - |
        docker build \
          --target app \
          --cache-from ...myapp-api:latest \
          --build-arg COMMIT=$COMMIT_SHA \
          -t ...myapp-api:$COMMIT_SHA \
          -t ...myapp-api:$_IMAGE_TAG \
          .

  - name: "gcr.io/cloud-builders/docker"
    id: "build-migration"
    waitFor: ["init-submodules"]  # Runs in parallel with app build
    args:
      - "-c"
      - |
        docker build \
          --target migration \
          -t ...myapp-migration:$COMMIT_SHA \
          .

  # 2. Push images to Artifact Registry
  # 3. Apply Cloud Deploy pipeline
  - name: "gcr.io/google.com/cloudsdktool/cloud-sdk:alpine"
    id: "apply-pipeline"
    entrypoint: "gcloud"
    args: ["deploy", "apply", "--file=clouddeploy.yaml", "--region=asia-northeast1"]

  # 4. Create Cloud Deploy Release
  - name: "gcr.io/google.com/cloudsdktool/cloud-sdk:alpine"
    id: "create-release"
    args:
      - "-c"
      - |
        VER=$(cat VERSION | tr -d ' \n')
        gcloud deploy releases create "rel-v${VER//\./-}-${SHORT_SHA}-$(date +%s)" \
          --delivery-pipeline=myapp-pipeline-api \
          --images=myapp-api=...myapp-api:$COMMIT_SHA,myapp-migration=...myapp-migration:$COMMIT_SHA \
          --skaffold-file=skaffold.yaml \
          --annotations="git_tag=v${VER},commit_sha=${COMMIT_SHA}"
Enter fullscreen mode Exit fullscreen mode

Key points:

  • --target app and --target migration build two images from the same Dockerfile in parallel
  • --cache-from reuses the previous build's image as a layer cache
  • Image tags use $COMMIT_SHA for immutability
  • --annotations attaches the Git tag and commit SHA to the release

clouddeploy.yaml — Defining the Delivery Pipeline

apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
  name: myapp-pipeline-api
serialPipeline:
  stages:
    # Stage 1: Staging (Auto-deploy)
    - targetId: staging
      profiles: [staging]
      strategy:
        standard:
          predeploy:
            actions: ["trigger-migration-job"]
          postdeploy:
            actions: ["update-setup-company-job"]

    # Stage 2: Production (Approval required)
    - targetId: production
      profiles: [production]
      strategy:
        standard:
          predeploy:
            actions: ["trigger-migration-job"]
          postdeploy:
            actions:
              - "tag-docker-images"
              - "create-github-release"
Enter fullscreen mode Exit fullscreen mode

Each stage can have predeploy and postdeploy custom actions.

Auto-Promotion — From Staging to Production

Using Cloud Deploy's Automation resource, we automatically trigger promotion to production when staging deployment succeeds:

apiVersion: deploy.cloud.google.com/v1
kind: Automation
metadata:
  name: myapp-pipeline-api/promote-to-production-automation
selector:
  - target:
      id: staging
rules:
  - promoteReleaseRule:
      id: "promote-release-rule"
      destinationTargetId: "@next"
Enter fullscreen mode Exit fullscreen mode

When destinationTargetId is set to "@next", the release is automatically promoted to the next stage in the pipeline — in our case, Production.

Approval Gates for Safe Releases

However, we have an approval gate for the production environment:

apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: production
requireApproval: true  # ← This is the key
run:
  location: projects/myapp-prod-xxxxxx/locations/asia-northeast1
Enter fullscreen mode Exit fullscreen mode

With requireApproval: true, even though the release is auto-promoted after staging verification, human approval is required before the production deployment proceeds.

The resulting flow looks like this:

GitHub Push
  → Cloud Build (Build)
    → Staging (Auto-deploy)
      → Auto-promotion
        → Approval Gate (Human approves)
          → Production (Deploy)
Enter fullscreen mode Exit fullscreen mode

Custom Actions — Automated Migration Execution

Using Skaffold's customActions, we run custom processes before and after deployment. Here's how we define automated database migration:

# skaffold.yaml
customActions:
  - name: trigger-migration-job
    containers:
      - name: job-runner
        image: gcr.io/google.com/cloudsdktool/cloud-sdk:alpine
        command: ["/bin/sh"]
        args:
          - "-c"
          - |
            set -e
            JOB_NAME="myapp-migration-$(date +%s)"

            # Create a temporary Cloud Run Job
            gcloud run jobs create ${JOB_NAME} \
              --image=${MIGRATION_IMAGE} \
              --set-cloudsql-instances=... \
              --set-secrets=DB_USER=...,DB_PASSWORD=...,DB_NAME=... \
              --max-retries=0 \
              --task-timeout=600s

            # Execute and wait for completion
            gcloud run jobs execute ${JOB_NAME} --wait

            # Delete the job on success (keep on failure for log inspection)
            gcloud run jobs delete ${JOB_NAME} --quiet
Enter fullscreen mode Exit fullscreen mode

Migration Job Design Points

  • Created as a temporary Cloud Run Job, deleted after execution
  • set -e causes immediate failure on migration error, which also halts the deployment
  • On failure, the job is preserved so you can inspect logs in the Cloud Console
  • Environment variables are injected per environment via Skaffold's patches

Custom Actions — Automated Git Tags & GitHub Releases

After a successful production deployment, the postdeploy action automatically:

  1. Tags Docker images with version numbers — Adds semantic version tags like v1.2.3 to images tagged by commit SHA
  2. Creates and pushes Git tags — Retrieves GitHub App credentials from Secret Manager and pushes release tags
  3. Generates GitHub Releases — Uses generate_release_notes: true to auto-generate PR-based release notes
# skaffold.yaml (excerpt)
- name: create-github-release
  containers:
    - name: github-releaser
      image: "gcr.io/google.com/cloudsdktool/cloud-sdk:alpine"
      env:
        - name: REPO_FULL_NAME
          value: "your-org/your-api"
      command: ["/bin/bash"]
      args:
        - "-c"
        - |
          # Retrieve git_tag and commit_sha from Cloud Deploy Release info
          RELEASE_JSON=$(gcloud deploy releases describe "${CLOUD_DEPLOY_RELEASE}" ...)
          TARGET_TAG=$(echo $RELEASE_JSON | jq -r '.annotations.git_tag')
          TARGET_SHA=$(echo $RELEASE_JSON | jq -r '.annotations.commit_sha')

          # Tag Docker images with version
          gcloud container images add-tag \
            "${AR_ROOT}/myapp-api:${TARGET_SHA}" \
            "${AR_ROOT}/myapp-api:${TARGET_TAG}" --quiet

          # Authenticate via GitHub App → Create Git tag & Release
          # ...
          curl -X POST \
            https://api.github.com/repos/${REPO_FULL_NAME}/releases \
            -d '{
              "tag_name": "${TARGET_TAG}",
              "generate_release_notes": true
            }'
Enter fullscreen mode Exit fullscreen mode

The git_tag and commit_sha embedded via --annotations when creating the release in Cloud Build are utilized here. This design connects the entire flow — from build to deployment to release — end to end.

Skaffold Profiles for Environment Switching

Skaffold profiles switch Kustomize overlays and deployment targets per environment:

# skaffold.yaml
profiles:
  - name: staging
    manifests:
      kustomize:
        paths:
          - cloud-run/overlays/staging
    deploy:
      cloudrun:
        projectid: myapp-stg-xxxxxx
        region: asia-northeast1
    # Inject staging-specific environment variables for custom actions
    patches:
      - op: add
        path: /customActions/0/containers/0/env
        value:
          - name: PROJECT_ID
            value: "myapp-stg-xxxxxx"
          - name: MIGRATION_IMAGE
            value: "...myapp-migration:latest"

  - name: production
    manifests:
      kustomize:
        paths:
          - cloud-run/overlays/production
    deploy:
      cloudrun:
        projectid: myapp-prod-xxxxxx
        region: asia-northeast1
Enter fullscreen mode Exit fullscreen mode

By simply switching between the staging and production profiles within a single Skaffold configuration, everything — manifest generation targets, custom action environment variables — switches accordingly.

Conclusion — What This Pattern Gave Us

By implementing the Build Once, Deploy Many pattern with GCP managed services, we achieved the following balance:

Reproducibility

  • The exact same container image runs in both staging and production
  • "It worked in staging but not in production" can never be caused by build discrepancies
  • Images are uniquely identified by commit SHA

Safety

  • Auto-promotion + approval gates balance speed and caution
  • Predeploy migrations run automatically; failures halt the deployment
  • Distroless images and nonroot users secure the containers
  • Multi-project structure isolates permissions between environments

Speed

  • A single build reduces total pipeline execution time
  • Parallel builds for App and Migration images further accelerate the process
  • --cache-from leverages layer caching
  • Everything from Git tag creation to GitHub Release generation after production deployment is fully automated

About the code examples in this article

The code examples in this article are based on the actual CI/CD pipeline configuration of Lasimban (羅針盤), a Scrum task management SaaS. Please adapt project-specific values (project IDs, secret names, etc.) to your own setup.

Cloud Deploy is still a relatively under-documented service, but combined with Skaffold + Kustomize, it provides a clean way to set up multi-environment deployments to Cloud Run. We hope this serves as a useful reference when building CI/CD pipelines on GCP.


The CI/CD pipeline described in this article powers Lasimban, a task management SaaS built for Scrum teams. We offer a 14-day free trial — give it a try and see how it works for your team. We'd love to hear your feedback!

Start your 14-day free trial → lasimban.team

Lasimban - Scrum-Focused Task Management Tool

Make Scrum development more intuitive and enjoyable. Lasimban is a Scrum-focused task management tool that shows your team the right direction.

favicon lasimban.team

Feel free to share your thoughts in the comments. We'd especially love to hear how you've set up similar pipelines — "here's how we do it" stories are always welcome!

Top comments (0)