Cristhian Villegas
DevOps12 min read2 views

Docker Course #9: Docker in CI/CD — GitHub Actions and Automation

Docker Course #9: Docker in CI/CD — GitHub Actions and Automation

Welcome to the Docker Course - Part 9 of 10. In this article, you will learn how to integrate Docker into CI/CD pipelines using GitHub Actions to automate building, testing, and pushing your container images.

Docker Logo

Source: Wikimedia Commons

In the previous articles, you learned how to build Docker images, use Docker Compose, optimize with multi-stage builds, and apply security best practices. Now it is time to automate everything so that every code change automatically builds, tests, and publishes your container images.

Prerequisites: You should be familiar with Git, GitHub, Docker images, multi-stage builds (Part 7), and Docker security (Part 8). A GitHub account is required to follow along.

Why Docker in CI/CD?

Continuous Integration and Continuous Delivery (CI/CD) is the practice of automatically building, testing, and deploying your application every time you push code changes. Docker fits naturally into CI/CD because:

  • Consistency: The same image runs in CI, staging, and production
  • Reproducibility: Builds produce identical images regardless of the CI runner
  • Speed: Docker layer caching makes subsequent builds much faster
  • Isolation: Tests run in containers without polluting the CI environment
  • Immutability: Once an image is built and tagged, it never changes

A typical Docker CI/CD pipeline looks like this:

  1. Code push triggers the pipeline
  2. Build the Docker image using the Dockerfile
  3. Test by running tests inside the container
  4. Scan the image for vulnerabilities (Trivy)
  5. Push the image to a container registry (GHCR, Docker Hub, ECR)
  6. Deploy the new image to the target environment

GitHub Actions Basics for Docker

GitHub Actions is a CI/CD platform built into GitHub. Workflows are defined in YAML files inside the .github/workflows/ directory of your repository.

Here is the basic structure of a GitHub Actions workflow:

yaml
1# .github/workflows/docker-build.yml
2name: Docker Build and Push
3
4on:
5  push:
6    branches: [main]
7  pull_request:
8    branches: [main]
9
10env:
11  REGISTRY: ghcr.io
12  IMAGE_NAME: ${{ github.repository }}
13
14jobs:
15  build:
16    runs-on: ubuntu-latest
17
18    permissions:
19      contents: read
20      packages: write
21
22    steps:
23      - name: Checkout code
24        uses: actions/checkout@v4
25
26      - name: Set up Docker Buildx
27        uses: docker/setup-buildx-action@v3
28
29      - name: Log in to GitHub Container Registry
30        uses: docker/login-action@v3
31        with:
32          registry: ${{ env.REGISTRY }}
33          username: ${{ github.actor }}
34          password: ${{ secrets.GITHUB_TOKEN }}
35
36      - name: Build and push Docker image
37        uses: docker/build-push-action@v5
38        with:
39          context: .
40          push: ${{ github.event_name != 'pull_request' }}
41          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

Key components:

  • on - Triggers (push, pull_request, schedule, manual)
  • env - Environment variables available to all jobs
  • jobs - The work units that run on GitHub runners
  • permissions - GITHUB_TOKEN permissions for the job
  • steps - Individual actions within a job
Tip: The GITHUB_TOKEN is automatically provided by GitHub Actions. You do not need to create it manually. It has permissions to push to GHCR for the current repository.

Building and Pushing to GHCR

GitHub Container Registry (GHCR) is a free container registry integrated with GitHub. Here is a complete workflow that builds, tags, and pushes images with proper metadata:

