GitHub Actions can build Docker images and push them to AWS ECR automatically on every push. This guide walks through the complete setup: IAM permissions, ECR repository creation, and the workflow file.
Overview
The pipeline does three things:
- Authenticate with AWS using OIDC (no long-lived secrets)
- Build a Docker image from your
Dockerfile - Push the image to ECR with a unique tag
Push to main → GitHub Actions triggers → Login to AWS ECR → Build Docker image → Tag & Push to ECR
Step 1: Create the ECR repository
Create the repository in AWS. You can do this via the console or the CLI:
# Create the repository
aws ecr create-repository \
--repository-name my-app \
--region us-east-1
# Note the repository URI from the output, e.g.:
# 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-appIf you want the repository to always exist (idempotent), you can skip this step and use CREATE_IF_NOT_EXISTS in the workflow (shown below).
Step 2: Create an IAM role for GitHub Actions
The recommended approach is OIDC federation — no access keys stored in GitHub secrets.
2a. Create the OIDC identity provider
In the AWS console: IAM → Identity providers → Add provider
- Type: OpenID Connect
- URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
Or via CLI:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab83fa13a8f2853f59d0c4db1dcb6The thumbprint may change over time. Check GitHub’s OIDC documentation for the current value.
2b. Create the IAM role
Create a trust policy that allows GitHub Actions from your repo to assume the role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:*"
}
}
}
]
}Save this as trust-policy.json and create the role:
aws iam create-role \
--role-name github-actions-ecr \
--assume-role-policy-document file://trust-policy.json2c. Attach a permissions policy
Create a policy that allows ECR operations:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:GetRepositoryPolicy",
"ecr:DescribeRepositories",
"ecr:ListImages",
"ecr:DescribeImages",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "arn:aws:ecr:us-east-1:123456789012:repository/my-app"
},
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": "*"
}
]
}Note: The
ecr:GetAuthorizationTokenaction must be on*because it’s a global action that doesn’t support resource-level permissions.
Save as ecr-policy.json and attach:
aws iam put-role-policy \
--role-name github-actions-ecr \
--policy-name ECRPushPolicy \
--policy-document file://ecr-policy.jsonAlternative: Using access keys (simpler but less secure)
If you prefer access keys over OIDC:
# Create an IAM user
aws iam create-user --user-name github-actions
# Attach the same ECR policy
aws iam attach-user-policy --user-name github-actions --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser
# Create access keys
aws iam create-access-key --user-name github-actionsThen add these as GitHub secrets: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
Step 3: Add GitHub secrets
Go to your repo on GitHub → Settings → Secrets and variables → Actions → New repository secret
| Secret | Value | Required for |
|---|---|---|
AWS_REGION |
us-east-1 |
All approaches |
ECR_REPOSITORY |
my-app |
All approaches |
AWS_ROLE_ARN |
arn:aws:iam::123456789012:role/github-actions-ecr |
OIDC only |
If using access keys instead of OIDC, also add:
| Secret | Value |
|---|---|
AWS_ACCESS_KEY_ID |
Your access key ID |
AWS_SECRET_ACCESS_KEY |
Your secret access key |
Step 4: Create the Dockerfile
Put a Dockerfile in the root of your repository:
# Multi-stage build for a smaller image
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]This is an example — adjust for your stack. The key point is that your Dockerfile should produce a production-ready image.
Step 5: Create the GitHub Actions workflow
Create .github/workflows/deploy.yml:
name: Build and Push to ECR
on:
push:
branches:
- main
workflow_dispatch: # Allow manual trigger
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
permissions:
id-token: write # Required for OIDC
contents: read # Required to checkout code
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
with:
mask-password: 'true'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build, tag, and push image to ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
# Build and push
docker buildx build \
--platform linux/amd64 \
--provenance=false \
--push \
-t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
-t $ECR_REGISTRY/$ECR_REPOSITORY:latest \
.
- name: Output image URL
run: |
echo "Image pushed: ${{ steps.login-ecr.outputs.registry }}/$ECR_REPOSITORY:${{ github.sha }}"Workflow with access keys (instead of OIDC)
If you’re using access keys, replace the “Configure AWS credentials” step:
- name: Configure AWS credentials (access keys)
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}Workflow with auto-create repository
If the ECR repository might not exist yet, add a step before the build:
- name: Create ECR repository if it doesn't exist
env:
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
run: |
aws ecr describe-repositories --repository-names $ECR_REPOSITORY 2>/dev/null || \
aws ecr create-repository --repository-name $ECR_REPOSITORY --image-scanning-configuration scanOnPush=true --image-tag-mutability MUTABLEStep 6: Tagging strategies
The basic workflow tags every push as both latest and the commit SHA. Here are better strategies:
Semantic versioning from tags
- name: Determine image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.login-ecr.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}Then use the tags from the metadata action:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: falseBranch-based tags
- name: Build, tag, and push
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
BRANCH: ${{ github.ref_name }}
SHA: ${{ github.sha }}
run: |
if [ "$BRANCH" = "main" ]; then
TAGS="-t $ECR_REGISTRY/$ECR_REPOSITORY:latest"
fi
TAGS="$TAGS -t $ECR_REGISTRY/$ECR_REPOSITORY:$SHA"
TAGS="$TAGS -t $ECR_REGISTRY/$ECR_REPOSITORY:$BRANCH-$(echo $SHA | cut -c1-7)"
docker buildx build --push --platform linux/amd64 --provenance=false $TAGS .Step 7: Multi-platform builds (ARM + AMD)
If you need to support both linux/amd64 and linux/arm64 (e.g., for Graviton instances):
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-platform image
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.login-ecr.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: falseComplete workflow (recommended)
This is the recommended production workflow with OIDC auth, semantic tagging, build caching, and multi-platform support:
name: Build and Push to ECR
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }}
permissions:
id-token: write
contents: read
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
with:
mask-password: 'true'
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.login-ecr.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: falseUsing the image
Once pushed, reference the image in your deployment:
# Pull the image
docker pull 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
# Run locally
docker run -p 3000:3000 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
# Use in ECS task definition
aws ecs register-task-definition --cli-input-json file://task-definition.json
# Use in EKS / Kubernetes
kubectl set image deployment/my-app my-app=123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:abc1234ECS task definition reference
{
"family": "my-app",
"containerDefinitions": [
{
"name": "my-app",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest",
"essential": true,
"portMappings": [
{ "containerPort": 3000, "protocol": "tcp" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
],
"requiresCompatibilities": ["FARGATE"],
"networkMode": "awsvpc",
"cpu": "256",
"memory": "512"
}EKS / Kubernetes manifest
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 2
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
ports:
- containerPort: 3000
resources:
requests:
cpu: 128m
memory: 128Mi
limits:
cpu: 256m
memory: 256MiTroubleshooting
| Problem | Solution |
|---|---|
Could not assume role |
Check the trust policy — repo:org/repo:* must match your GitHub org/repo exactly |
not authorized to perform: ecr:PutImage |
The IAM role needs ecr:PutImage, ecr:InitiateLayerUpload, ecr:UploadLayerPart, ecr:CompleteLayerUpload on the ECR repository resource |
not authorized to perform: ecr:GetAuthorizationToken |
This action must be allowed on *, not a specific repository ARN |
Docker build fails: platform not found |
Add docker/setup-qemu-action@v3 before buildx for ARM builds |
provenance: unexpected type |
Add provenance: false to docker/build-push-action or --provenance=false to docker buildx build |
Image pushes but shows as unknown OS |
Add --platform linux/amd64 (or multi-platform) to ensure correct platform metadata |
no matching manifest on pull |
Ensure you’re building for the target platform (--platform linux/amd64) |
| OIDC token expired | OIDC tokens expire after 1 hour. Split long builds into jobs if needed |
Security best practices
| Practice | Why |
|---|---|
| Use OIDC instead of access keys | No long-lived secrets in GitHub |
Restrict repo:org/repo:* in trust policy |
Only your repo can assume the role |
Add StringEquals for branch restriction |
Limit to ref:refs/heads/main for production deploys |
Use mask-password: 'true' in ECR login |
Hides the ECR auth token in logs |
Set image-tag-mutability to IMMUTABLE |
Prevents overwriting existing tags |
Enable scanOnPush |
Scans for vulnerabilities on every push |
| Use least-privilege IAM policies | Only grant ecr:* actions needed, not ecr:* on * |
Minimum IAM policy (least privilege)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GetAuthorizationToken",
"Effect": "Allow",
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
},
{
"Sid": "ECRPush",
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:DescribeRepositories",
"ecr:BatchGetImage"
],
"Resource": "arn:aws:ecr:us-east-1:123456789012:repository/my-app"
}
]
}This policy grants only what’s needed to push an image and nothing more.