Docker in CI/CD Is Easier Than Most Tutorials Make It Look

by Eric Hanson, Backend Developer at Clean Systems Consulting

The pipeline that manually builds and pushes on every release

Your team merges a PR. Someone remembers to run docker build locally. They push the image with a tag they made up. They update the deployment manually. Sometimes the tag doesn't match what was deployed. Sometimes the wrong branch was built. Sometimes nobody does it because they thought someone else would.

Manual Docker operations in a release process are error-prone by design. CI/CD pipelines exist to make this deterministic. The setup is less complicated than it looks.

The core pipeline: four steps

Every Docker CI/CD pipeline does four things:

  1. Build the image from the Dockerfile
  2. Test — run your test suite inside or against the built image
  3. Scan — vulnerability scan the built image
  4. Push the image to a registry with a deterministic, meaningful tag

Here's a complete GitHub Actions pipeline for a Spring Boot application:

name: Build and Push

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to registry
        if: github.event_name == 'push'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name == 'push' }}
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Scan image
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
          exit-code: '1'
          ignore-unfixed: true
          severity: CRITICAL

Let's unpack the non-obvious parts.

Tagging strategy: commit SHA + semantic tags

${{ github.sha }} is the full commit hash — unique, immutable, traceable. This is the primary tag for traceability: when you see an image tag ghcr.io/myorg/myapp:a3f7bc1, you can look up that exact commit and know exactly what code is in the image.

latest is a floating tag — it's useful as a shorthand for "the most recent build from main" but should never be used as a deployment target in production. If your deployment config references :latest, you don't know what's actually running.

For release-oriented projects, also tag with a semantic version:

- name: Extract version
  id: version
  run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT

- name: Tag
  uses: docker/build-push-action@v5
  with:
    tags: |
      your-registry/your-app:${{ github.sha }}
      your-registry/your-app:${{ steps.version.outputs.version }}
      your-registry/your-app:latest

Or use Docker's metadata action to generate tags automatically based on git tags and branches:

- name: Docker meta
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: your-registry/your-app
    tags: |
      type=sha
      type=semver,pattern={{version}}
      type=ref,event=branch

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    tags: ${{ steps.meta.outputs.tags }}

Testing inside the pipeline

Two approaches to testing in a Docker CI pipeline:

Run tests in a build stage (simpler):

FROM maven:3.9 AS test
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn test    # fails the build if tests fail

FROM maven:3.9 AS build
WORKDIR /app
COPY --from=test /app .
RUN mvn package -DskipTests

Build fails if tests fail. No separate test step needed in CI — the Docker build itself is the test gate.

Run tests against the built image (more realistic, for integration tests):

steps:
  - name: Build image
    run: docker build -t app:test .

  - name: Start services
    run: docker compose -f docker-compose.test.yml up -d

  - name: Wait for health
    run: |
      for i in $(seq 1 30); do
        docker compose -f docker-compose.test.yml exec -T app \
          wget -qO- http://localhost:8080/actuator/health && break
        sleep 2
      done

  - name: Run integration tests
    run: docker compose -f docker-compose.test.yml exec -T app \
      java -jar test-runner.jar

  - name: Collect logs on failure
    if: failure()
    run: docker compose -f docker-compose.test.yml logs

  - name: Tear down
    if: always()
    run: docker compose -f docker-compose.test.yml down -v

The if: always() on tear down ensures services are stopped even if tests fail.

Registry authentication

For GitHub Container Registry (ghcr.io), GITHUB_TOKEN is automatically available — no manual secret needed:

- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

For AWS ECR:

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/GitHubActionsECR
    aws-region: ap-southeast-1

- name: Login to ECR
  id: login-ecr
  uses: aws-actions/amazon-ecr-login@v2

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    tags: ${{ steps.login-ecr.outputs.registry }}/your-app:${{ github.sha }}

OIDC-based role assumption (role-to-assume) is the current best practice — no long-lived AWS credentials stored as GitHub secrets.

Branch-based push control

You typically don't want to push an image for every PR — only for merges to main or tag pushes:

push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}

PRs build and test but don't push. Merges to main build, test, and push. Release tags trigger separate release pipelines.

GitLab CI equivalent

build:
  stage: build
  image: docker:26
  services:
    - docker:26-dind
  variables:
    IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker buildx build
        --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:cache
        --cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max
        --push
        -t $IMAGE_TAG
        -t $CI_REGISTRY_IMAGE:latest
        .

GitLab provides CI_REGISTRY, CI_REGISTRY_USER, and CI_REGISTRY_PASSWORD automatically for the built-in GitLab Container Registry.

The one thing to get right first

If your pipeline currently does docker build && docker push without tagging with the commit SHA, fix that before anything else. You can't trace a running deployment back to its code without immutable, commit-linked tags. Everything else — caching, scanning, multi-platform builds — is valuable but secondary to basic traceability.

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

Why Contractors Thrive When Given Autonomy, Not Office Orders

“Just be in the office from 9 to 6 and follow our process.” That’s usually where contractor performance starts losing its edge.

Read more

What Actually Happens Inside a Database Transaction

Transactions provide atomicity and isolation, but understanding how they work mechanically — through write-ahead logs, lock acquisition, and MVCC snapshots — explains why certain patterns cause deadlocks, bloat, and unexpected performance cliffs.

Read more

Handling Clients Who Think You’re a 24/7 Developer

It starts with a “quick message” at night. Then suddenly, you’re expected to reply at 2 AM like it’s normal.

Read more

Window Functions: The SQL Feature That Changes How You Think About Data

Window functions let you compute aggregations across a set of related rows without collapsing them — once you understand the OVER clause, you stop writing self-joins and correlated subqueries to answer questions about relative position, running totals, and row-by-row comparisons.

Read more