
Deploy to AWS ECS Fargate from GitHub Actions with OIDC (Zero Long-Lived AWS Keys)
A hands-on, production-minded guide: GitHub Actions -> ECR -> ECS Fargate using OIDC federation, least-privilege IAM, rollback on failure, and the exact errors you will hit.
If your CI pipeline still uses
AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY stored in GitHub Secrets, you have a ticking time bomb. Those keys never expire, they get copied into forks, logs and laptops, and rotating them is something everyone promises to do and nobody does.In this article we build a pipeline that:
- Authenticates to AWS using OpenID Connect (OIDC), so there are no stored AWS credentials at all.
- Builds a Docker image and pushes it to Amazon ECR.
- Deploys it to ECS Fargate and waits for the service to become stable.
- Rolls back automatically if the new version is unhealthy.
- Uses least-privilege IAM, locked to one repo and one branch.
I'll also cover the errors that cost me hours, so you don't have to lose them too.
1. How the flow works
1
2
3
4
5
6
7
8
9
10
GitHub Actions job
| 1. Requests a signed JWT (OIDC token) from GitHub
v
AWS STS <-- 2. AssumeRoleWithWebIdentity (JWT + role ARN)
| 3. Validates JWT signature + trust policy conditions
v
Temporary credentials (valid ~1 hour)
|
+--> ECR: push image
+--> ECS: register new task definition, update service
The key idea: AWS trusts GitHub's identity provider. Your trust policy decides which repository, branch or environment may assume the role. The credentials are short-lived and exist only for that job.
2. Prerequisites
- An AWS account and the AWS CLI configured for setup only (your own admin user, not for CI).
- An existing ECS cluster, service (Fargate) and VPC. If you do not have one yet, create a cluster and a service with the console first, then automate.
- A GitHub repo with a containerised app.
- Terraform >= 1.5 (optional, you can click through the console, but IaC is better).
Throughout this article, replace:
| Placeholder | Meaning |
|---|---|
| 123456789012 | Your AWS account ID |
| my-org/my-app | Your GitHub owner/repo |
| eu-west-1 | Your AWS region |
| my-app | Name used for ECR repo, ECS service, container |
3. Sample app and Dockerfile
Any app works. A minimal Node example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app /app
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Notes that matter in production:
- Multi-stage build keeps the image small, which means faster pulls and faster deploys.
USER nodeavoids running as root inside the container.- Make sure your app exposes a health endpoint (for example
/health) that returns HTTP 200. Your load balancer and the ECS deployment depend on it.
4. Create the OIDC provider and IAM role (Terraform)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# oidc.tf
data "aws_caller_identity" "current" {}
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# Required by older Terraform AWS provider versions.
# AWS now validates GitHub's certificate via its trusted CA library,
# but keeping this avoids validation errors on older providers.
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
data "aws_iam_policy_document" "github_trust" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# THIS is your security boundary. Be specific.
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:my-org/my-app:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "github_deploy" {
name = "github-actions-deploy-my-app"
assume_role_policy = data.aws_iam_policy_document.github_trust.json
max_session_duration = 3600
}
Why the sub condition is everything
If you only check
aud, then any repository on GitHub can request a token and assume your role. The sub claim pins it down. Common formats:| Trigger | sub value |
|---|---|
| Push to a branch | repo:OWNER/REPO:ref:refs/heads/main |
| Pull request | repo:OWNER/REPO:pull_request |
| Job using an environment | repo:OWNER/REPO:environment:production |
| Tag push | repo:OWNER/REPO:ref:refs/tags/v1.0.0 |
Use
StringEquals for exact match. Use StringLike only when you intentionally want wildcards (for example repo:my-org/my-app:*), and understand the risk before doing so.Best practice: deploy from a GitHub Environment (such as
production) with required reviewers, and trust the environment:production subject. Then a human approval gates every production deploy.5. Least-privilege permissions for the role
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# permissions.tf
data "aws_iam_policy_document" "deploy" {
# ECR login token is account-wide by design
statement {
sid = "EcrAuth"
actions = ["ecr:GetAuthorizationToken"]
resources = ["*"]
}
# Push only to this repository
statement {
sid = "EcrPush"
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer"
]
resources = ["arn:aws:ecr:eu-west-1:${data.aws_caller_identity.current.account_id}:repository/my-app"]
}
# Task definition actions do not support resource-level restrictions
statement {
sid = "EcsTaskDef"
actions = [
"ecs:RegisterTaskDefinition",
"ecs:DescribeTaskDefinition"
]
resources = ["*"]
}
statement {
sid = "EcsService"
actions = [
"ecs:UpdateService",
"ecs:DescribeServices"
]
resources = ["arn:aws:ecs:eu-west-1:${data.aws_caller_identity.current.account_id}:service/my-cluster/my-app"]
}
# Allow passing ONLY the task roles, ONLY to ECS tasks
statement {
sid = "PassTaskRoles"
actions = ["iam:PassRole"]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/my-app-task-execution-role",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/my-app-task-role"
]
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy" "deploy" {
name = "deploy-my-app"
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.deploy.json
}
Output the role ARN so you can paste it into your workflow:
1
2
3
output "deploy_role_arn" {
value = aws_iam_role.github_deploy.arn
}
Run:
1
2
terraform init
terraform apply
6. ECR repository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
resource "aws_ecr_repository" "app" {
name = "my-app"
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_ecr_lifecycle_policy" "app" {
repository = aws_ecr_repository.app.name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "Keep last 20 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 20
}
action = { type = "expire" }
}]
})
}
Two production habits here:
- Immutable tags: you can never overwrite
abc123with a different image. What you tested is what you deploy. - Lifecycle policy: ECR storage costs money. Without it, you keep every build forever.
7. Task definition template
Store this in your repo as
.aws/task-definition.json. The workflow will inject the new image into it.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"family": "my-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::123456789012:role/my-app-task-execution-role",
"taskRoleArn": "arn:aws:iam::123456789012:role/my-app-task-role",
"containerDefinitions": [
{
"name": "my-app",
"image": "PLACEHOLDER",
"essential": true,
"portMappings": [
{ "containerPort": 3000, "protocol": "tcp" }
],
"healthCheck": {
"command": ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"],
"interval": 15,
"timeout": 5,
"retries": 3,
"startPeriod": 20
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-app",
"awslogs-region": "eu-west-1",
"awslogs-stream-prefix": "app"
}
}
}
]
}
The
container name here (my-app) must match the container-name in the workflow. Mismatches are a classic silent failure.8. Enable deployment circuit breaker (automatic rollback)
On your ECS service (Terraform shown):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
resource "aws_ecs_service" "app" {
name = "my-app"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 2
launch_type = "FARGATE"
deployment_circuit_breaker {
enable = true
rollback = true
}
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
# network_configuration, load_balancer, etc. omitted for brevity
lifecycle {
ignore_changes = [task_definition] # CI owns the task definition revision
}
}
What this gives you:
- ECS detects tasks that keep failing to start or pass health checks.
- It stops the rollout and reverts to the last good task definition.
minimum_healthy_percent = 100andmaximum_percent = 200means new tasks come up before old ones go down, which gives zero-downtime rolling deploys.ignore_changes = [task_definition]stops Terraform from fighting your pipeline over which revision is live.
9. The GitHub Actions workflow
.github/workflows/deploy.yml:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
name: Deploy to ECS
on:
push:
branches: [main]
# Only one deploy at a time per branch; newer commit cancels older queued run
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
permissions:
id-token: write # REQUIRED for OIDC
contents: read
env:
AWS_REGION: eu-west-1
ECR_REPOSITORY: my-app
ECS_CLUSTER: my-cluster
ECS_SERVICE: my-app
CONTAINER_NAME: my-app
TASK_DEFINITION: .aws/task-definition.json
jobs:
deploy:
runs-on: ubuntu-latest
# environment: production # uncomment to require approvals + use environment sub claim
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-my-app
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag and push image
id: build-image
env:
REGISTRY: ${{ steps.ecr-login.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t "$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" .
docker push "$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
echo "image=$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> "$GITHUB_OUTPUT"
- name: Render task definition with new image
id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ${{ env.TASK_DEFINITION }}
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy to ECS and wait for stability
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.render.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
What each important piece does
permissions: id-token: writelets the job request the OIDC JWT. Without it, the role assumption fails.- Image tag = commit SHA. Never deploy
latestto production. You lose traceability and rollbacks become guesswork. wait-for-service-stability: truemakes the pipeline fail if the deployment does not become healthy, instead of reporting green while production burns.concurrencyprevents two deploys racing each other and corrupting the order of task definition revisions.
10. Troubleshooting: the errors you will actually hit
Error: Not authorized to perform sts:AssumeRoleWithWebIdentity
This is the number one error. Check in this order:
- Missing
id-token: writein the workflowpermissions. submismatch. Compare your trust policy to what GitHub actually sends. Debug by decoding the token (never log it in a public repo):
__PRE_PLACEHOLDER_10__- Using an environment in the job but trusting
ref:refs/heads/main. When a job hasenvironment: production, the subject becomesrepo:OWNER/REPO:environment:production, not the branch. - Pull request workflows have a different
suband fork PRs get no OIDC token at all, which is a good security default. - Wrong
aud. It must match what is in the provider'sclient_id_list(sts.amazonaws.com). - Provider URL typo. It must be exactly
https://token.actions.githubusercontent.comwith no trailing slash.
Error: AccessDeniedException ... iam:PassRole
The deploy role needs
iam:PassRole on both the task execution role and the task role referenced in the task definition. Check the ARNs in the policy match the task definition exactly, and that the iam:PassedToService condition is ecs-tasks.amazonaws.com.Error: Service never stabilises / ResourceInitializationError: unable to pull secrets or registry auth
The Fargate task cannot reach ECR, Secrets Manager or CloudWatch. Fix one of:
- Place tasks in private subnets with a NAT Gateway, or
- Use a public subnet with
assignPublicIp = ENABLED(cheaper, less isolated), or - Create VPC endpoints:
ecr.api,ecr.dkr,logs, and an S3 gateway endpoint (ECR layers are stored in S3).
Error: Tasks start then get killed, repeatedly
- The container fails its health check. Test locally:
docker run -p 3000:3000 imagethencurl localhost:3000/health. - The health check command uses a tool that does not exist in the image (
curlis missing in many Alpine images, which is why the example useswget). startPeriodis too short for a slow-booting app.- Out of memory: check the stopped task reason in the console or with:
__PRE_PLACEHOLDER_11__
Error: Pipeline is green but old code is still running
- Container name in the workflow does not match the task definition.
UpdateServicewas called without forcing a new deployment and the task definition did not change. The action handles this when the image changes, but check your service's actual task definition revision.
11. Hardening checklist
- [ ] Trust policy
subis exact, not a wildcard on the whole org. - [ ] Deploy from a protected GitHub Environment with required reviewers.
- [ ] Separate roles per environment (staging role cannot touch production).
- [ ] Pin third-party actions to a commit SHA instead of a tag to reduce supply-chain risk.
- [ ] ECR immutable tags and scan on push enabled.
- [ ] Add an image scan gate in CI, for example Trivy, and fail on
CRITICALfindings. - [ ] Task role has only the permissions the app needs. Execution role and task role are different things: the execution role pulls images and writes logs, the task role is what your application code uses.
- [ ] CloudTrail enabled so every
AssumeRoleWithWebIdentityis auditable. - [ ] Set a CloudWatch alarm on
HTTPCode_Target_5XX_Countso a bad release is caught even when the health check passes.
12. Rolling back manually
Because every image is tagged with a commit SHA and every deploy creates a new task definition revision, rollback is trivial:
1
2
3
4
5
6
7
8
# list recent revisions
aws ecs list-task-definitions --family-prefix my-app --sort DESC --max-items 5
# point the service back at a known-good revision
aws ecs update-service \
--cluster my-cluster \
--service my-app \
--task-definition my-app:41
No rebuild. No guessing what
latest was yesterday.13. Cost notes (important if you are a student or a small team)
- Fargate bills per vCPU and GB per second.
256 CPU / 512 MBis the smallest size and fine for learning. - NAT Gateways are often the largest hidden cost (hourly charge plus data processing). For a learning lab, consider public subnets with tight security groups, or VPC endpoints, and tear everything down with
terraform destroywhen you finish. - ECR lifecycle policies keep storage costs from creeping up.
- Set an AWS Budget alert before you start experimenting.
Conclusion
You now have a pipeline where:
- No AWS secrets live in GitHub. Credentials are issued per job and expire.
- Access is scoped to one repo, one branch or environment, and one set of resources.
- Deploys are traceable: commit SHA to image to task definition revision.
- Bad releases fix themselves through the circuit breaker and stability check.
This is the baseline I recommend for every container project on AWS. Once it is in place, the next steps are blue/green deployments with CodeDeploy, adding Trivy and Checkov scans, and moving the whole stack into reusable Terraform modules. I'll cover those in upcoming posts.
If this helped you, share it with someone still storing AWS keys in GitHub Secrets, and let me know in the comments which error you hit.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article