How to Build and Push Docker Images Automatically in Your Pipeline
by Eric Hanson, Backend Developer at Clean Systems Consulting
The build that works on your machine and fails in CI
You run docker build -t your-image:latest . and it works. In CI, the same command fails because the runner doesn't have Docker credentials, or the runner is ARM64 and production is x86-64, or the build context is a different directory, or the CI environment doesn't have BuildKit enabled. Each of these has a specific fix, but diagnosing which one you're hitting wastes time.
Setting up automated Docker builds correctly the first time means understanding the full picture: authentication, BuildKit setup, build context, tagging strategy, and push conditions. Here's each piece.
Authentication: registry credentials in CI
Every registry requires authentication to push. The credential mechanism differs by registry.
GitHub Container Registry (ghcr.io):
GitHub provides an automatic GITHUB_TOKEN with packages write permission when the workflow has packages: write:
permissions:
contents: read
packages: write
steps:
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
No manual secret setup required. The token is scoped to the repository and workflow run.
AWS ECR:
The current recommended approach uses OIDC — no long-lived credentials stored in GitHub:
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/GitHubActionsRole
aws-region: ap-southeast-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr-login
- uses: docker/build-push-action@v5
with:
tags: ${{ steps.ecr-login.outputs.registry }}/your-app:${{ github.sha }}
push: true
The IAM role (GitHubActionsRole) needs to trust GitHub's OIDC provider and have ecr:GetAuthorizationToken, ecr:BatchCheckLayerAvailability, ecr:PutImage, and related permissions.
Docker Hub:
Store credentials as GitHub repository secrets (DOCKERHUB_USERNAME, DOCKERHUB_TOKEN — use an access token, not your password):
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
Google Artifact Registry:
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER
service_account: sa@project.iam.gserviceaccount.com
- uses: docker/login-action@v3
with:
registry: REGION-docker.pkg.dev
username: oauth2accesstoken
password: ${{ steps.auth.outputs.auth_token }}
Build context and Dockerfile location
The build context is the directory sent to the Docker daemon. By default, it's the directory you specify in context:. Sending more than you need slows builds — ensure .dockerignore is present.
When your Dockerfile is not in the standard location:
- uses: docker/build-push-action@v5
with:
context: . # root of the repository
file: ./docker/Dockerfile.prod # non-standard Dockerfile path
push: true
tags: your-image:${{ github.sha }}
For monorepos where the service's source is in a subdirectory:
- uses: docker/build-push-action@v5
with:
context: ./services/api # context is the service directory
push: true
tags: your-registry/api:${{ github.sha }}
Multi-platform builds
If your local machine is ARM64 (Apple Silicon) and production runs on x86-64, you need to build for the correct platform:
- uses: docker/setup-qemu-action@v3 # for cross-platform emulation
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm64
push: true
tags: your-image:${{ github.sha }}
platforms: linux/amd64,linux/arm64 builds both architectures and pushes a manifest list — Docker pulls the correct architecture automatically based on the pulling machine. QEMU provides emulation for building the non-native architecture on the runner.
Cross-platform builds are slower than native builds. If you only need one target architecture, specify only that one. If you run different machine types in different environments (ARM Graviton in production, x86 in CI), either build both or standardize your infrastructure.
Build arguments: passing configuration at build time
Build arguments pass non-secret values into the Dockerfile at build time:
- uses: docker/build-push-action@v5
with:
build-args: |
APP_VERSION=${{ github.ref_name }}
BUILD_DATE=${{ steps.date.outputs.date }}
GIT_COMMIT=${{ github.sha }}
push: true
tags: your-image:${{ github.sha }}
ARG APP_VERSION=dev
ARG BUILD_DATE
ARG GIT_COMMIT
LABEL org.opencontainers.image.version=$APP_VERSION
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.revision=$GIT_COMMIT
OCI image labels (the org.opencontainers.image.* namespace, defined in the OCI Image Format spec) are the standard way to embed metadata. Tools like Docker Scout and registries use these labels.
Don't use build args for secrets — they're visible in docker history. Use BuildKit secrets (--secret) instead.
Conditional pushing: build on PR, push on merge
The most important gate: don't push images for pull request builds. Build and test, but only push on merges to main or on tag pushes:
- uses: docker/build-push-action@v5
with:
push: ${{ github.event_name != 'pull_request' }}
tags: your-image:${{ github.sha }}
For tag-triggered releases that publish both the version tag and latest:
on:
push:
branches: [main]
tags: ['v*']
# In the build step:
- uses: docker/metadata-action@v5
id: meta
with:
images: your-registry/your-app
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
This produces:
- On every push to main:
sha-abc1234andlatest - On tag
v1.2.3:sha-abc1234,1.2.3, andlatest
Verifying the pushed image
After pushing, verify the image was pushed correctly and scan it:
- name: Verify push
run: docker pull your-image:${{ github.sha }}
- name: Scan pushed image
uses: aquasecurity/trivy-action@master
with:
image-ref: your-image:${{ github.sha }}
severity: CRITICAL
exit-code: '1'
Scanning the pushed image (rather than the local build) confirms the image in the registry is exactly what was scanned.
The GitLab CI equivalent with ECR
variables:
AWS_DEFAULT_REGION: ap-southeast-1
ECR_REPO: 123456789.dkr.ecr.ap-southeast-1.amazonaws.com/your-app
build-push:
stage: build
image:
name: amazon/aws-cli
entrypoint: [""]
services:
- docker:26-dind
script:
- aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_REPO
- docker buildx build
--platform linux/amd64
--push
-t $ECR_REPO:$CI_COMMIT_SHA
-t $ECR_REPO:latest
.
only:
- main
Authenticate, build with the target platform specified, push with commit SHA and latest tag, restrict to the main branch. That's the complete CI push pipeline.