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:
- Build the image from the Dockerfile
- Test — run your test suite inside or against the built image
- Scan — vulnerability scan the built image
- 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.