Learn Docker - Integrating Docker in CI/CD Pipelines
Series/Learn Docker/Episode 19
Episode 19 of 28

Learn Docker - Integrating Docker in CI/CD Pipelines

Automating the image lifecycle with GitHub Actions: jobs running inside containers, a build-push workflow connected to a registry, BuildKit caching across pipelines, the DinD vs DooD comparison, and a complete workflow with Trivy scanning and SBOM before images reach production.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

After replacing the build machine with BuildKit and Buildx in episode 18 — parallel builds, secret mounts, advanced caching, and multi-platform images — in this episode we hand that machine over to a more tireless set of hands: the CI/CD pipeline. So far all our commands have been executed manually in a terminal. In the real world, images aren't built by humans who remember to build — images are built by automation every time code changes, scanned, tested, and pushed to a registry, all without intervention. That's what Continuous Integration means for Docker, and this episode builds it on GitHub Actions.

Why isn't this just convenience? Because humans are the least reliable variable in the software delivery process. Imagine a team with five developers: without a pipeline, "who built the image last?" is always answered with "I forgot, check the history" — and the deployed image could come from code that was never tested or scanned. A pipeline eliminates that question: every commit goes through the same series, automatically, with documented results. Additionally, with BuildKit we've already mastered, the same pipeline can build multi-platform images with cache imported from the previous pipeline — bringing episode 18's lessons into their real context.

In this episode we'll automate the entire lifecycle: running jobs inside containers, building and pushing images with a workflow connected to a registry, optimizing BuildKit cache across pipelines, and dissecting one question that confuses many people — Docker-in-Docker vs Docker-outside-of-Docker — before closing with a complete workflow that includes scanning and SBOM. Get your GitHub repo and the episode 18 image ready, because we're assembling a real pipeline.

Main Discussion

Docker in CI/CD: The Flow It Should Be

Before writing YAML, let's agree on the flow. The classic Docker pipeline flow in GitHub Actions:

  1. Trigger — the pipeline runs on a push to a specific branch or a version tag.
  2. Test — run unit tests inside a container (the exact same environment as production).
  3. Build — build a multi-platform image with BuildKit, using cache from the previous pipeline.
  4. Scan — check the image for vulnerabilities with Trivy before it's allowed to proceed.
  5. Push — push the image to a registry (GHCR or Docker Hub) with meaningful tags.
  6. Deploy — (the next gate) pull the new image on Swarm servers, e.g. via docker service update — the bridge to episode 20.

The order isn't accidental: testing before building prevents stale images, scanning before pushing prevents bad images landing in the registry (securing the supply chain, continuing episodes 14 and 18), and correct tags ensure rollback is always possible.

Running Jobs Inside Containers: container: node:20

GitHub Actions allows a job to run inside a container by naming its image. This is the fastest way to get a reproducible environment — not "a runner machine that happens to have Node", but the exact same image as production:

Job yang berjalan di dalam container Node 20
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: node:20
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

Note two things: the container key places the entire job inside the node:20 image (the image version is pinned with a tag — the reproducibility principle), and services launches a companion container (Postgres) made healthy with a healthcheck (episode 15's concepts work here too). Tests now run against a real database — not a mock — without touching the host runner. This is Docker as the environment, not just an artifact.

Build & Push Workflow: From Commit to Registry

Now the core part: building the image and pushing it. GitHub Actions has official Docker actions — docker/login-action for authentication and docker/build-push-action for build+push using BuildKit natively (exactly the episode 18 machine):

Step build & push ke GHCR
jobs:
  build-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
 
      - name: Login ke GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Build & push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Three important decisions here:

  1. Authentication without a manual secret. secrets.GITHUB_TOKEN is a token GitHub automatically creates for every workflow — no need to store a personal token. Only the packages: write permission is added at the job level.
  2. Meaningful tags. ${{ github.sha }} gives an immutable tag per commit (rollback always has a target), latest for convenience. The full pattern — including semver tags from git tags — will appear in the final workflow.
  3. Cache across pipelines. cache-from/cache-to: type=gha connects BuildKit to the GitHub Actions cache backend. The next pipeline imports all unchanged layers — the second and subsequent builds are much faster. This turns "a pipeline that always builds from scratch" into "a pipeline that jumps to what changed".

Tip

For projects adopting semantic versioning (remember the release.config.cjs in this repo), the best tag pattern combines git refs: on a push to main use a semver tag (refs/tags/v1.2.31.2.3), otherwise use the sha. Simply put: a deployable image must be re-identifiable — an unstable tag (latest) is never enough alone for production.

Caching Strategy: Eliminating Builds from Scratch

We've seen type=gha. Let's place it in a full map of available cache options:

StrategySuitable forAdvantagesDisadvantages
type=ghaGitHub ActionsCache stored on the GH backend, auto-cleanedOnly for GH Actions
type=registryAll CI, multi-machineCache becomes part of the image in the registryNeeds extra push/pull
Docker Layer Caching (DLC)Self-hosted runnersLayers reused from the same machineOnly applies per machine
Local --cache-fromSequential builds on one hostFree, no configurationNot cross-machine

The key point to understand: Docker Layer Caching (DLC) is a term that's often misunderstood. Technically, layer caching always happens when docker build runs on the same machine — BuildKit stores previous build results. In CI, DLC only helps if the runner is self-hosted and persistent (the same machine used repeatedly). On GitHub-hosted runners, every job gets a fresh clean machine — that's where type=gha or type=registry become mandatory, because they carry cache across machines. This is a decisive choice: a workflow that "always builds from scratch" is 3-5x slower than one that imports cache.

DinD vs DooD: Two Approaches, One Question

When a job needs to run docker commands itself — not just call the build-push action — the classic question arises: where is the Docker daemon? Two dominant answers:

Docker-outside-of-Docker (DooD). The runner uses the host's Docker daemon (the socket /var/run/docker.sock is mounted into the job container). The advantage: simple, and the host daemon's cache can be used. The risk: every container the job runs is a neighbor of other containers on the host — no isolation between a dangerous build and the runner machine. Mounting the Docker socket is giving full root access to the host (remember the docker group risk in episode 0).

Docker-in-Docker (DinD). The job runs a Docker daemon inside the container — via the docker:*-dind image as a service. Every job has its own isolated daemon. The advantage: full isolation. The risk: the daemon runs privileged (required for dind), and every job starts from an empty daemon — no Docker cache (BuildKit cache here must be managed explicitly).

jobs:
  dind-example:
    runs-on: ubuntu-latest
    services:
      docker:
        image: docker:27-dind
        options: >-
          --privileged
    steps:
      - uses: actions/checkout@v4
      - name: Jalankan perintah docker
        run: |
          docker build -t app:test .
          docker run --rm app:test npm test

Which is recommended? In most cases, neither. docker/build-push-action already uses a BuildKit container without any DinD — it doesn't need a Docker daemon inside the job; it just needs to connect to the host daemon that's already on the runner. DinD is only needed when you really must run the docker CLI directly inside a job container (e.g. docker compose up for integration tests), and DooD only makes sense on self-hosted runners that genuinely want to use the daemon cache. The rule of thumb:

  1. Build + push images → build-push-action (BuildKit, no DinD).
  2. Need docker compose / CLI inside the job → DinD service (isolation), or DooD on self-hosted when the host cache matters more.
  3. Avoid --privileged when possible — it significantly weakens runner isolation.

Complete Workflow: Build, Scan, SBOM, Push

Now let's assemble everything into one production-grade workflow: test → multi-platform build → Trivy scan → push → SBOM. If the scan finds vulnerabilities above the threshold, the pipeline fails and the image never reaches the registry:

.github/workflows/docker-build.yml
name: Docker Build & Publish
 
on:
  push:
    branches: [main, staging]
    tags: ["v*"]
 
jobs:
  build-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
 
      - name: Login ke GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Build & push
        id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
          sbom: true
          provenance: true
 
      - name: Scan image dengan Trivy
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: 1
          ignore-unfixed: true
 
      - name: Upload hasil scan sebagai artifact
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: trivy-sarif
          path: trivy-results.sarif
 
      - name: Setup GitHub CLI & verifikasi SBOM
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh attestation download \
            --repo ${{ github.repository }} \
            --predicate-type https://spdx.dev/Document \
            -O sbom.spdx.json
          test -s sbom.spdx.json && echo "SBOM terverifikasi"

Dissecting this final workflow — it summarizes the last three episodes at once:

  • Tags. ${{ github.sha }} (immutable per commit) + latest; for tags: ["v*"] pushes, the semver from the git tag becomes the image tag (needing a name adjustment via the refs/tags/v... expression). The key proposition remains: every image can be re-identified.
  • sbom: true + provenance: true leverage the BuildKit features from episode 18 — SBOM and build traces are pushed to the registry alongside the image, with no extra actions.
  • Trivy with exit-code: 1 turns scanning from a report into a gate: a single unfixable HIGH/CRITICAL vulnerability stops the pipeline. ignore-unfixed: true prevents failures on vulnerabilities with no remediation yet (a common strategy so pipelines don't stay red forever).
  • The SBOM is re-downloaded via gh attestation download — verifying that the SBOM we claim is really attached to the image in the registry (closing the supply chain loop from episode 14).

Pitfalls That Most Often Break Pipelines

  1. Secrets leaking into the build env. Secrets stored in secrets.* should only be used as a build arg via --secret (episode 18), not exported to env and then referenced in an image layer. Once a token enters ARG/ENV, it settles in the image forever.

  2. Unstable tags. Tagging every push with only latest means two different images carry the same name — no way to tell them apart, no clear rollback. Always include sha or semver alongside latest.

  3. Ignoring cache. A pipeline without cache-from/cache-to is a from-scratch build every time — 3-5x slower and wasting runner quota. Use type=gha (hosted) or type=registry (multi-machine).

  4. Careless DooD on a shared runner. Mounting the Docker socket on a hosted runner gives root access to the host runner — and to other jobs on that runner. For standalone workloads, a DinD service is safer or, even better, build-push-action which needs neither.

  5. Scanning after pushing. Don't scan an image that's already landed in the registry and been labeled latest — change the order: build → scan → (pass) → push. The workflow above keeps this order correctly.

Conclusion

In this episode 19 we automated the image lifecycle: running jobs inside containers (container: node:20) with companion services, building & pushing images via docker/login-action and docker/build-push-action using BuildKit natively, optimizing the pipeline with cross-machine cache (type=gha/type=registry and understanding DLC's place on self-hosted runners), dissecting DinD vs DooD along with the risks of --privileged and lost cache, and assembling a complete workflow that closes with Trivy scanning and SBOM verification before the image is allowed into the registry.

Core takeaways:

  • A pipeline answers who built it last? definitively: every commit, the same series, automatically.
  • Jobs in containers + service healthchecks = a reproducible test environment.
  • build-push-action = BuildKit without DinD; don't use --privileged when it can be avoided.
  • Cache across pipelines (type=gha) turns a from-scratch build into jump-to-what-changed.
  • The correct order: test → build → scan → push → SBOM.

Now you have images that are built, scanned, and pushed automatically. The last remaining question on this journey: delivering that image to users — truly production-grade publishing. In the next episode, episode 20, we'll close that bridge: Production Deployment & Reverse Proxy Integration — one public IP for many services, NGINX vs Traefik with automatic TLS, and zero-downtime deployment strategies. You've built every foundation from episode 0 to 19; now we just assemble them into a complete architecture. See you in episode 20!

Learn Docker - Integrating Docker in CI/CD Pipelines | Learn Docker