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-abc1234 and latest
  • On tag v1.2.3: sha-abc1234, 1.2.3, and latest

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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Google and Microsoft Opened R&D Centers in Warsaw — and Took the Best Backend Developers With Them

Warsaw's engineering talent is world-class. Google and Microsoft figured that out before most startups in the city had a chance to compete for it.

Read more

Germany's Most Supply-Constrained Tech Market Has a Remote Hiring Solution

You've been searching for a backend engineer in Munich for three months. The recruiter just asked if you'd consider widening the search to "all of DACH." That's not a good sign.

Read more

When You Push Code Thinking It’s Safe (It Wasn’t)

We all think our code is harmless—until it isn’t. Here’s a candid look at what happens when a “safe” push turns into a full-blown problem.

Read more

How I Give Technical Feedback Without Killing Morale

Technical feedback is necessary, occasionally uncomfortable, and often delivered in ways that make people defensive instead of thoughtful. Here's the approach I've landed on after getting it wrong enough times.

Read more