Skip to content

Deploy to AWS with GitHub Actions

Lesson 1 of 7Intermediate → Advanced6 min readGitHub Actions & CI/CD · Continuous DeliveryVerified: aws-actions/configure-aws-credentials v6, aws-actions/amazon-ecs-deploy-task-definition v2, August 2026

Most AWS deployment tutorials open by telling you to create an IAM user, generate an access key, and paste it into repository secrets. Do not do that. A stored access key is a credential that works from anywhere in the world, for anyone who obtains it, until somebody notices and rotates it — and the usual way people notice is a bill.

There is a better mechanism, and it is not harder.

The complete workflow is at examples/github-actions/cd-aws/deploy.yml, validated by npm run check:workflows.

jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- name: Authenticate to AWS
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-deploy-production
aws-region: eu-west-1

Three things in that block are doing the security work, and each is easy to misread.

id-token: write does not grant access to AWS. It grants this job permission to ask GitHub for a signed token describing itself — the repository, the branch, the workflow, the environment. AWS decides separately whether that description is one it trusts. If you read it as “write access to AWS”, the whole model looks alarming; read correctly, it is the least privileged step in the file.

role-to-assume names a role, not a credential. There is no secret here. Anyone can read this ARN; it is useless without an AWS-side trust policy that names your repository.

environment: production is not decoration. It is the hook that lets GitHub require reviewers, restrict which branches may deploy, and hold environment-scoped secrets — enforced by the platform. See environments.

concurrency:
group: deploy-production
cancel-in-progress: false

This is the opposite of the setting you want on CI. In CI, a new commit makes the previous run irrelevant, so cancel-in-progress: true saves runner time. In deployment, cancelling a run part-way through leaves the system in a state that neither the old nor the new version describes — half the assets uploaded, a task definition registered but not rolled out.

cancel-in-progress: false queues instead. The second deploy waits for the first to finish.

Static hosting looks trivial until you get the cache headers wrong, at which point users load a new index.html that references assets which no longer exist, or an old index.html for a day.

The pattern that works relies on content-hashed asset filenames:

- name: Deploy static assets
run: |
aws s3 sync ./dist "s3://${BUCKET}" \
--delete \
--cache-control "public,max-age=31536000,immutable" \
--exclude "index.html"
env:
BUCKET: example-production-assets
- name: Deploy index.html with a short cache
run: |
aws s3 cp ./dist/index.html "s3://${BUCKET}/index.html" \
--cache-control "public,max-age=60,must-revalidate"
env:
BUCKET: example-production-assets

Assets whose names contain a content hash can be cached forever — a changed file gets a new name, so there is nothing to invalidate. index.html cannot: it is the one file whose name is stable and whose contents change every deploy. Caching it for a year means users keep loading references to assets you deleted.

--delete removes objects from the bucket that are no longer in dist. That is what makes the bucket match the build rather than accumulate every file ever deployed. It is also the flag that will empty your bucket if dist is empty because the build silently failed — so the build job should fail loudly and needs: should gate this job.

Deploying a container service means registering a new task definition that points at a new image and telling the service to roll it out.

- name: Render the task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ./ecs/task-definition.json
container-name: app
image: ${{ needs.build.outputs.image-digest }}
- name: Deploy
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: app-production
cluster: production
wait-for-service-stability: true

Pass the digest from the build job, not a tag. A tag like main points at a different image every merge; a digest is a content hash that identifies exactly one image forever. That property is what makes a rollback a matter of redeploying a known digest — see Docker CI for how the build job produces it.

wait-for-service-stability: true is the difference between a workflow that reports “deployed” and one that reports “deployed and the new tasks are actually running”. Without it, the job succeeds the moment AWS accepts the update, and a container that crash-loops on startup produces a green deployment and a broken service.

- name: Deploy the function
run: |
aws lambda update-function-code \
--function-name "$FUNCTION" \
--zip-file "fileb://function.zip" \
--publish
aws lambda wait function-updated --function-name "$FUNCTION"
env:
FUNCTION: app-production

--publish creates an immutable numbered version. Combined with an alias, that gives you a rollback that is a single API call: repoint the alias at the previous version number.

The wait is not optional in a pipeline that does anything afterwards. Code updates are asynchronous, and a follow-up update-function-configuration against a function still updating fails with a conflict error.

For anything beyond a single function, deploy the whole stack with Terraform or CloudFormation rather than issuing per-resource CLI calls. A pipeline made of individual API calls has no concept of a failed deploy as a whole — each step succeeds or fails independently, and there is no state to reconcile.

Rollback means different things depending on what you deployed, and pretending otherwise is how incidents get worse:

DeploymentRollbackReliable?
S3 static assetsRedeploy the previous build’s outputYes, if the previous artifact still exists
ECS serviceRedeploy the previous task definition revisionYes — revisions are immutable and retained
Lambda with an aliasRepoint the alias at the previous versionYes, and fast
Database migrationNo. A migration that dropped a column has destroyed data
Infrastructure changesRe-apply the old configurationSometimes — see deploying Terraform

The rows that roll back cleanly all share a property: the previous state still exists as an immutable object. The rows that do not are the ones where deploying destroyed something.

This is why the standard advice for migrations is to make them backwards-compatible in both directions — add a column, deploy code that writes to both, backfill, then remove the old column in a later release. It is more work, and it is the only way the deployment above stays rollback-able.

The role assumed by the deployment should be able to do the deployment and nothing else. Concretely:

  • Scope S3 permissions to the one bucket ARN, not arn:aws:s3:::*.
  • Scope ECS permissions to the specific cluster and service.
  • Separate the production role from the staging role, with separate trust policies. A staging deploy that can reach production is not a staging deploy.
  • Restrict the trust policy by sub claim so only the branch or environment you intend can assume it. A trust policy that accepts any workflow in the repository lets a pull request branch deploy to production.

That last point is the one that gets skipped, and it is the one that matters most. See AWS OIDC for the exact condition syntax.

  1. Follow AWS OIDC to create the identity provider and a role whose trust policy names your repository and the production environment.

  2. Create a production environment in the repository with yourself as a required reviewer.

  3. Copy examples/github-actions/cd-aws/deploy.yml, replacing the ARN, region and bucket with your own. Push to main.

  4. Confirm the run pauses for approval before the deploy job starts, and that the environment URL appears on the run.

  5. Approve, and read the aws sts get-caller-identity output. Confirm it names the role you created.

  6. Change the trust policy to require a branch that is not main, push again, and read the failure. Knowing what a rejected trust policy looks like saves an hour the first time it happens for real.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.