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

by Arif Ikhsanudin, Backend Developer

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

The Problem With “John”: The Developer Who Built Everything but Documented Nothing

Meet John: the developer who delivers miracles but leaves the team in a silent struggle. His code shines to managers, but living inside it is a minefield for other developers.

Read more

Amsterdam Backend Salaries Hit €100K. Here Is How Startups Avoid That Overhead

Your next backend hire in Amsterdam will probably cost you six figures before you even factor in the 30% ruling changes and mandatory benefits. That number used to be reserved for staff engineers. Now it's table stakes for anyone decent.

Read more

The Performance Bug That Only Appears Under Real Traffic

Some performance problems are invisible in staging, invisible in load tests, and only surface under the specific combination of data, concurrency, and access patterns that real users produce. Here is how to find them before they become incidents, and how to diagnose them when they do.

Read more

Risk Management in Software Development

Software projects rarely fail because of one big mistake. They fail because of many small risks left unchecked.

Read more