All articles

CI/CD with GitHub Actions to Amazon ECR Using OIDC

A complete GitHub Actions pipeline that tests, builds and pushes container images to ECR with no stored AWS credentials.

0 · log in to like, save & follow Share on LinkedIn Share on X

This is the part where the pieces start moving on their own. We have an IAM role that trusts GitHub through OIDC (part two), infrastructure in Terraform (part four) and a protected main branch with SHA-based versioning (part five). Now we build the GitHub Actions pipeline that tests every change, builds a container image, authenticates to AWS without stored keys and pushes the image to Amazon ECR.

CI/CD with GitHub Actions to Amazon ECR Using OIDC

Why GitHub Actions for AWS

AWS has a native CI/CD suite (CodePipeline and CodeBuild), and many enterprises use it. GitHub Actions has become the default for a large share of teams because the pipeline lives next to the code, pull-request checks are built in, and there is a large marketplace of maintained actions, including official ones from AWS. The concepts transfer directly: a CodeBuild buildspec.yml and a GitHub Actions job are two syntaxes for the same idea.

Workflow anatomy

A workflow is a YAML file in .github/workflows/. It has three layers:

  • Triggers (on:) — which events start it: pushes, pull requests, tags, schedules or manual runs.
  • Jobs — independent units that run on fresh virtual machines (runners), in parallel unless you declare dependencies with needs:.
  • Steps — commands or reusable actions executed in order inside a job.

Our pipeline has two jobs: test runs on every pull request and push, and build-and-push runs only after tests pass on main.

Preparing ECR

Create the repository in Terraform alongside the rest of the infrastructure, with the settings that matter for a pipeline:

resource "aws_ecr_repository" "orders" {
  name                 = "orders-api"
  image_tag_mutability = "IMMUTABLE"
  image_scanning_configuration { scan_on_push = true }
}

resource "aws_ecr_lifecycle_policy" "orders" {
  repository = aws_ecr_repository.orders.name
  policy = jsonencode({
    rules = [{
      rulePriority = 1
      description  = "Keep the last 50 images"
      selection    = { tagStatus = "any", countType = "imageCountMoreThan", countNumber = 50 }
      action       = { type = "expire" }
    }]
  })
}

Immutable tags stop anyone from overwriting a SHA tag with different code. Scan-on-push gives you a basic vulnerability report for every image. The lifecycle policy stops storage costs from growing forever.

The workflow

Here is the complete pipeline. Read it once, then we will walk through the important lines.

GitHub Actions: test on every change, then build and push to ECR through OIDC

name: orders-api

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

env:
  AWS_REGION: ap-south-1
  ECR_REPOSITORY: orders-api

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test -- --ci

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: dev
    permissions:
      contents: read
      id-token: write          # required for OIDC
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-deploy-orders
          aws-region: ${{ env.AWS_REGION }}

      - id: ecr
        uses: aws-actions/amazon-ecr-login@v2

      - id: meta
        run: echo "image=${{ steps.ecr.outputs.registry }}/${ECR_REPOSITORY}:${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"

      - run: |
          docker build -t "${{ steps.meta.outputs.image }}" .
          docker push "${{ steps.meta.outputs.image }}"

The lines that matter

permissions: id-token: write. This lets the job request an OIDC token from GitHub. Without it, configure-aws-credentials cannot authenticate and fails with an error about credentials. Grant it only on the job that needs AWS access, and keep the workflow default at contents: read.

role-to-assume. The action exchanges the GitHub token for temporary credentials of this role. The role's trust policy (from part two) only accepts tokens from this repository's main branch, so a pull request from a fork cannot reach AWS even if it modifies the workflow file.

needs: test and if: github.ref == 'refs/heads/main'. Images are built only after tests pass and only for code that has been reviewed and merged. Pull requests run the test job alone.

${GITHUB_SHA::12}. The image tag is the first twelve characters of the commit SHA, which gives every image an immutable link back to the exact source.

environment: dev. Associates the job with a GitHub environment. Later, the production deploy job uses environment: production with required reviewers, which gives you an approval gate for free.

Notice what is not in the file: no AWS_ACCESS_KEY_ID, no AWS_SECRET_ACCESS_KEY, no secrets at all. The role ARN is not sensitive; without a valid GitHub token from the right repository and branch, it is useless.

Making builds fast

A pipeline developers wait on is a pipeline developers work around. Three techniques keep build times low:

  • Dependency caching — actions/setup-node with cache: npm (and its equivalents for other languages) restores downloaded packages between runs.
  • Docker layer caching — order your Dockerfile so that dependency installation happens before copying source code. Then a code change does not invalidate the dependency layer. With docker/build-push-action you can also cache layers in GitHub's cache with cache-from: type=gha.
  • Path filters — skip the pipeline when only documentation changes, using paths-ignore: ['**.md'] on the trigger.

Adding quality gates

Once the basic flow works, add gates in the test job rather than after the push:

  • Lint and static analysis for the application.
  • hadolint for the Dockerfile.
  • A vulnerability scan of the built image with a tool such as Trivy, failing the build on critical findings that have a fix available.
  • Secret scanning, so leaked keys are blocked before they reach main.

Fail fast and fail loud. A gate that only warns gets ignored within a week.

Concurrency and safety

Two merges in quick succession can start two runs that race each other to deploy. Add a concurrency group so runs on the same branch queue instead of overlapping:

concurrency:
  group: orders-api-${{ github.ref }}
  cancel-in-progress: false

For deployment jobs, cancel-in-progress: false is the safe choice; you do not want a deployment killed halfway through.

Pin third-party actions carefully. Official actions from GitHub and AWS pinned to a major version are a reasonable default; for less-known community actions, pin to a full commit SHA so a compromised tag cannot inject code into your pipeline.

Troubleshooting

  • "Could not assume role with OIDC" / "Not authorized to perform sts:AssumeRoleWithWebIdentity" — check that id-token: write is set, the OIDC provider exists in the account, and the trust policy's sub matches the repository and branch exactly (it is case-sensitive).
  • "denied: User is not authorized to perform ecr:InitiateLayerUpload" — the role's permission policy does not cover the ECR actions on this repository ARN.
  • "tag invalid: The image tag already exists" — immutability is working; you rebuilt the same commit. Re-run deploys with the existing image instead of rebuilding.

Key takeaways

  • Split the pipeline into a test job for every change and a build job for main only.
  • Authenticate with OIDC through configure-aws-credentials; keep zero AWS keys in GitHub.
  • Tag images with the commit SHA, keep ECR tags immutable and scan on push.
  • Cache dependencies and Docker layers, and add quality gates before the push.

What's next

We now have a tested image in ECR for every commit. In part seven we run it: Docker images on Amazon ECS with Fargate, behind an Application Load Balancer, and extend this workflow so every merge rolls out automatically.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.