Skip to content

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:

  1. Settings → Environments → New environment, name it staging, save with no rules added.
  2. 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: true

The 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
Last updated on