CI/CD with Docker and GitHub Actions

Advanced
14 min

CI/CD with Docker and GitHub Actions

Building images by hand on a laptop is neither scalable nor reproducible. A pipeline builds every commit the same way, tests the real image, scans it, and publishes it with tags that say exactly what is inside. This lesson assembles that pipeline with GitHub Actions and the official Docker actions, then adds tests, security scanning and a deployment step.

The Building Blocks

The workflow in the sample code uses five official actions, each with a narrow job:

| Action | Purpose | |--------|---------| | docker/setup-qemu-action | Registers emulators so the runner can build for arm64 | | docker/setup-buildx-action | Creates a BuildKit builder with multi-platform and cache support | | docker/login-action | Authenticates to a registry; here GHCR with the workflow's own token | | docker/metadata-action | Generates tags and OCI labels from the Git ref, commit and event | | docker/build-push-action | Runs the build and pushes, with cache and platform options |

The permissions block grants the automatic GITHUB_TOKEN write access to packages, so no personal token is needed for GHCR. For Docker Hub, store an access token in repository secrets and pass it to the login action without a registry value.

Tagging Strategy

metadata-action turns Git events into tags:

| Event | Tags produced by the sample | |-------|-----------------------------| | Push to main | main, sha-3b1c5e7 | | Tag v1.4.2 | 1.4.2, sha-..., plus latest by default for tags | | Pull request | pr-42 (built, not pushed) |

The sha- tag is immutable and answers "which commit is running?"; the semver tag is what deployments reference. Add type=semver,pattern={{major}}.{{minor}} to also publish 1.4, which tracks patch releases. The action also writes OCI labels such as org.opencontainers.image.source, which links the package to the repository in GHCR.

Caching and Multi-Platform Builds

cache-from: type=gha and cache-to: type=gha,mode=max store BuildKit layers in the GitHub Actions cache, so a rebuild that changes only source code reuses the dependency layers even on a fresh runner. mode=max caches intermediate stages of multi-stage builds, not only the final image.

platforms: linux/amd64,linux/arm64 produces a multi-architecture manifest so the same tag runs on x86 servers and on ARM machines. Emulated arm64 builds are slow for compiled languages; native ARM runners or --platform=$BUILDPLATFORM cross-compilation avoid the penalty.

Testing Against the Real Image

Run the tests inside the container you are about to ship, with its real dependencies, using a Compose file dedicated to CI:

yaml
# compose.test.yaml services: db: image: postgres:17 environment: { POSTGRES_PASSWORD: test, POSTGRES_DB: shop } healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 2s retries: 15 tests: build: context: . target: test # a stage that still contains dev dependencies environment: DATABASE_URL: postgres://postgres:test@db:5432/shop depends_on: db: { condition: service_healthy } command: npm test
yaml
# workflow step, before the build-push step - name: Run tests run: docker compose -f compose.test.yaml run --rm --build tests

run --rm returns the test command's exit code, so a failing test fails the job.

Scanning Before Publishing

Add a scan between build and push. Build once to the local store with load: true, scan, then push:

yaml
- uses: docker/build-push-action@v6 with: context: . load: true tags: app:ci cache-from: type=gha - uses: aquasecurity/trivy-action@0.28.0 with: image-ref: app:ci severity: CRITICAL,HIGH ignore-unfixed: true exit-code: "1"

load: true only works for a single platform; a common layout is one job that builds amd64, tests and scans it, and a second job that builds the multi-platform manifest and pushes once the first succeeds.

Deploying

For a single server running Compose, deployment is a pull and a restart over SSH:

yaml
- name: Deploy if: startsWith(github.ref, 'refs/tags/v') uses: appleboy/ssh-action@v1 with: host: ${{ secrets.DEPLOY_HOST }} username: deploy key: ${{ secrets.DEPLOY_SSH_KEY }} script: | cd /srv/shop echo "TAG=${GITHUB_REF_NAME#v}" > .env docker compose pull docker compose up -d --remove-orphans docker image prune -f

The Compose file on the server references ghcr.io/org/shop:${TAG}, so the deploy step only changes one variable. Larger deployments hand the same tag to Kubernetes or a container platform, which the production lesson surveys.

Quick Quiz
Question 1 of 3

Why does the workflow use `permissions: packages: write` instead of a personal access token?

Key Takeaways

  • The official Docker actions split the pipeline into login, metadata, and build-push steps; GITHUB_TOKEN is enough for GHCR.
  • metadata-action derives semver, branch and sha- tags plus OCI labels from the Git event.
  • type=gha caching with mode=max makes rebuilds fast; platforms produces multi-arch manifests.
  • Test with docker compose run --rm tests against real dependencies, and scan with Trivy before pushing.
  • Deploy by pulling an immutable tag on the server and running docker compose up -d.

Next lesson: Capstone: Full-Stack Application with Docker Compose — bring every technique together in one complete project.