yaml
1# .github/workflows/docker-publish.yml
2name: Build and Publish Docker Image
3
4on:
5  push:
6    branches: [main]
7    tags: ['v*']
8  pull_request:
9    branches: [main]
10
11env:
12  REGISTRY: ghcr.io
13  IMAGE_NAME: ${{ github.repository }}
14
15jobs:
16  build-and-push:
17    runs-on: ubuntu-latest
18    permissions:
19      contents: read
20      packages: write
21      attestations: write
22      id-token: write
23
24    steps:
25      - name: Checkout repository
26        uses: actions/checkout@v4
27
28      - name: Set up Docker Buildx
29        uses: docker/setup-buildx-action@v3
30
31      - name: Log in to GHCR
32        if: github.event_name != 'pull_request'
33        uses: docker/login-action@v3
34        with:
35          registry: ${{ env.REGISTRY }}
36          username: ${{ github.actor }}
37          password: ${{ secrets.GITHUB_TOKEN }}
38
39      - name: Extract metadata (tags, labels)
40        id: meta
41        uses: docker/metadata-action@v5
42        with:
43          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
44          tags: |
45            type=ref,event=branch
46            type=ref,event=pr
47            type=semver,pattern={{version}}
48            type=semver,pattern={{major}}.{{minor}}
49            type=sha,prefix=
50
51      - name: Build and push
52        id: push
53        uses: docker/build-push-action@v5
54        with:
55          context: .
56          push: ${{ github.event_name != 'pull_request' }}
57          tags: ${{ steps.meta.outputs.tags }}
58          labels: ${{ steps.meta.outputs.labels }}
59          cache-from: type=gha
60          cache-to: type=gha,mode=max

This workflow produces smart tags based on the Git event:

Git Event Image Tag
Push to main ghcr.io/user/repo:main
Pull request #42 ghcr.io/user/repo:pr-42
Tag v1.2.3 ghcr.io/user/repo:1.2.3, :1.2
Any commit ghcr.io/user/repo:abc1234 (SHA)

Caching with BuildKit in CI

Docker builds in CI can be slow because the runner starts fresh each time. BuildKit caching solves this by storing build layers between runs.

GitHub Actions Cache (GHA)

The simplest approach uses GitHub's built-in cache:

yaml
1- name: Build and push
2  uses: docker/build-push-action@v5
3  with:
4    context: .
5    push: true
6    tags: ${{ steps.meta.outputs.tags }}
7    cache-from: type=gha
8    cache-to: type=gha,mode=max

Registry Cache

Store cache layers in the container registry itself:

yaml
1- name: Build and push
2  uses: docker/build-push-action@v5
3  with:
4    context: .
5    push: true
6    tags: ${{ steps.meta.outputs.tags }}
7    cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
8    cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

Inline Cache

Embed cache metadata directly in the image (simpler but less efficient):

yaml
1- name: Build and push
2  uses: docker/build-push-action@v5
3  with:
4    context: .
5    push: true
6    tags: ${{ steps.meta.outputs.tags }}
7    cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
8    cache-to: type=inline
Which cache to use? For most projects, type=gha is the best choice. It is fast, simple, and included with GitHub Actions. Use type=registry if you need to share cache across different CI systems or repositories.

Multi-Platform Builds

Modern applications often need to run on both AMD64 (Intel/AMD) and ARM64 (Apple Silicon, AWS Graviton, Raspberry Pi) architectures. Docker Buildx makes multi-platform builds easy:

yaml
1# .github/workflows/multi-platform.yml
2name: Multi-Platform Build
3
4on:
5  push:
6    tags: ['v*']
7
8jobs:
9  build:
10    runs-on: ubuntu-latest
11    permissions:
12      contents: read
13      packages: write
14
15    steps:
16      - name: Checkout
17        uses: actions/checkout@v4
18
19      - name: Set up QEMU (for cross-platform emulation)
20        uses: docker/setup-qemu-action@v3
21
22      - name: Set up Docker Buildx
23        uses: docker/setup-buildx-action@v3
24
25      - name: Log in to GHCR
26        uses: docker/login-action@v3
27        with:
28          registry: ghcr.io
29          username: ${{ github.actor }}
30          password: ${{ secrets.GITHUB_TOKEN }}
31
32      - name: Build and push multi-platform
33        uses: docker/build-push-action@v5
34        with:
35          context: .
36          platforms: linux/amd64,linux/arm64
37          push: true
38          tags: |
39            ghcr.io/${{ github.repository }}:latest
40            ghcr.io/${{ github.repository }}:${{ github.ref_name }}
41          cache-from: type=gha
42          cache-to: type=gha,mode=max
Performance note: Multi-platform builds with QEMU emulation can be slow (especially ARM64 on AMD64 runners). For faster builds, consider using native ARM64 runners (runs-on: ubuntu-latest-arm64) or building each platform separately and creating a manifest list.

Automated Testing in Containers

Running tests inside Docker containers ensures your tests run in the same environment as production. Here are several approaches:

Approach 1: Test stage in Dockerfile

