Managing Multiple Deployment Environments
Two chapters before this stood up one ECS service and the last chapter put a gate in front of it, but real projects almost never deploy straight from a commit to the one and only live service. This chapter properly separates what’s been one service wearing a “production” label into three real tiers — dev, staging, production — with the same image promoted through all three instead of three separate builds.
Two More ECS Services
The devstuffs-service from previous chapters becomes your dev tier as-is — no changes needed, it already exists and already deploys on every push. Add two more services on the same cluster, reusing the security group and subnet:
aws ecs create-service \
--cluster devstuffs-cluster \
--service-name devstuffs-service-staging \
--task-definition devstuffs-hello \
--desired-count 1 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$SUBNET_ID],securityGroups=[$SG_ID],assignPublicIp=ENABLED}"
aws ecs create-service \
--cluster devstuffs-cluster \
--service-name devstuffs-service-production \
--task-definition devstuffs-hello \
--desired-count 1 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$SUBNET_ID],securityGroups=[$SG_ID],assignPublicIp=ENABLED}"All three services share the same task definition family (devstuffs-hello), but each one tracks its own currently-running revision independently — deploying a new revision to staging has no effect on what dev or production are running until you deploy to them too. That independence is the entire mechanism this chapter relies on.
Creating the staging and dev Environments
production already exists from the last chapter, gate and all. Add two more, both with no protection rules — dev and staging are meant to move fast:
- Settings → Environments → New environment, name it
staging, save with no rules added. - Repeat for
dev.
The Workflow: One Build, Three Promotions
flowchart LR
A[build-and-push] --> B[deploy-dev<br/>auto]
B --> C[deploy-staging<br/>auto]
C --> D{production gate<br/>reviewer + wait timer}
D --> E[deploy-production]
name: Deploy to ECS Fargate (dev/staging/production)
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
build-and-push:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.build-image.outputs.image }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Log in to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v1
- name: Build and push image
id: build-image
env:
ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
ECR_REPOSITORY: devstuffs-hello
IMAGE_TAG: ${{ github.sha }}
run: |
docker image build -t "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" .
docker image push "$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> "$GITHUB_OUTPUT"
deploy-dev:
needs: build-and-push
runs-on: ubuntu-latest
environment:
name: dev
steps:
- uses: actions/checkout@v6
- uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- run: aws ecs describe-task-definition --task-definition devstuffs-hello --query taskDefinition > task-definition.json
- id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: devstuffs-hello
image: ${{ needs.build-and-push.outputs.image }}
- uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.render.outputs.task-definition }}
service: devstuffs-service
cluster: devstuffs-cluster
wait-for-service-stability: true
deploy-staging:
needs: deploy-dev
runs-on: ubuntu-latest
environment:
name: staging
steps:
- uses: actions/checkout@v6
- uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- run: aws ecs describe-task-definition --task-definition devstuffs-hello --query taskDefinition > task-definition.json
- id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: devstuffs-hello
image: ${{ needs.build-and-push.outputs.image }}
- uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.render.outputs.task-definition }}
service: devstuffs-service-staging
cluster: devstuffs-cluster
wait-for-service-stability: true
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
steps:
- uses: actions/checkout@v6
- uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- run: aws ecs describe-task-definition --task-definition devstuffs-hello --query taskDefinition > task-definition.json
- id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: devstuffs-hello
image: ${{ needs.build-and-push.outputs.image }}
- uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.render.outputs.task-definition }}
service: devstuffs-service-production
cluster: devstuffs-cluster
wait-for-service-stability: trueThe whole promotion chain is just needs:, stacked: deploy-staging needs deploy-dev, deploy-production needs deploy-staging. A failure at any stage stops the chain — if deploy-dev fails, deploy-staging never even starts, so a broken image can’t accidentally skip ahead to a later tier. The only job with a real gate in front of it is deploy-production, because that’s the only environment you attached protection rules to — dev and staging reference an environment: purely so they show up on the Deployments tab with their own history, not because anything’s blocking them.
Each of the three deploy jobs is nearly identical, which is exactly the kind of repetition you already know how to collapse — a reusable workflow, covered in an earlier section, would let you define this deploy sequence once and call it three times with different service and environment inputs instead of pasting the same six steps three times. It’s left inline here so the promotion structure is visible in one file while you’re first learning it; collapsing it is a natural refactor once the shape is familiar.
Watching a Full Promotion
Push a commit. build-and-push, then deploy-dev, then deploy-staging all run back to back with no pauses. deploy-production sits Waiting exactly like it did in the last chapter — same reviewer, same wait timer, just now sitting at the end of a three-stage chain instead of standing alone. Check each service’s public IP (the same describe-tasks lookup from earlier chapters, swapping the service name) to confirm dev and staging are already running the new image while production is still on the old one, waiting on approval.
Cleanup
If you’re moving on to the rollback chapter next, leave all three services running — it reuses this exact setup. Otherwise, scale down and remove the two services you added this chapter the same way you would the original:
aws ecs update-service --cluster devstuffs-cluster --service devstuffs-service-staging --desired-count 0
aws ecs delete-service --cluster devstuffs-cluster --service devstuffs-service-staging
aws ecs update-service --cluster devstuffs-cluster --service devstuffs-service-production --desired-count 0
aws ecs delete-service --cluster devstuffs-cluster --service devstuffs-service-production