<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ryo Tsugawa</title>
    <description>The latest articles on DEV Community by Ryo Tsugawa (@ryotsun).</description>
    <link>https://dev.to/ryotsun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3822759%2F0cf364cd-eb3a-4421-93c0-adccd8597834.png</url>
      <title>DEV Community: Ryo Tsugawa</title>
      <link>https://dev.to/ryotsun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ryotsun"/>
    <language>en</language>
    <item>
      <title>Build Once, Deploy Many — A Staging-to-Production Pipeline with GCP Cloud Deploy</title>
      <dc:creator>Ryo Tsugawa</dc:creator>
      <pubDate>Wed, 25 Mar 2026 14:56:23 +0000</pubDate>
      <link>https://dev.to/ryotsun/build-once-deploy-many-a-staging-to-production-pipeline-with-gcp-cloud-deploy-1kh1</link>
      <guid>https://dev.to/ryotsun/build-once-deploy-many-a-staging-to-production-pipeline-with-gcp-cloud-deploy-1kh1</guid>
      <description>&lt;h2&gt;
  
  
  Introduction — "Build Once, Deploy Everywhere"
&lt;/h2&gt;

&lt;p&gt;When building CI/CD pipelines, do you find yourself with a setup like this?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build for staging    → Deploy to staging
Build for production → Deploy to production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Rebuilding for each environment comes with several problems:&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Build Once, Deploy Many&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture Overview — The Pipeline at a Glance
&lt;/h2&gt;

&lt;p&gt;Let's start with the high-level flow:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Push
  ↓
Cloud Build (Build &amp;amp; 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)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Multi-Project Structure
&lt;/h3&gt;

&lt;p&gt;We separate GCP projects into three:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Contents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;common&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared resources&lt;/td&gt;
&lt;td&gt;Artifact Registry, Cloud Build, Cloud Deploy pipelines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Staging environment&lt;/td&gt;
&lt;td&gt;Cloud Run services, Cloud SQL, Secret Manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Production environment&lt;/td&gt;
&lt;td&gt;Cloud Run services, Cloud SQL, Secret Manager&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This separation enables fine-grained IAM control per environment and eliminates the risk of staging operations affecting production.&lt;/p&gt;
&lt;h2&gt;
  
  
  Multi-Stage Dockerfile — Multiple Targets in One File
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h3&gt;
  
  
  Go API
&lt;/h3&gt;

&lt;p&gt;The Go API Dockerfile consists of three stages:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ============================&lt;/span&gt;
&lt;span class="c"&gt;# Stage 1: Builder (Shared build environment)&lt;/span&gt;
&lt;span class="c"&gt;# ============================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;golang:1.25.5-alpine3.21&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; git ca-certificates curl

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /build&lt;/span&gt;

&lt;span class="c"&gt;# Copy dependencies first (cache optimization)&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; go.mod go.sum ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;go mod download &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; go mod verify

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="c"&gt;# Static linking build&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; COMMIT=unknown&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; BUILD_TIME=unknown&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;GOOS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;linux &lt;span class="nv"&gt;GOARCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;amd64 go build &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-tags&lt;/span&gt; timetzdata &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-ldflags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-w -s -X ...app.commit=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;COMMIT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -X ...app.buildTime=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BUILD_TIME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-trimpath&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nt"&gt;-o&lt;/span&gt; myapp &lt;span class="se"&gt;\
&lt;/span&gt;    ./cmd/myapp

&lt;span class="c"&gt;# Download the migrate tool&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; MIGRATE_VERSION=v4.17.1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;curl &lt;span class="nt"&gt;-L&lt;/span&gt; https://github.com/golang-migrate/migrate/releases/download/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MIGRATE_VERSION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;/migrate.linux-amd64.tar.gz | &lt;span class="nb"&gt;tar &lt;/span&gt;xvz &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;mv &lt;/span&gt;migrate /usr/local/bin/migrate &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ============================&lt;/span&gt;
&lt;span class="c"&gt;# Stage 2: Target 'app' (Production app — minimal)&lt;/span&gt;
&lt;span class="c"&gt;# ============================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;gcr.io/distroless/static-debian12:nonroot&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /build/myapp /app/myapp&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /build/VERSION /app/VERSION&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/app/myapp"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ============================&lt;/span&gt;
&lt;span class="c"&gt;# Stage 3: Target 'migration' (Migration runner)&lt;/span&gt;
&lt;span class="c"&gt;# ============================&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;alpine:3.21&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;migration&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /migrations&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /usr/local/bin/migrate /usr/local/bin/migrate&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /build/migrations/sql ./sql&lt;/span&gt;