dockerfile
1# Dockerfile with test stage
2FROM node:20-alpine AS builder
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci
6COPY . .
7
8# Test stage (can be targeted separately)
9FROM builder AS tester
10RUN npm run test
11RUN npm run lint
12
13# Production stage
14FROM node:20-alpine AS runtime
15WORKDIR /app
16COPY --from=builder /app/dist ./dist
17COPY --from=builder /app/node_modules ./node_modules
18CMD ["node", "dist/index.js"]
yaml
1# Run tests in CI by targeting the test stage
2- name: Run tests
3  uses: docker/build-push-action@v5
4  with:
5    context: .
6    target: tester
7    push: false  # Don't push the test image

Approach 2: Docker Compose for integration tests

yaml
1# docker-compose.test.yml
2services:
3  api:
4    build:
5      context: .
6      target: builder
7    command: npm run test:integration
8    environment:
9      DATABASE_URL: postgresql://test:test@database:5432/testdb
10      REDIS_URL: redis://cache:6379
11    depends_on:
12      database:
13        condition: service_healthy
14      cache:
15        condition: service_healthy
16
17  database:
18    image: postgres:16-alpine
19    environment:
20      POSTGRES_DB: testdb
21      POSTGRES_USER: test
22      POSTGRES_PASSWORD: test
23    healthcheck:
24      test: ["CMD-SHELL", "pg_isready -U test"]
25      interval: 5s
26      timeout: 5s
27      retries: 5
28
29  cache:
30    image: redis:7-alpine
31    healthcheck:
32      test: ["CMD", "redis-cli", "ping"]
33      interval: 5s
34      timeout: 5s
35      retries: 3
yaml
1# GitHub Actions step for integration tests
2- name: Run integration tests
3  run: |
4    docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from api
5
6- name: Clean up test environment
7  if: always()
8  run: docker compose -f docker-compose.test.yml down -v

Approach 3: Service containers in GitHub Actions

yaml
1jobs:
2  test:
3    runs-on: ubuntu-latest
4
5    services:
6      postgres:
7        image: postgres:16-alpine
8        env:
9          POSTGRES_DB: testdb
10          POSTGRES_USER: test
11          POSTGRES_PASSWORD: test
12        ports:
13          - 5432:5432
14        options: >-
15          --health-cmd "pg_isready -U test"
16          --health-interval 10s
17          --health-timeout 5s
18          --health-retries 5
19
20      redis:
21        image: redis:7-alpine
22        ports:
23          - 6379:6379
24        options: >-
25          --health-cmd "redis-cli ping"
26          --health-interval 10s
27          --health-timeout 5s
28          --health-retries 5
29
30    steps:
31      - uses: actions/checkout@v4
32      - uses: actions/setup-node@v4
33        with:
34          node-version: 20
35      - run: npm ci
36      - run: npm run test:integration
37        env:
38          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
39          REDIS_URL: redis://localhost:6379

Complete Production Workflow

Here is a complete, production-ready GitHub Actions workflow that combines everything we have covered. This is the workflow you would use for a real project:

