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
| Strategy | How | Rollback | Cost |
|---|---|---|---|
| Recreate | Stop, deploy, start | Slow, downtime | Cheap |
| Rolling | Replace instances in batches | Roll forward or back gradually | Medium |
| Blue-green | Two full environments, switch traffic | Instant switch back | Double infrastructure |
| Canary | Send a fraction of traffic to the new version | Remove the canary | Small overhead |
| Feature flag | Ship code dark, enable per user | Toggle off instantly | Flag 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: 1mA 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.
Related
Building and publishing container images in CI Infrastructure as code in the pipeline
Last refreshed 2026-09-18.