Environment promotion and release strategies

Promote one immutable artefact through environments, externalise configuration, and choose between blue-green, canary, rolling and feature-flag releases.

Build once, promote everywhere

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.push.outputs.image }}
    steps:
      - uses: actions/checkout@v4
      - id: push
        run: |
          IMAGE=ghcr.io/example/app@$(docker buildx imagetools inspect ghcr.io/example/app:sha-$GITHUB_SHA --format '{{.Manifest.Digest}}')
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - run: ./deploy.sh --image "${{ needs.build.outputs.image }}" --env staging

  deploy-production:
    needs: [build, deploy-staging]
    runs-on: ubuntu-latest
    environment: production           # required reviewers configured in the repo
    steps:
      - run: ./deploy.sh --image "${{ needs.build.outputs.image }}" --env production
  • The image digest is the only thing that moves between environments. Rebuilding per environment produces a different artefact than the one you tested.
  • Configuration comes from the environment — environment variables, a config service or a mounted file — never from a build-time constant.
  • A migration that must run before the new code is a separate, explicit step. A container that migrates on startup makes a rolling deploy unpredictable.
  • Every deploy writes down which digest is live where, or rollback becomes archaeology.

Release strategies compared

StrategyHowRollbackCost
RecreateStop, deploy, startSlow, downtimeCheap
RollingReplace instances in batchesRoll forward or back graduallyMedium
Blue-greenTwo full environments, switch trafficInstant switch backDouble infrastructure
CanarySend a fraction of traffic to the new versionRemove the canarySmall overhead
Feature flagShip code dark, enable per userToggle off instantlyFlag cleanup debt
# a canary that widens as the error rate stays low
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
  name: app
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: app
  service:
    port: 80
  analysis:
    interval: 1m
    threshold: 5
    maxWeight: 50
    stepWeight: 10
    metrics:
      - name: request-success-rate
        thresholdRange: { min: 99 }
        interval: 1m
      - name: request-duration
        thresholdRange: { max: 500 }
        interval: 1m

A canary is only as good as the metric that decides it. Latency and error rate on the new version, compared with the baseline, are the two that catch most real regressions; a build-time green pipeline does not.

Rolling back and feature flags

# prefer rolling back to the previous known-good image over rebuilding
kubectl set image deployment/app app=ghcr.io/example/app@sha256:OLD_DIGEST
kubectl rollout status deployment/app --timeout=5m

# if the schema changed, the rollback needs a compatible migration first
./migrate.sh --to-version 42 --backward-compatible

# feature flags decouple deploy from release
curl -X PATCH https://flags.internal/api/v1/flags/checkout_v2 \
  -d '{"enabled": false, "environments": ["production"]}'
⚠️
A rollback that is not tested is a hypothesis. Practise it in staging on a normal day, and make the previous digest discoverable from a dashboard rather than from a chat message. The first real rollback should not be the first time you run it.

FAQ

Is blue-green worth the extra infrastructure?
When a release must be reversible in seconds and you can afford two full environments. Otherwise a canary or a rolling deploy with a tested rollback gets most of the safety for a fraction of the cost.
How do I stop feature flags accumulating?
Give every flag an owner and an expiry date when it is created, and add a pipeline check that fails once a flag is past its expiry. Flags are a temporary tool, not a configuration system.

Building and publishing container images in CI Infrastructure as code in the pipeline

Last refreshed 2026-09-18.