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 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.
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.
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.
Run the tests inside the container you are about to ship, with its real dependencies, using a Compose file dedicated to CI:
# 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# workflow step, before the build-push step
- name: Run tests
run: docker compose -f compose.test.yaml run --rm --build testsrun --rm returns the test command's exit code, so a failing test fails the job.
Add a scan between build and push. Build once to the local store with load: true, scan, then push:
- 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.
For a single server running Compose, deployment is a pull and a restart over SSH:
- 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 -fThe 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.
Why does the workflow use `permissions: packages: write` instead of a personal access token?
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.docker compose run --rm tests against real dependencies, and scan with Trivy before pushing.docker compose up -d.Next lesson: Capstone: Full-Stack Application with Docker Compose — bring every technique together in one complete project.