&lt;span class="c"&gt;# Generate the migration execution script&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'#!/bin/sh'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /migrations/run-migration.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'set -e'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /migrations/run-migration.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'DSN="mysql://${DB_USER}:${DB_PASSWORD}@unix(/cloudsql/${CLOUD_SQL_CONNECTION_NAME})/${DB_NAME}"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /migrations/run-migration.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/usr/local/bin/migrate -path /migrations/sql -database "$DSN" up'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /migrations/run-migration.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;chmod&lt;/span&gt; +x /migrations/run-migration.sh

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["/migrations/run-migration.sh"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The key point is that the &lt;code&gt;builder&lt;/code&gt; stage is shared across two targets: &lt;code&gt;app&lt;/code&gt; and &lt;code&gt;migration&lt;/code&gt;. By specifying &lt;code&gt;--target app&lt;/code&gt; and &lt;code&gt;--target migration&lt;/code&gt; in Cloud Build, we can efficiently build both images in parallel.&lt;/p&gt;
&lt;h3&gt;
  
  
  Next.js Web
&lt;/h3&gt;

&lt;p&gt;The frontend follows the same multi-stage build pattern:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 1: Builder&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:25.5-alpine3.22&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /build&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="c"&gt;# ★ Do NOT bake NEXT_PUBLIC_* at build time&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Build failed."&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Stage 2: App (Distroless Node.js)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; gcr.io/distroless/nodejs24-debian12:nonroot&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /build/.next/standalone ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /build/.next/static ./.next/static&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /build/public ./public&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important: Do NOT bake &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; at build time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next.js &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; environment variables are normally embedded statically at build time. However, that would require rebuilding for each environment. To avoid this, we inject &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; at runtime instead (details below).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Why Distroless Images?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;th&gt;Base Image&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Go API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gcr.io/distroless/static-debian12:nonroot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Statically linked binary needs no shell; minimal attack surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js Web&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gcr.io/distroless/nodejs24-debian12:nonroot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minimal image containing only the Node.js runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;alpine:3.21&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The migrate tool requires a shell to run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  Environment Separation with Kustomize — The base + overlays Pattern
&lt;/h2&gt;

&lt;p&gt;To deploy the same image while applying different configurations per environment, we use Kustomize.&lt;/p&gt;
&lt;h3&gt;
  
  
  Directory Structure
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  base/service.yaml — Shared Definition
&lt;/h3&gt;

&lt;p&gt;The base contains the Cloud Run service definition shared across all environments:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;serving.knative.dev/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${serviceName}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;autoscaling.knative.dev/minScale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;
        &lt;span class="na"&gt;autoscaling.knative.dev/maxScale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10"&lt;/span&gt;
        &lt;span class="na"&gt;run.googleapis.com/cpu-throttling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
        &lt;span class="na"&gt;run.googleapis.com/startup-cpu-boost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-runner@${projectId}.iam.gserviceaccount.com&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-api&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http1&lt;/span&gt;
              &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;APP_ENV&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${targetId}&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GOOGLE_CLOUD_PROJECT&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${projectId}&lt;/span&gt;
            &lt;span class="c1"&gt;# Environment variables from Secret Manager&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DB_USER&lt;/span&gt;
              &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DB_USER&lt;/span&gt;
                  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;latest&lt;/span&gt;
            &lt;span class="c1"&gt;# ... other environment variables&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;${serviceName}&lt;/code&gt; and &lt;code&gt;${projectId}&lt;/code&gt; are replaced per environment via Cloud Deploy's &lt;strong&gt;Deploy Parameters&lt;/strong&gt;. Secret Manager references are defined in the base, while the actual secret values are managed in each GCP project's Secret Manager.&lt;/p&gt;
&lt;h3&gt;
  
  
  overlays/production/kustomization.yaml — Production-Specific Patches
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;

&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;../../base&lt;/span&gt;

&lt;span class="na"&gt;patches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.*"&lt;/span&gt;
    &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
      &lt;span class="s"&gt;- op: replace&lt;/span&gt;
        &lt;span class="s"&gt;path: /metadata/name&lt;/span&gt;
        &lt;span class="s"&gt;value: myapp-api-production&lt;/span&gt;
      &lt;span class="s"&gt;- op: replace&lt;/span&gt;
        &lt;span class="s"&gt;path: /spec/template/spec/containers/0/env/0/value&lt;/span&gt;
        &lt;span class="s"&gt;value: production&lt;/span&gt;
      &lt;span class="s"&gt;- op: replace&lt;/span&gt;
        &lt;span class="s"&gt;path: /spec/template/spec/containers/0/env/1/value&lt;/span&gt;
        &lt;span class="s"&gt;value: myapp-prod-xxxxxx&lt;/span&gt;
      &lt;span class="s"&gt;# Always keep at least 1 instance running (avoid cold starts)&lt;/span&gt;
      &lt;span class="s"&gt;- op: replace&lt;/span&gt;
        &lt;span class="s"&gt;path: /spec/template/metadata/annotations/autoscaling.knative.dev~1minScale&lt;/span&gt;
        &lt;span class="s"&gt;value: "1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;In production, we set &lt;code&gt;minScale: "1"&lt;/code&gt; to avoid cold starts. In staging, it stays at &lt;code&gt;"0"&lt;/code&gt; to optimize costs.&lt;/p&gt;
&lt;h3&gt;
  
  
  Runtime Injection of NEXT_PUBLIC_* — We Didn't Want SSR, But...
&lt;/h3&gt;

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

&lt;p&gt;However, this wasn't an easy decision.&lt;/p&gt;

&lt;p&gt;We originally &lt;strong&gt;didn't want to use SSR (Server-Side Rendering)&lt;/strong&gt;. 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.&lt;/p&gt;

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

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

&lt;p&gt;In the end, we chose Build Once. When we weighed reproducibility and safety against SSR overhead, the latter was an acceptable cost.&lt;/p&gt;

&lt;p&gt;We inject &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; as Cloud Run environment variables at runtime and expose them to the client as &lt;code&gt;window.__RUNTIME_ENV__&lt;/code&gt; via server-rendered HTML:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# cloud-run/base/service.yaml (Web)&lt;/span&gt;
&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# NEXT_PUBLIC_* environment variables injected at runtime&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NEXT_PUBLIC_API_DOMAIN&lt;/span&gt;
    &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NEXT_PUBLIC_API_DOMAIN&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;latest&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NEXT_PUBLIC_FIREBASE_API_KEY&lt;/span&gt;
    &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NEXT_PUBLIC_FIREBASE_API_KEY&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;latest&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why store &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; in Secret Manager?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; 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 &lt;strong&gt;to align with Cloud Run's environment variable injection mechanism&lt;/strong&gt;. By managing them in the same &lt;code&gt;secretKeyRef&lt;/code&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With this approach, &lt;strong&gt;the build happens only once&lt;/strong&gt;. Different API endpoints and Firebase projects for staging and production can be swapped using the same image.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cloud Deploy + Skaffold — Automating Deployments
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h3&gt;
  
  
  Cloud Build — From Build to Release Creation
&lt;/h3&gt;

&lt;p&gt;Let's look at the key steps in &lt;code&gt;cloudbuild.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# 1. Build App Image and Migration Image in parallel&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gcr.io/cloud-builders/docker"&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;build-app"&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;docker build \&lt;/span&gt;
          &lt;span class="s"&gt;--target app \&lt;/span&gt;
          &lt;span class="s"&gt;--cache-from ...myapp-api:latest \&lt;/span&gt;
          &lt;span class="s"&gt;--build-arg COMMIT=$COMMIT_SHA \&lt;/span&gt;
          &lt;span class="s"&gt;-t ...myapp-api:$COMMIT_SHA \&lt;/span&gt;
          &lt;span class="s"&gt;-t ...myapp-api:$_IMAGE_TAG \&lt;/span&gt;
          &lt;span class="s"&gt;.&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gcr.io/cloud-builders/docker"&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;build-migration"&lt;/span&gt;
    &lt;span class="na"&gt;waitFor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;init-submodules"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# Runs in parallel with app build&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;docker build \&lt;/span&gt;
          &lt;span class="s"&gt;--target migration \&lt;/span&gt;
          &lt;span class="s"&gt;-t ...myapp-migration:$COMMIT_SHA \&lt;/span&gt;
          &lt;span class="s"&gt;.&lt;/span&gt;

  &lt;span class="c1"&gt;# 2. Push images to Artifact Registry&lt;/span&gt;
  &lt;span class="c1"&gt;# 3. Apply Cloud Deploy pipeline&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gcr.io/google.com/cloudsdktool/cloud-sdk:alpine"&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apply-pipeline"&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gcloud"&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deploy"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apply"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--file=clouddeploy.yaml"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--region=asia-northeast1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

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

&lt;/div&gt;


&lt;p&gt;Key points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--target app&lt;/code&gt; and &lt;code&gt;--target migration&lt;/code&gt; build two images from the same Dockerfile in parallel&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--cache-from&lt;/code&gt; reuses the previous build's image as a layer cache&lt;/li&gt;
&lt;li&gt;Image tags use &lt;code&gt;$COMMIT_SHA&lt;/code&gt; for immutability&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--annotations&lt;/code&gt; attaches the Git tag and commit SHA to the release&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  clouddeploy.yaml — Defining the Delivery Pipeline
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy.cloud.google.com/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DeliveryPipeline&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-pipeline-api&lt;/span&gt;
&lt;span class="na"&gt;serialPipeline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Stage 1: Staging (Auto-deploy)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targetId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
      &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;staging&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;standard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;predeploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trigger-migration-job"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;postdeploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update-setup-company-job"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# Stage 2: Production (Approval required)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targetId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
      &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;production&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;standard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;predeploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trigger-migration-job"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;postdeploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tag-docker-images"&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create-github-release"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Each stage can have &lt;strong&gt;predeploy&lt;/strong&gt; and &lt;strong&gt;postdeploy&lt;/strong&gt; custom actions.&lt;/p&gt;
&lt;h3&gt;
  
  
  Auto-Promotion — From Staging to Production
&lt;/h3&gt;

&lt;p&gt;Using Cloud Deploy's &lt;strong&gt;Automation&lt;/strong&gt; resource, we automatically trigger promotion to production when staging deployment succeeds:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy.cloud.google.com/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Automation&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-pipeline-api/promote-to-production-automation&lt;/span&gt;
&lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;promoteReleaseRule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;promote-release-rule"&lt;/span&gt;
      &lt;span class="na"&gt;destinationTargetId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@next"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;When &lt;code&gt;destinationTargetId&lt;/code&gt; is set to &lt;code&gt;"@next"&lt;/code&gt;, the release is automatically promoted to the next stage in the pipeline — in our case, Production.&lt;/p&gt;
&lt;h3&gt;
  
  
  Approval Gates for Safe Releases
&lt;/h3&gt;

&lt;p&gt;However, we have an approval gate for the production environment:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy.cloud.google.com/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Target&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
&lt;span class="na"&gt;requireApproval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# ← This is the key&lt;/span&gt;
&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;projects/myapp-prod-xxxxxx/locations/asia-northeast1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;With &lt;code&gt;requireApproval: true&lt;/code&gt;, even though the release is auto-promoted after staging verification, human approval is required before the production deployment proceeds.&lt;/p&gt;

&lt;p&gt;The resulting flow looks like this:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Push
  → Cloud Build (Build)
    → Staging (Auto-deploy)
      → Auto-promotion
        → Approval Gate (Human approves)
          → Production (Deploy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Custom Actions — Automated Migration Execution
&lt;/h3&gt;

&lt;p&gt;Using Skaffold's &lt;code&gt;customActions&lt;/code&gt;, we run custom processes before and after deployment. Here's how we define automated database migration:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# skaffold.yaml&lt;/span&gt;
&lt;span class="na"&gt;customActions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;trigger-migration-job&lt;/span&gt;
    &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job-runner&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gcr.io/google.com/cloudsdktool/cloud-sdk:alpine&lt;/span&gt;
        &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/bin/sh"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;set -e&lt;/span&gt;
            &lt;span class="s"&gt;JOB_NAME="myapp-migration-$(date +%s)"&lt;/span&gt;

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

            &lt;span class="s"&gt;# Execute and wait for completion&lt;/span&gt;
            &lt;span class="s"&gt;gcloud run jobs execute ${JOB_NAME} --wait&lt;/span&gt;

            &lt;span class="s"&gt;# Delete the job on success (keep on failure for log inspection)&lt;/span&gt;
            &lt;span class="s"&gt;gcloud run jobs delete ${JOB_NAME} --quiet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Migration Job Design Points&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Created as a temporary Cloud Run Job, deleted after execution&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;set -e&lt;/code&gt; causes immediate failure on migration error, which also halts the deployment&lt;/li&gt;
&lt;li&gt;On failure, the job is preserved so you can inspect logs in the Cloud Console&lt;/li&gt;
&lt;li&gt;Environment variables are injected per environment via Skaffold's &lt;code&gt;patches&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Custom Actions — Automated Git Tags &amp;amp; GitHub Releases
&lt;/h3&gt;

&lt;p&gt;After a successful production deployment, the &lt;code&gt;postdeploy&lt;/code&gt; action automatically:&lt;/p&gt;

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

          &lt;span class="s"&gt;# Tag Docker images with version&lt;/span&gt;
          &lt;span class="s"&gt;gcloud container images add-tag \&lt;/span&gt;
            &lt;span class="s"&gt;"${AR_ROOT}/myapp-api:${TARGET_SHA}" \&lt;/span&gt;
            &lt;span class="s"&gt;"${AR_ROOT}/myapp-api:${TARGET_TAG}" --quiet&lt;/span&gt;

          &lt;span class="s"&gt;# Authenticate via GitHub App → Create Git tag &amp;amp; Release&lt;/span&gt;
          &lt;span class="s"&gt;# ...&lt;/span&gt;
          &lt;span class="s"&gt;curl -X POST \&lt;/span&gt;
            &lt;span class="s"&gt;https://api.github.com/repos/${REPO_FULL_NAME}/releases \&lt;/span&gt;
            &lt;span class="s"&gt;-d '{&lt;/span&gt;
              &lt;span class="s"&gt;"tag_name": "${TARGET_TAG}",&lt;/span&gt;
              &lt;span class="s"&gt;"generate_release_notes": true&lt;/span&gt;
            &lt;span class="s"&gt;}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;git_tag&lt;/code&gt; and &lt;code&gt;commit_sha&lt;/code&gt; embedded via &lt;code&gt;--annotations&lt;/code&gt; 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.&lt;/p&gt;
&lt;h3&gt;
  
  
  Skaffold Profiles for Environment Switching
&lt;/h3&gt;

&lt;p&gt;Skaffold profiles switch Kustomize overlays and deployment targets per environment:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# skaffold.yaml&lt;/span&gt;
&lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;staging&lt;/span&gt;
    &lt;span class="na"&gt;manifests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;kustomize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cloud-run/overlays/staging&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cloudrun&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;projectid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-stg-xxxxxx&lt;/span&gt;
        &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;asia-northeast1&lt;/span&gt;
    &lt;span class="c1"&gt;# Inject staging-specific environment variables for custom actions&lt;/span&gt;
    &lt;span class="na"&gt;patches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;add&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/customActions/0/containers/0/env&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PROJECT_ID&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp-stg-xxxxxx"&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MIGRATION_IMAGE&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...myapp-migration:latest"&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;manifests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;kustomize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cloud-run/overlays/production&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cloudrun&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;projectid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-prod-xxxxxx&lt;/span&gt;
        &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;asia-northeast1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;By simply switching between the &lt;code&gt;staging&lt;/code&gt; and &lt;code&gt;production&lt;/code&gt; profiles within a single Skaffold configuration, everything — manifest generation targets, custom action environment variables — switches accordingly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Conclusion — What This Pattern Gave Us
&lt;/h2&gt;

&lt;p&gt;By implementing the Build Once, Deploy Many pattern with GCP managed services, we achieved the following balance:&lt;/p&gt;
&lt;h3&gt;
  
  
  Reproducibility
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The exact same container image runs in both staging and production&lt;/li&gt;
&lt;li&gt;"It worked in staging but not in production" can never be caused by build discrepancies&lt;/li&gt;
&lt;li&gt;Images are uniquely identified by commit SHA&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Safety
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Auto-promotion + approval gates balance speed and caution&lt;/li&gt;
&lt;li&gt;Predeploy migrations run automatically; failures halt the deployment&lt;/li&gt;
&lt;li&gt;Distroless images and nonroot users secure the containers&lt;/li&gt;
&lt;li&gt;Multi-project structure isolates permissions between environments&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Speed
&lt;/h3&gt;

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



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;About the code examples in this article&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;



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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lasimban.team/en/" rel="noopener noreferrer"&gt;Start your 14-day free trial → lasimban.team&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://lasimban.team/en/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flasimban.team%2Fog-image.jpg" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://lasimban.team/en/" rel="noopener noreferrer" class="c-link"&gt;
            Lasimban - Scrum-Focused Task Management Tool
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Make Scrum development more intuitive and enjoyable. Lasimban is a Scrum-focused task management tool that shows your team the right direction.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flasimban.team%2Ffavicon.svg"&gt;
          lasimban.team
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;



&lt;p&gt;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!&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>clouddeploy</category>
      <category>webdev</category>
      <category>docker</category>
    </item>
    <item>
      <title>Agile tools became Excel for managers. So I built a gamified Scrum board that lives inside your IDE.</title>
      <dc:creator>Ryo Tsugawa</dc:creator>
      <pubDate>Tue, 17 Mar 2026 15:11:53 +0000</pubDate>
      <link>https://dev.to/ryotsun/agile-tools-became-excel-for-managers-so-i-built-a-gamified-scrum-board-that-lives-inside-your-ide-5cd8</link>
      <guid>https://dev.to/ryotsun/agile-tools-became-excel-for-managers-so-i-built-a-gamified-scrum-board-that-lives-inside-your-ide-5cd8</guid>
      <description>&lt;h2&gt;
  
  
  Scrum shouldn't be a reporting chore
&lt;/h2&gt;

&lt;p&gt;Here's a pattern I've seen at every company that "does Scrum":&lt;/p&gt;

&lt;p&gt;Sprint planning is a calendar ritual. The backlog is a graveyard of tickets nobody reads. Daily standups are people reading yesterday's status off a screen while everyone else zones out. And the PM tool? A bloated browser app that takes 8 seconds to load, existing primarily so someone in management can export a burndown chart to a slide deck.&lt;/p&gt;

&lt;p&gt;The tools aren't built for the people doing the work. They're built for the people watching the work get done.&lt;/p&gt;

&lt;p&gt;For developers, the workflow is brutal. You're in the zone, deep in your editor, and then you need to update a ticket status. Context-switch to a heavy browser tab, wait for it to load, click through three dropdowns, and by the time you're back in your terminal you've forgotten what you were doing.&lt;/p&gt;

&lt;p&gt;I got tired of it. So I built something different.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built: Lasimban (羅針盤)
&lt;/h2&gt;

&lt;p&gt;Lasimban means "compass" in Japanese. It's a Scrum-specialized task management tool — not a generic project management Swiss army knife, but a tool that actually understands the Scrum framework.&lt;/p&gt;

&lt;p&gt;The structure is opinionated by design. Epics break down into Product Backlog Items (PBIs), PBIs live in sprints, sprints contain tasks. This isn't a flexible "use it however you want" board — it's Scrum Guide constraints baked into the data model. You can't accidentally create a 6-week sprint or have orphan tasks floating in the void.&lt;/p&gt;

&lt;p&gt;A few things that matter to me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native 4-language support (EN, JA, VI, TL).&lt;/strong&gt; I work with teams spanning Tokyo and Manila. Language shouldn't be a barrier to using your own project management tool. This isn't bolted-on i18n — it's built in from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time sync via GraphQL subscriptions.&lt;/strong&gt; When someone in Manila moves a task, the board updates instantly in Tokyo. No refresh button, no "someone else modified this item" conflicts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DSU (Daily Stand-Up) mode.&lt;/strong&gt; Tasks untouched for 48 hours glow red — they're probably blocked. Tasks completed in the last 24 hours glow green. Your daily standup becomes a 30-second visual scan instead of a 15-minute status recital.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9rtp9scnj55sadrh85gc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9rtp9scnj55sadrh85gc.png" alt="Sprint doctor"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  "Scrum is a Game" — Why I added confetti to a B2B SaaS
&lt;/h2&gt;

&lt;p&gt;This is where people usually raise an eyebrow. Confetti? In a B2B tool? Hear me out.&lt;/p&gt;

&lt;p&gt;Scrum, at its core, is a multiplayer game. You have a team, a timebox, a goal, and a set of rules. There are rounds (sprints), a score (velocity), and win conditions (sprint goals met). The problem is that every tool on the market treats it like a spreadsheet exercise. Check the box, move the card, generate the report.&lt;/p&gt;

&lt;p&gt;So I leaned into the game metaphor:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9vwntgw5y2evjnqp90id.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9vwntgw5y2evjnqp90id.png" alt="Rocket Launch"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Starting a sprint&lt;/strong&gt; triggers a sailing departure animation. You're setting off on a voyage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completing a task&lt;/strong&gt; plays a sparkle effect. Small, quick, satisfying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completing a PBI&lt;/strong&gt; drops confetti. Because shipping a feature &lt;em&gt;should&lt;/em&gt; feel like something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completing a sprint&lt;/strong&gt; launches a rocket animation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is flashy for the sake of it. It's about dopamine loops. The same reason every game gives you visual feedback when you accomplish something — it reinforces the behavior. Completing tasks should feel good, not feel like paperwork.&lt;/p&gt;

&lt;p&gt;I also added &lt;strong&gt;keyboard navigation shortcuts&lt;/strong&gt;. Press &lt;code&gt;g&lt;/code&gt; followed by a key to jump anywhere — &lt;code&gt;g b&lt;/code&gt; for the backlog, &lt;code&gt;g s&lt;/code&gt; for sprints, &lt;code&gt;g d&lt;/code&gt; for the dashboard. Your hands never leave the keyboard. Because if you're building a tool for developers, it should respect how developers actually work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hack: MCP integration — never leave your IDE
&lt;/h2&gt;

&lt;p&gt;This is the part I'm most excited about technically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP (Model Context Protocol)&lt;/a&gt; is an open standard that lets AI assistants connect to external tools and data sources. Think of it as a universal API layer between LLMs and your applications. If your editor has an AI assistant (Cursor, Claude Code, GitHub Copilot, etc.), MCP lets that assistant talk directly to Lasimban.&lt;/p&gt;

&lt;p&gt;The motivation is simple: developers live in the IDE. If I can bring the Scrum board &lt;em&gt;into&lt;/em&gt; the editor through AI, there's zero context-switching. You ask your AI assistant "what's left in this sprint?" and it pulls the answer from Lasimban without you ever opening a browser.&lt;/p&gt;

&lt;p&gt;Here's how I built it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stateless HTTP, not SSE
&lt;/h3&gt;

&lt;p&gt;The MCP spec defines SSE (Server-Sent Events) streaming as a transport option, but I went with stateless HTTP — &lt;code&gt;POST&lt;/code&gt; request in, JSON response out, connection closed.&lt;/p&gt;

&lt;p&gt;Why? The backend runs on &lt;strong&gt;Cloud Run&lt;/strong&gt;, which scales containers from 0 to 10 based on request volume. SSE requires long-lived connections and session management — a client connects, holds the connection open, and the server pushes events over time. That's fundamentally at odds with Cloud Run's request-based scaling model. You'd pay for idle containers holding open connections and need sticky sessions or external session storage.&lt;/p&gt;

&lt;p&gt;Stateless HTTP means every request is independent. Container spins up, handles the request, spins down. Auto-scaling just works. Simple to operate, cost-effective, and fully compliant with the MCP 2025-03-26 spec's Streamable HTTP transport.&lt;/p&gt;

&lt;p&gt;The implementation uses &lt;code&gt;mark3labs/mcp-go&lt;/code&gt; v0.45.0 mounted on a Gin router. Single endpoint: &lt;code&gt;POST /mcp&lt;/code&gt;, speaking JSON-RPC.&lt;/p&gt;

&lt;h3&gt;
  
  
  Markdown responses for LLM readability
&lt;/h3&gt;

&lt;p&gt;Every tool response is formatted as Markdown text, not raw JSON.&lt;/p&gt;

&lt;p&gt;This is deliberate. LLMs understand and summarize Markdown far better than they parse nested JSON objects. When the AI pulls sprint details, it gets a formatted overview with burnup/burndown data laid out in a way that's easy to reason about. A dedicated Presenter layer handles the conversion — the Usecase layer returns domain objects, and the Presenter formats them into Markdown strings.&lt;/p&gt;

&lt;p&gt;The result: when you ask "summarize the current sprint," the AI gives you a coherent paragraph, not a JSON dump.&lt;/p&gt;

&lt;h3&gt;
  
  
  API key authentication
&lt;/h3&gt;

&lt;p&gt;Authentication uses &lt;code&gt;lsb_&lt;/code&gt;-prefixed API keys — Base62-encoded, 32 bytes of entropy. The plaintext key is shown exactly once at creation, then stored as a SHA-256 hash. Each user can have up to 3 keys, revocable anytime.&lt;/p&gt;

&lt;p&gt;This is the same pattern GitHub and Stripe use for their API keys. Prefixed so you can identify them in logs and secret scanners, hashed so a database breach doesn't compromise access.&lt;/p&gt;

&lt;p&gt;Here's the clever part: after API key authentication, the server generates the same user context as a regular browser login. That means &lt;strong&gt;zero new business logic&lt;/strong&gt; was needed for MCP. The entire auth flow reuses existing infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  11 tools, read-heavy by design
&lt;/h3&gt;

&lt;p&gt;The MCP server exposes 11 tools:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7 read tools:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list_projects&lt;/code&gt; — all projects the user has access to&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_sprints&lt;/code&gt; — sprints for a project&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_product_backlog_items&lt;/code&gt; — PBIs with status filtering&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_backlog_statuses&lt;/code&gt; — available status options&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_sprint_details&lt;/code&gt; — full sprint data including burndown metrics&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_product_backlog_item&lt;/code&gt; — detailed PBI view&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_task&lt;/code&gt; — individual task details&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4 write tools:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;update_task_status&lt;/code&gt; — change a task's status&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_pbi&lt;/code&gt; — create a new PBI (with priority and Markdown description support)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;create_task&lt;/code&gt; — create a new task (with Markdown description and self-assign option)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;update_pbi_status&lt;/code&gt; — update a PBI's backlog status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read-heavy, write-light. The AI can observe everything and make targeted changes — creating items and updating statuses — but can't restructure or delete existing data.&lt;/p&gt;

&lt;p&gt;Every tool calls the existing Usecase layer directly. No new business logic was written for MCP — the tools are thin wrappers that authenticate, call the same functions the web app uses, and format the output as Markdown.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this actually looks like in practice
&lt;/h3&gt;

&lt;p&gt;From your editor, you can do things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"Summarize the current sprint status"&lt;/em&gt; — the AI pulls sprint details, burndown data, and gives you a status overview&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Which PBIs have the most remaining tasks?"&lt;/em&gt; — the AI identifies where bottlenecks are hiding&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Mark task LSMB-42 as done"&lt;/em&gt; — status update from your IDE, no browser needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full workflow becomes: check your task, implement the code, update the status — all without leaving the editor. Your Scrum board becomes ambient information rather than a destination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;p&gt;Quick overview for the curious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Go (Gin framework, clean architecture)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Next.js&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API:&lt;/strong&gt; GraphQL for the web client, JSON-RPC for MCP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time:&lt;/strong&gt; WebSocket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure:&lt;/strong&gt; Cloud Run (0-to-10 auto-scaling)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP:&lt;/strong&gt; mark3labs/mcp-go, stateless Streamable HTTP&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The latest update already expanded MCP tools to 11 — you can now create PBIs (with priority levels), create tasks with Markdown descriptions, and update PBI statuses directly from your IDE. The longer-term vision: AI facilitating actual Scrum events. Imagine an AI that auto-summarizes your sprint review based on completed PBIs, or suggests retrospective improvements based on sprint metrics patterns.&lt;/p&gt;

&lt;p&gt;The goal isn't to replace the Scrum Master — it's to remove the clerical overhead so teams can focus on the conversations that actually matter.&lt;/p&gt;

&lt;p&gt;If you want to try it out, Lasimban has a free tier at &lt;a href="https://lasimban.team" rel="noopener noreferrer"&gt;lasimban.team&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;

&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://lasimban.team/en/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flasimban.team%2Fog-image.jpg" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://lasimban.team/en/" rel="noopener noreferrer" class="c-link"&gt;
            Lasimban - Scrum-Focused Task Management Tool
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Make Scrum development more intuitive and enjoyable. Lasimban is a Scrum-focused task management tool that shows your team the right direction.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Flasimban.team%2Ffavicon.svg"&gt;
          lasimban.team
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;







&lt;p&gt;I'm curious — &lt;strong&gt;what's the most annoying thing about your current agile tool?&lt;/strong&gt; The thing that makes you think "why is this so hard?" every single time. I'd love to hear what pain points other developers are hitting, especially if you're working across time zones or languages.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>go</category>
    </item>
  </channel>
</rss>