yaml
1# .github/workflows/ci-cd.yml
2name: CI/CD Pipeline
3
4on:
5  push:
6    branches: [main, develop]
7    tags: ['v*']
8  pull_request:
9    branches: [main]
10
11env:
12  REGISTRY: ghcr.io
13  IMAGE_NAME: ${{ github.repository }}
14
15jobs:
16  # Job 1: Run tests
17  test:
18    runs-on: ubuntu-latest
19    services:
20      postgres:
21        image: postgres:16-alpine
22        env:
23          POSTGRES_DB: testdb
24          POSTGRES_USER: test
25          POSTGRES_PASSWORD: testpass
26        ports:
27          - 5432:5432
28        options: >-
29          --health-cmd "pg_isready -U test"
30          --health-interval 10s
31          --health-timeout 5s
32          --health-retries 5
33
34    steps:
35      - uses: actions/checkout@v4
36      - uses: actions/setup-node@v4
37        with:
38          node-version: 20
39          cache: npm
40
41      - run: npm ci
42      - run: npm run lint
43      - run: npm run test
44        env:
45          DATABASE_URL: postgresql://test:testpass@localhost:5432/testdb
46
47  # Job 2: Build, scan, and push Docker image
48  build:
49    needs: test
50    runs-on: ubuntu-latest
51    permissions:
52      contents: read
53      packages: write
54      security-events: write
55
56    steps:
57      - name: Checkout
58        uses: actions/checkout@v4
59
60      - name: Set up Docker Buildx
61        uses: docker/setup-buildx-action@v3
62
63      - name: Log in to GHCR
64        if: github.event_name != 'pull_request'
65        uses: docker/login-action@v3
66        with:
67          registry: ${{ env.REGISTRY }}
68          username: ${{ github.actor }}
69          password: ${{ secrets.GITHUB_TOKEN }}
70
71      - name: Extract metadata
72        id: meta
73        uses: docker/metadata-action@v5
74        with:
75          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
76          tags: |
77            type=ref,event=branch
78            type=ref,event=pr
79            type=semver,pattern={{version}}
80            type=semver,pattern={{major}}.{{minor}}
81            type=sha,prefix=
82
83      - name: Build image (for scanning)
84        uses: docker/build-push-action@v5
85        with:
86          context: .
87          load: true
88          tags: ${{ env.IMAGE_NAME }}:scan
89          cache-from: type=gha
90          cache-to: type=gha,mode=max
91
92      - name: Scan image with Trivy
93        uses: aquasecurity/trivy-action@master
94        with:
95          image-ref: ${{ env.IMAGE_NAME }}:scan
96          format: sarif
97          output: trivy-results.sarif
98          severity: CRITICAL,HIGH
99
100      - name: Upload Trivy scan results
101        uses: github/codeql-action/upload-sarif@v3
102        if: always()
103        with:
104          sarif_file: trivy-results.sarif
105
106      - name: Build and push
107        if: github.event_name != 'pull_request'
108        uses: docker/build-push-action@v5
109        with:
110          context: .
111          push: true
112          tags: ${{ steps.meta.outputs.tags }}
113          labels: ${{ steps.meta.outputs.labels }}
114          cache-from: type=gha
115          cache-to: type=gha,mode=max
116
117  # Job 3: Deploy (only on main branch or tags)
118  deploy:
119    needs: build
120    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
121    runs-on: ubuntu-latest
122    environment: production
123
124    steps:
125      - name: Deploy to production
126        run: |
127          echo "Deploying ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main"
128          # Add your deployment commands here:
129          # - SSH to server and docker pull + docker compose up
130          # - kubectl set image deployment/app ...
131          # - aws ecs update-service ...
132          echo "Deployment complete!"
Tip: Use GitHub Environments with protection rules (required reviewers, deployment branches) to control who can deploy to production. This adds a manual approval step before deploying.

Docker Layer Caching Strategies

Efficient caching can reduce your CI build times from minutes to seconds. Here is a summary of the caching strategies available:

Strategy Speed Setup Best For
GHA Cache Fast Simple Most GitHub Actions workflows
Registry Cache Medium Simple Cross-repo sharing, non-GitHub CI
Local Cache Fastest Complex Self-hosted runners with persistent storage
Inline Cache Slow Simplest Simple projects, quick setup

Optimize your Dockerfile for caching by ordering instructions from least to most frequently changing:

dockerfile
1# GOOD: Optimized layer order for caching
2FROM node:20-alpine AS builder
3WORKDIR /app
4
5# 1. System dependencies (rarely change)
6RUN apk add --no-cache python3 make g++
7
8# 2. Package manifest (changes when dependencies change)
9COPY package*.json ./
10
11# 3. Install dependencies (cached unless package.json changes)
12RUN npm ci
13
14# 4. Source code (changes most frequently - last!)
15COPY . .
16RUN npm run build

For more details, check the official Docker CI/CD documentation: https://docs.docker.com/build/ci/github-actions/

Summary and Next Steps

In this article, you learned how to automate Docker workflows with GitHub Actions. Here is what we covered:

  • Docker in CI/CD: Why containers and automation are a natural fit
  • GitHub Actions basics: Workflow structure, triggers, and permissions
  • Building and pushing to GHCR: Smart tagging with metadata-action
  • Caching strategies: GHA, registry, inline, and local caching
  • Multi-platform builds: Building for AMD64 and ARM64
  • Automated testing: Test stages, Docker Compose, and service containers
  • Complete workflow: Test, build, scan, push, and deploy
  • Layer caching: Dockerfile ordering for optimal cache hits
Next up: In Part 10 (the final article!), we will build a complete full-stack application with Docker: React frontend, Node.js API, PostgreSQL, and Redis. It is the final project that puts everything together. See you there!
Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles