How do I build a container image once and promote it across environments?

Build and test the image once with Depot, save it to Depot Registry, capture its immutable digest, and deploy that same digest to staging and production. Promotion should change the release pointer, not rebuild the image.

Last updated 2026-09-10

Build the container image once, test it, save it to Depot Registry, and promote the same immutable digest through staging and production.

Don't rebuild from the same Git commit for each environment. A second build can resolve a different base image, package version, timestamp, or network dependency. The source revision may be identical while the image bytes aren't.

Depot builds and stores the artifact. Kubernetes, Cloud Run, or another deployment system deploys it. Keep deployment approval, rollout policy, health checks, and rollback in the system that owns the runtime.

Build and save one immutable image

Depot CI automatically injects a short-lived DEPOT_TOKEN, so depot/build-push-action can save the result to Depot Registry without a registry login:

name: Build image

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: depot-ubuntu-24.04
    outputs:
      build-id: ${{ steps.image.outputs.build-id }}
      digest: ${{ steps.image.outputs.digest }}
      image: registry.depot.dev/<project-id>@${{ steps.image.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: depot/setup-action@v1
      - name: Build and save image
        id: image
        uses: depot/build-push-action@v1
        with:
          project: <project-id>
          context: .
          platforms: linux/amd64,linux/arm64
          save: true
          save-tags: |
            ${{ github.sha }}

  smoke-test:
    needs: build
    runs-on: depot-ubuntu-24.04
    steps:
      - name: Authenticate to Depot Registry
        run: printf '%s' "$DEPOT_TOKEN" | docker login registry.depot.dev --username x-token --password-stdin
      - name: Run the saved image
        env:
          IMAGE: ${{ needs.build.outputs.image }}
        run: |
          docker pull "$IMAGE"
          docker run --detach --name api --publish 8080:8080 "$IMAGE"
          for attempt in $(seq 1 30); do
            if curl --fail --silent http://127.0.0.1:8080/healthz; then
              exit 0
            fi
            sleep 1
          done
          docker logs api
          exit 1

  record-release:
    needs: [build, smoke-test]
    runs-on: depot-ubuntu-24.04
    steps:
      - name: Record promotable artifact
        env:
          BUILD_ID: ${{ needs.build.outputs.build-id }}
          DIGEST: ${{ needs.build.outputs.digest }}
          IMAGE: ${{ needs.build.outputs.image }}
        run: |
          {
            echo '## Promotable image'
            echo
            echo "- Build ID: \`$BUILD_ID\`"
            echo "- Digest: \`$DIGEST\`"
            echo "- Image: \`$IMAGE\`"
          } >> "$GITHUB_STEP_SUMMARY"

depot/build-push-action exposes the same outputs as docker/build-push-action, including the image digest. Record the full registry reference with @sha256:... in the release record.

The commit tag is useful for humans. The digest is the deployment identity. Tags can move. A digest identifies the exact manifest produced by the build.

Test the artifact that will be deployed

The example expects the application to listen on port 8080 and return a successful response from /healthz. Adapt the health check to the application, but keep the image input as needs.build.outputs.image. The test pulls the saved digest, not a mutable tag.

record-release runs only after smoke-test passes. Use the build ID and image digest from that record as the inputs to manual promotion. Don't promote values copied from the build job when its smoke test failed or never ran.

This smoke-test job runs on an AMD64 sandbox, so it exercises only the AMD64 image selected from the multi-platform manifest. Add a second depot-ubuntu-24.04-arm smoke-test job when production also runs the ARM64 image.

Don't compile the application again in the deployment job. That tests one artifact and deploys another.

For multi-platform images, the digest points to the manifest list that contains the AMD64 and ARM64 images. Depot runs those builds on native CPUs in parallel and keeps the project layer cache across local and CI builds.

Promote by digest

Pass the immutable image reference into a separately dispatched deployment workflow:

name: Deploy image

on:
  workflow_dispatch:
    inputs:
      image:
        description: Immutable image reference including sha256 digest
        required: true
        type: string
      environment:
        description: Deployment environment
        required: true
        type: choice
        options:
          - staging
          - production

jobs:
  deploy:
    runs-on: depot-ubuntu-24.04
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - name: Validate immutable source image
        env:
          INPUT_IMAGE: ${{ inputs.image }}
        run: |
          expected_prefix='registry.depot.dev/<project-id>@'
          case "$INPUT_IMAGE" in
            "$expected_prefix"*) ;;
            *) echo "Image must use $expected_prefix" >&2; exit 1 ;;
          esac
          digest="${INPUT_IMAGE#*@}"
          printf '%s' "$digest" | grep --extended-regexp --quiet '^sha256:[0-9a-f]{64}$'
      - name: Configure Kubernetes credentials
        run: |
          printf '%s' "$KUBECONFIG_B64" | base64 --decode > "$RUNNER_TEMP/kubeconfig"
          echo "KUBECONFIG=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_ENV"
        env:
          KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
      - name: Deploy exact image
        env:
          IMAGE: ${{ inputs.image }}
        run: |
          kubectl set image deployment/api api="$IMAGE"
          kubectl rollout status deployment/api --timeout=5m
          deployed_image="$(kubectl get deployment/api -o jsonpath='{.spec.template.spec.containers[?(@.name=="api")].image}')"
          test "$deployed_image" = "$IMAGE"

Create KUBECONFIG_B64 variants limited to the staging and production environments. The workflow keeps one secret name while Depot selects the matching cluster credential.

