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
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)
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
# ============================
# 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"]
# ============================
# 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"]
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"]
Important: Do NOT bake
NEXT_PUBLIC_*at build timeNext.js
NEXT_PUBLIC_*environment variables are normally embedded statically at build time. However, that would require rebuilding for each environment. To avoid this, we injectNEXT_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
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
${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"
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
# ...
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 samesecretKeyRefformat 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}"
Key points:
-
--target appand--target migrationbuild two images from the same Dockerfile in parallel -
--cache-fromreuses the previous build's image as a layer cache - Image tags use
$COMMIT_SHAfor immutability -
--annotationsattaches 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"
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"
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
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)
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
Migration Job Design Points
- Created as a temporary Cloud Run Job, deleted after execution
set -ecauses 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:
-
Tags Docker images with version numbers — Adds semantic version tags like
v1.2.3to images tagged by commit SHA - Creates and pushes Git tags — Retrieves GitHub App credentials from Secret Manager and pushes release tags
-
Generates GitHub Releases — Uses
generate_release_notes: trueto 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
}'
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
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-fromleverages 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
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)