Deployment Gates: Reviewers and Wait Timers
The Fargate service from the last chapter deploys the instant you push to main — no pause, no second opinion, nobody watching. That’s exactly the kind of unattended deploy that made sense for practice, and exactly the kind you’d (might) never want for a real production target. This chapter goes back to the Environment protection rules introduced early in this section and actually turns them on.
Creating the production Environment
The workflow from the last chapter referenced an aws-ecs environment with no protection rules attached — GitHub created it automatically the first time that job ran. This chapter uses a differently-named environment, production, specifically so the gate only applies to the job you actually want gated:
- Settings → Environments → New environment, name it
production. - Under Deployment protection rules, enable Required reviewers — add yourself (or a teammate, if you’re working with one) as a reviewer. You can list up to 6 people or teams.
- Enable Wait timer and set it to something short but real for practice — 5 minutes is enough to prove the mechanism works without making every test run tedious.
- Save.
Note
A required reviewer can’t approve their own pull request-triggered deployment in some GitHub plans, but that restriction is about PR review, not this. Deployment approval is a separate permission — as the repo owner, you can approve your own gated deployment here even solo.
Splitting the Workflow
The single deploy job from the last chapter did everything — build, push, render, deploy — in one gated unit. That means the gate would block the build too, which wastes the reviewer’s wait pointlessly: there’s nothing to review yet until an image actually exists. Split it into two jobs instead, so the gate sits only in front of the part that actually touches production:
name: Deploy to ECS Fargate (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:
needs: build-and-push
runs-on: ubuntu-latest
environment:
name: production
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: Fetch current task definition
run: |
aws ecs describe-task-definition \
--task-definition devstuffs-hello \
--query taskDefinition \
> task-definition.json
- name: Render new image into task definition
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 }}
- name: Deploy to ECS
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: trueTwo things changed structurally from the last chapter, both worth noticing:
outputs: image:on thebuild-and-pushjob is what carries the resolved image reference across the job boundary. A step output only lives inside its own job by default —needs.build-and-push.outputs.imagein thedeployjob is how a downstream job reads a value an upstream job produced, which you haven’t needed until now because everything used to happen inside one job.environment: productionsits only ondeploy, not onbuild-and-push. That’s deliberate: the build can run immediately and unattended, while only the step that actually touches the live service waits on a human.
flowchart LR
A[Push to main] --> B[build-and-push job<br/>runs immediately]
B --> C{production environment<br/>required reviewer}
C -- waiting --> C
C -- approved --> D{wait timer<br/>5 minutes}
D --> E[deploy job<br/>updates ECS service]
Watching a Gated Run
Push a commit and open the run from the Actions tab. build-and-push finishes normally, and deploy sits in a Waiting state instead of starting — the Actions run page shows a Review deployments button right there. Click it, select production, approve, and watch the run log show the wait timer counting down before the actual ECS update call fires.
Tip
If you don’t want to sit and watch a 5-minute timer every time you’re testing this chapter, GitHub also lets you reject a pending deployment from that same Review deployments panel — useful for confirming the gate genuinely blocks the job, without waiting out the timer on every single test push.
What This Buys You
Nothing about the ECS deploy step itself changed — same task definition render, same amazon-ecs-deploy-task-definition call. What changed is that a bad or accidental push to main no longer reaches the live service unattended: someone has to look at the run and consciously let it through, and even after they do, there’s a fixed window where a second thought (or a fast Cancel workflow) can still stop it before AWS ever sees the update. That’s the entire value of a deployment gate — it doesn’t make deploys safer by inspecting anything, it just guarantees a human is in the loop before the risky step runs.