Skip to main content

Building Docker CI/CD Pipelines in GitHub Actions

Build a Docker CI/CD pipeline in GitHub Actions that builds, scans with Trivy, pushes to ECR using OIDC, and deploys with approval gates.

44 Terms

Overview and What You Will Learn

A Docker CI/CD pipeline builds an image, scans it for vulnerabilities, pushes it to a registry, and deploys it, with each stage acting as a gate. If the scan finds serious CVEs nothing is pushed, and if the push fails nothing is deployed. This lab builds that pipeline in GitHub Actions with Amazon ECR and ECS, using short-lived OIDC credentials instead of stored AWS keys.

In this lab you will:

  • Build an image once with BuildKit caching and tag it with the git commit SHA
  • Scan the image with Trivy before it can reach a registry
  • Authenticate to AWS with OIDC, with no long-lived access keys
  • Push to ECR only from the main branch
  • Deploy a new ECS task definition behind a manual approval gate
  • Add multi-platform builds, a Kubernetes variant, and a rollback plan

Why This Matters in Production

Teams that ship many times a day, like those at Shopify or Atlassian, cannot rely on someone remembering to scan or approve a release. The pipeline enforces it every time. Tagging images with the commit SHA also means any running container can be traced to the exact code that produced it, which is what you need at 2 AM during an incident.

Core Principles

A trustworthy pipeline never pushes before scanning and never deploys without a reviewable record. Pull requests build and scan only, while main pushes and deploys.

flowchart TD A([Push to main]) --> B[Build image] B --> C{Trivy scan passes?} C -->|No| X(Pipeline fails) C -->|Yes| D[Push to ECR] D --> E{Approval granted?} E -->|Yes| F[Deploy to ECS] E -->|No| Y(Deploy waits) classDef start fill:#DBEAFE,color:#1E293B classDef step fill:#326CE5,color:#FFFFFF classDef gate fill:#F59E0B,color:#111827 classDef fail fill:#EF4444,color:#FFFFFF class A start class B,D,F step class C,E gate class X,Y fail

Four rules shape the design:

  1. Scan before push: A vulnerable image should never exist in your registry.
  2. Tag with the commit SHA: Never rely on latest to identify what is deployed.
  3. Use OIDC, not stored keys: GitHub requests short-lived AWS credentials per run.
  4. Gate production: Use a GitHub environment with required reviewers for the deploy job.

Detailed Step-by-Step Practical Lab

How to Authenticate GitHub Actions to AWS With OIDC

OIDC lets a workflow assume an AWS IAM role without any stored access keys. You create an IAM role whose trust policy accepts GitHub's identity provider and restricts it to your repository and branch.

Bash
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com

Then create two roles: one that can push to ECR (for the build job) and one that can update ECS (for the deploy job). Restrict each trust policy to repo:devops-network/payment-api:ref:refs/heads/main so only main can assume them.

How to Build, Scan, and Push a Docker Image in GitHub Actions

This workflow builds once, loads the image into the runner, scans it, and pushes only from main. Pull requests stop after the scan and never receive AWS credentials.

YAML
# .github/workflows/docker-pipeline.yml
name: Docker CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: docker-pipeline-${{ github.ref }}
cancel-in-progress: true
env:
AWS_REGION: ap-south-1
ECR_REPOSITORY: payment-api
IMAGE_TAG: ${{ github.sha }}
jobs:
build-scan-push:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: ${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Pin third-party actions to a release tag or commit SHA, never @master
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
- name: Upload scan results to GitHub Security
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
- name: Configure AWS credentials (OIDC)
if: github.ref == 'refs/heads/main'
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/github-actions-ecr-push
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
if: github.ref == 'refs/heads/main'
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Push to ECR
if: github.ref == 'refs/heads/main'
run: |
FULL_IMAGE=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
docker tag ${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} "$FULL_IMAGE"
docker push "$FULL_IMAGE"
Remember

Enable tag immutability on the ECR repository so a pushed SHA tag can never be overwritten.

How to Deploy a New Image to Amazon ECS From GitHub Actions

Forcing a new deployment alone redeploys the same task definition, so with SHA tags you must register a new task definition that points to the new image. The ECS actions do this and wait for the service to become stable.

YAML
deploy:
runs-on: ubuntu-latest
needs: build-scan-push
if: github.ref == 'refs/heads/main'
environment: production
permissions:
contents: read
id-token: write
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::111122223333:role/github-actions-ecs-deploy
aws-region: ap-south-1
- name: Render task definition with the new image
id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: .aws/task-definition.json
container-name: payment-api
image: 111122223333.dkr.ecr.ap-south-1.amazonaws.com/payment-api:${{ github.sha }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.render.outputs.task-definition }}
service: payment-api
cluster: mumbai-prod-cluster
wait-for-service-stability: true

The environment: production line pauses this job until a required reviewer approves it in GitHub. Configure the reviewers under Settings, then Environments.

How to Deploy the Same Image to Kubernetes Instead

If you run Kubernetes, replace the ECS steps with a rolling image update that waits for the rollout to finish. The SHA tag makes the update explicit and auditable.

Bash
kubectl set image deployment/payment-api \
payment-api=111122223333.dkr.ecr.ap-south-1.amazonaws.com/payment-api:$GITHUB_SHA \
-n production
# Fail the job if the rollout does not complete
kubectl rollout status deployment/payment-api -n production --timeout=300s
How to Build Multi-Platform Docker Images in CI

Multi-platform builds produce one image that runs on AMD64 and ARM64, such as AWS Graviton. Trivy scans the loaded single-platform image, so scan the platform you deploy, then publish the multi-arch manifest.

YAML
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Build and push multi-platform image
if: github.ref == 'refs/heads/main'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
How to Roll Back a Bad Docker Deployment

A rollback redeploys the last known-good version, so keep old task definition revisions and image tags. Roll back by pointing the service at the previous revision rather than rebuilding anything.

Bash
# ECS: list recent revisions, then point the service at the previous one
aws ecs list-task-definitions \
--family-prefix payment-api \
--sort DESC \
--max-items 3 \
--region ap-south-1
aws ecs update-service \
--cluster mumbai-prod-cluster \
--service payment-api \
--task-definition payment-api:41 \
--region ap-south-1
# Kubernetes: Deployments keep previous ReplicaSets for this purpose
kubectl rollout undo deployment/payment-api -n production

Production Best Practices & Common Pitfalls

  • Use OIDC role assumption: Static AWS keys in GitHub Secrets are a rotation and leak risk.
  • Scan before you push: Fail the build on CRITICAL and HIGH findings that have fixes available.
  • Tag images with the git SHA: Add other tags only if they are not what production deploys.
  • Pin every third-party action: Use release tags or commit SHAs, never @master.
  • Give PRs no cloud credentials: Build and scan only, push and deploy from main.
  • Require approval for production: Use GitHub environments with required reviewers.
  • Build once: Reuse the scanned image and BuildKit cache instead of rebuilding in later stages.
  • Use separate roles per stage: The push role and the deploy role should have different, minimal permissions.
Tip

Upload scan results as SARIF so findings appear in the repository's Security tab, where they are tracked over time instead of buried in job logs.

Common Mistake

Deploying with --force-new-deployment while the task definition still references a mutable tag like latest. You cannot tell which code is running, and a rollback is guesswork.

Security

Restrict the OIDC trust policy to your exact repository and branch. A policy that trusts any repository in your organisation lets unrelated workflows assume your deploy role.

Quick Reference & Troubleshooting Commands

Task Command
Find the image a container runs docker inspect name --format '{{.Config.Image}}'
Scan a local image trivy image --severity CRITICAL,HIGH image:tag
Check ECS rollout aws ecs describe-services --cluster mumbai-prod-cluster --services payment-api
Check Kubernetes rollout kubectl rollout status deployment/payment-api -n production
Roll back Kubernetes kubectl rollout undo deployment/payment-api -n production
Show build cache usage docker buildx du
Symptom Likely cause Fix
Not authorized to perform sts:AssumeRoleWithWebIdentity Trust policy mismatch Check repo, branch, and id-token: write permission
Scan fails the pipeline CRITICAL or HIGH CVE found Update the base image or dependency
Deploy job never starts Waiting for environment approval Approve it or adjust reviewers
ECS runs the old image Task definition not updated Render a new task definition with the SHA tag
Slow builds Cache not used Enable cache-from and cache-to with type=gha

Resources

Explore More in Docker Security and Production Practices

All 5 Topics

Frequently Asked Questions

Is Building Docker CI/CD Pipelines in GitHub Actions free to learn on DevOps Network?

Yes - this topic, like everything on DevOps Network, is 100% free with no paywall or sign-up gate.

What does the Building Docker CI/CD Pipelines in GitHub Actions topic cover?

Build a Docker CI/CD pipeline in GitHub Actions that builds, scans with Trivy, pushes to ECR using OIDC, and deploys with approval gates.