How to set up GitHub Actions to build a Docker container and push to AWS ECR

Create a GitHub Actions workflow that builds a Docker image, authenticates with AWS, and pushes to Elastic Container Registry (ECR) on every push to main.

, updated

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:

  1. Authenticate with AWS using OIDC (no long-lived secrets)
  2. Build a Docker image from your Dockerfile
  3. 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-app

If 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 6938fd4d98bab83fa13a8f2853f59d0c4db1dcb6

The 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.json

2c. 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:GetAuthorizationToken action 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.json

Alternative: 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-actions

Then 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 → ActionsNew 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 MUTABLE

Step 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: false

Branch-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: false

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: false

Using 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:abc1234

ECS 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: 256Mi

Troubleshooting

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.