The cluster must already contain a Deployment whose container and pull-secret names match the workflow. This minimal manifest defines those pieces:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      imagePullSecrets:
        - name: depot-registry
      containers:
        - name: api
          image: registry.depot.dev/<project-id>@sha256:<initial-digest>
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 5

kubectl set image deployment/api api=... updates the container named api. The rollout command waits for the Deployment controller to report the new ReplicaSet ready, then the final command verifies the image on that named container rather than assuming it's the first container in the pod.

The manual dispatch is the release decision in this example. The environment field selects secret variants. It doesn't add a human approval by itself. If production requires reviewers, policy checks, progressive delivery, or a change window, enforce those in the deployment system or in the process that dispatches this workflow.

Copy the verified image before deploying to Cloud Run

Cloud Run doesn't pull an arbitrary private Depot Registry image directly. Copy the saved build to Google Artifact Registry, verify the destination digest, then deploy the Artifact Registry reference. Use the values from a successful record-release job. This separately dispatched Depot CI workflow declares the build, digest, and environment inputs:

on:
  workflow_dispatch:
    inputs:
      build_id:
        description: Depot build ID from the promotable release record
        required: true
        type: string
      source_digest:
        description: sha256 digest from the promotable release record
        required: true
        type: string
      environment:
        description: Deployment environment
        required: true
        type: choice
        options:
          - staging
          - production

permissions:
  contents: read
  id-token: write

jobs:
  deploy-cloud-run:
    runs-on: depot-ubuntu-24.04
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v3
        with:
          project_id: ${{ vars.GCP_PROJECT_ID }}
          workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
      - uses: google-github-actions/setup-gcloud@v3
      - uses: depot/setup-action@v1
      - name: Copy, verify, and deploy
        env:
          BUILD_ID: ${{ inputs.build_id }}
          SOURCE_DIGEST: ${{ inputs.source_digest }}
          GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
          GOOGLE_CLOUD_REGION: ${{ vars.GOOGLE_CLOUD_REGION }}
          ARTIFACT_REPOSITORY: ${{ vars.ARTIFACT_REPOSITORY }}
        run: |
          printf '%s' "$SOURCE_DIGEST" | grep --extended-regexp --quiet '^sha256:[0-9a-f]{64}$'

          registry_host="$GOOGLE_CLOUD_REGION-docker.pkg.dev"
          destination_image="$registry_host/$GCP_PROJECT_ID/$ARTIFACT_REPOSITORY/api"
          destination_tag="promotion-$GITHUB_RUN_ID"

          gcloud auth configure-docker "$registry_host" --quiet
          depot push \
            --project <project-id> \
            --tag "$destination_image:$destination_tag" \
            "$BUILD_ID"

          destination_digest="$(
            gcloud artifacts docker images describe \
              "$destination_image:$destination_tag" \
              --format='value(image_summary.digest)'
          )"
          test "$destination_digest" = "$SOURCE_DIGEST"

          destination_ref="$destination_image@$destination_digest"
          gcloud run deploy api \
            --image "$destination_ref" \
            --region "$GOOGLE_CLOUD_REGION" \
            --quiet

          deployed_image="$(
            gcloud run services describe api \
              --region "$GOOGLE_CLOUD_REGION" \
              --format='value(spec.template.spec.containers[0].image)'
          )"
          test "$deployed_image" = "$destination_ref"

Configure Google Cloud Workload Identity Federation to trust Depot CI's https://identity.depot.dev issuer and scope the trust to the intended Depot organization, repository, and branch. A provider configured only for GitHub's https://token.actions.githubusercontent.com issuer will reject Depot CI tokens. The Depot CI OIDC guide documents the issuer, subject format, claims, and migration path.

The Google service account needs permission to write the Artifact Registry repository and deploy the Cloud Run service. The workflow identity also needs permission to impersonate that service account, and the deployer needs permission to act as the Cloud Run runtime service account. Create the Artifact Registry Docker repository before the workflow runs. Cloud Run deploys container images from supported registries, and Artifact Registry exposes image digests for this verification step.

The transfer preserves the OCI manifest digest. Refuse the deployment if the destination digest differs from the source digest tested in CI.

Make the image pullable by the runtime

Depot Registry is private. A Kubernetes cluster needs an image pull secret or another supported Depot token available to the runtime:

kubectl create secret docker-registry depot-registry \
  --docker-server=registry.depot.dev \
  --docker-username=x-token \
  --docker-password=<depot-token>

Reference that secret through imagePullSecrets in the workload. Use a token with only the access the runtime needs.

You can also use depot push to copy a saved build directly from Depot infrastructure into ECR, Artifact Registry, Docker Hub, or another registry. The local runner doesn't need to download and upload every layer.

Keep the promoted digest long enough

Retention is part of release correctness. A rollback fails if the previous digest has already been deleted.

Tagged ordinary Depot Registry repositories have opt-in retention rules. Untagged manifests default to three days when the repository uses its default rule. Images saved from depot build --save use the project's image retention policy, which defaults to Unlimited and can be set to 1, 7, 14, or 30 days.

Choose retention from the rollback window, not only the frequency of new builds. Keep every production digest and its immediate rollback candidates for as long as the deployment policy requires.

Roll back by changing the pointer

A rollback should deploy the previous known-good digest. It shouldn't rerun the old build.

Record the commit SHA, Depot build ID, digest, test run, and deployment environment together. That gives the release system enough information to answer what is running and to restore it without trying to recreate old bytes from old source.

Start building with Depot
in minutes