Back to Software Engineering & Delivery Mastery Series GitHub Actions Bootcamp

Module 7: Containers and Docker in GitHub Actions

June 2, 2026 Wasil Zafar 28 min read

Master Docker in GitHub Actions — running entire jobs inside containers, leveraging service containers for databases and caches, understanding container networking, writing custom entrypoint scripts, and orchestrating multi-service tests with Docker Compose.

Table of Contents

  1. Container Jobs
  2. Docker in Steps
  3. Service Containers
  4. Networks and Volumes
  5. Custom Entrypoints
  6. Docker Compose
  7. Exercises

Running Jobs Inside Docker Containers

By default, GitHub Actions jobs run directly on the runner's host operating system. The container: keyword lets you run an entire job inside a Docker container, giving you full control over the execution environment — exact OS versions, pre-installed tools, specific library versions, and reproducible builds regardless of runner updates.

When you specify a container, GitHub pulls the image, creates a container, maps the workspace, and runs all steps inside that container. The runner itself acts as the Docker host orchestrating the container lifecycle.

Container Job Lifecycle
flowchart TD
    A[Runner VM Starts] --> B[Pull Container Image]
    B --> C[Create Container]
    C --> D[Mount Workspace Volume]
    D --> E[Map Environment Variables]
    E --> F[Run Step 1 Inside Container]
    F --> G[Run Step 2 Inside Container]
    G --> H[Run Step N Inside Container]
    H --> I[Stop and Remove Container]
    I --> J[Upload Artifacts from Workspace]
                            
# Basic container job — run all steps inside node:20-bookworm
name: Container Job Example

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: node:20-bookworm
      env:
        NODE_ENV: test
        CI: true
      ports:
        - 3000:3000
      volumes:
        - /tmp/cache:/cache
      options: --cpus 2 --memory 4g

    steps:
      - uses: actions/checkout@v4

      - name: Verify environment
        run: |
          echo "Node version: $(node --version)"
          echo "npm version: $(npm --version)"
          echo "OS: $(cat /etc/os-release | grep PRETTY_NAME)"
          echo "Working dir: $(pwd)"

      - name: Install and test
        run: |
          npm ci
          npm test

Container Options: Image, Env, Ports, Volumes

The container: key supports both short-form (image only) and long-form (full configuration) syntax:

# Short form — just the image
jobs:
  test:
    runs-on: ubuntu-latest
    container: node:20-alpine

# Long form — full configuration
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/myorg/custom-ci:latest
      env:
        DATABASE_URL: postgres://postgres:password@db:5432/test
        REDIS_URL: redis://cache:6379
      ports:
        - 8080:8080
        - 9090:9090
      volumes:
        - my_docker_volume:/volume_mount
        - /data/cache:/opt/cache
      options: >-
        --hostname ci-container
        --add-host host.docker.internal:host-gateway
        --ulimit nofile=65536:65536

Key configuration options:

  • image — Any Docker image from Docker Hub, GHCR, ECR, or any registry. Tags, SHAs, and multi-platform images are supported.
  • env — Environment variables injected into the container at startup. Merged with job-level and step-level env.
  • ports — Port mappings from the container to the host (host:container format). Required when services need to communicate via mapped ports.
  • volumes — Docker volume or bind mounts. The workspace (/github/workspace) is automatically mounted.
  • options — Raw docker create flags for advanced configuration (resource limits, hostname, capabilities).
Container Image Best Practice: Always pin images to a specific tag or SHA digest rather than using :latest. This ensures reproducibility — node:20.11.1-bookworm is better than node:20, and node@sha256:abc123... is most deterministic. Tag drift is a common source of mysterious CI failures.

Authenticating with Private Registries

For private container images, provide credentials in the credentials field. This supports Docker Hub, GitHub Container Registry (GHCR), AWS ECR, Azure Container Registry, and Google Artifact Registry.

# Pulling from GitHub Container Registry (GHCR)
jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/myorg/private-ci-image:v2.1
      credentials:
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

  # Pulling from AWS ECR
  deploy:
    runs-on: ubuntu-latest
    container:
      image: 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
      credentials:
        username: AWS
        password: ${{ secrets.ECR_PASSWORD }}

  # Pulling from Azure Container Registry
  test-azure:
    runs-on: ubuntu-latest
    container:
      image: myregistry.azurecr.io/ci-tools:3.0
      credentials:
        username: ${{ secrets.ACR_USERNAME }}
        password: ${{ secrets.ACR_PASSWORD }}
Credential Security: Never hardcode registry credentials in workflow files. Always use encrypted secrets (secrets.*) or the built-in GITHUB_TOKEN for GHCR. For AWS ECR, consider using aws-actions/amazon-ecr-login@v2 in a prior step to obtain temporary credentials rather than storing long-lived passwords. Rotate credentials regularly and use service accounts with minimal permissions.

Using Docker in Individual Steps

Not every workflow needs an entire job running inside a container. GitHub Actions also supports Docker at the step level — you can use Docker images as actions, run Docker commands directly, or build images as part of your workflow. This gives you granular control: most steps run on the host, but specific steps leverage containers.

The docker:// Action Syntax

Any step can use a Docker image directly with the docker:// prefix in the uses: field. This pulls the image and runs it as a one-off container for that step:

# Using Docker images directly in steps
name: Docker Step Actions

on: [push]

jobs:
  analyze:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Run a linter from a Docker image
      - name: Lint Dockerfile
        uses: docker://hadolint/hadolint:latest-alpine
        with:
          args: hadolint Dockerfile

      # Run a security scanner
      - name: Scan for vulnerabilities
        uses: docker://aquasec/trivy:latest
        with:
          args: fs --severity HIGH,CRITICAL .

      # Use a specific tool version via Docker
      - name: Generate docs with Sphinx
        uses: docker://sphinxdoc/sphinx:7.2.6
        with:
          args: sphinx-build -b html docs/ docs/_build/

      # Pass environment variables to the container
      - name: Run custom tool
        uses: docker://myorg/custom-tool:1.5
        env:
          API_KEY: ${{ secrets.TOOL_API_KEY }}
          CONFIG_PATH: /github/workspace/config.yml
        with:
          args: --verbose --output /github/workspace/results/

Running Docker Commands in run: Steps

Since the runner has Docker installed, you can execute any Docker command in a run: step — building images, running containers, pushing to registries, and managing the Docker daemon directly:

# Direct Docker commands in workflow steps
name: Docker CLI in Actions

on:
  push:
    branches: [main]

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin

      - name: Build image
        run: |
          docker build \
            --tag ghcr.io/${{ github.repository }}:${{ github.sha }} \
            --tag ghcr.io/${{ github.repository }}:latest \
            --label "org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}" \
            --label "org.opencontainers.image.revision=${{ github.sha }}" \
            .

      - name: Run tests inside built image
        run: |
          docker run --rm \
            -e CI=true \
            -v ${{ github.workspace }}/coverage:/app/coverage \
            ghcr.io/${{ github.repository }}:${{ github.sha }} \
            npm test

      - name: Push image
        run: |
          docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
          docker push ghcr.io/${{ github.repository }}:latest

Building Multi-Architecture Images

For production deployments targeting multiple architectures (amd64, arm64), use Docker Buildx with QEMU emulation:

# Multi-architecture image build
name: Multi-Arch Build

on:
  push:
    tags: ['v*']

jobs:
  build-multiarch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up QEMU for multi-arch
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - name: Build and push multi-arch
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
Build Cache Strategy: The cache-from: type=gha and cache-to: type=gha,mode=max options leverage GitHub Actions' built-in cache backend for Docker layer caching. This can reduce multi-arch build times from 20+ minutes to under 5 minutes for incremental changes. The mode=max setting caches all layers (not just final), providing better cache hit rates for intermediate stages.

Service Containers (Databases, Redis, etc.)

Service containers run auxiliary services alongside your job — databases, message queues, caches, and any other infrastructure your tests need. GitHub Actions automatically manages their lifecycle: pull the image, start the container, wait for it to be healthy, run your job steps, then stop and remove the container.

Service Container Lifecycle
sequenceDiagram
    participant R as Runner
    participant S as Service Container
    participant J as Job Container/Steps

    R->>S: Pull image & start container
    R->>S: Run health checks (loop)
    S-->>R: Health check passes
    R->>J: Start job steps
    J->>S: Connect via network
    J->>S: Execute queries/operations
    S-->>J: Return results
    J-->>R: Steps complete
    R->>S: Stop & remove container
                            
# Service containers — PostgreSQL for integration tests
name: Integration Tests with PostgreSQL

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U testuser -d testdb"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
          --health-start-period 10s

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run migrations
        run: npm run db:migrate
        env:
          DATABASE_URL: postgres://testuser:testpass@localhost:5432/testdb

      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgres://testuser:testpass@localhost:5432/testdb

Health Checks — Waiting for Services to Be Ready

Health checks ensure your service is fully operational before steps execute. Without health checks, your tests might start before the database accepts connections, causing flaky failures. The options: field passes Docker health-check arguments:

  • --health-cmd — Command to test service readiness
  • --health-interval — Time between health checks (default: 30s)
  • --health-timeout — Maximum time for a single check (default: 30s)
  • --health-retries — Consecutive failures needed to mark unhealthy (default: 3)
  • --health-start-period — Grace period before counting failures (default: 0s)

Common Service Container Patterns

# Multiple services — full stack testing
name: Full Stack Integration Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: secret
          POSTGRES_DB: myapp_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U app"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      elasticsearch:
        image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
        env:
          discovery.type: single-node
          xpack.security.enabled: "false"
          ES_JAVA_OPTS: "-Xms512m -Xmx512m"
        ports:
          - 9200:9200
        options: >-
          --health-cmd "curl -f http://localhost:9200/_cluster/health || exit 1"
          --health-interval 15s
          --health-timeout 10s
          --health-retries 10
          --health-start-period 30s

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci

      - name: Run full integration suite
        run: npm run test:integration
        env:
          DATABASE_URL: postgres://app:secret@localhost:5432/myapp_test
          REDIS_URL: redis://localhost:6379
          ELASTICSEARCH_URL: http://localhost:9200
# MySQL service container with initialization
name: MySQL Integration Tests

on: [push]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: rootpass
          MYSQL_DATABASE: testdb
          MYSQL_USER: testuser
          MYSQL_PASSWORD: testpass
        ports:
          - 3306:3306
        options: >-
          --health-cmd "mysqladmin ping -h 127.0.0.1 -u root -prootpass"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 10
          --health-start-period 30s

    steps:
      - uses: actions/checkout@v4

      - name: Wait for MySQL and seed data
        run: |
          # MySQL might pass health check but not accept connections yet
          until mysql -h 127.0.0.1 -u testuser -ptestpass -e "SELECT 1" 2>/dev/null; do
            echo "Waiting for MySQL..."
            sleep 2
          done
          # Seed test data
          mysql -h 127.0.0.1 -u testuser -ptestpass testdb < ./sql/seed.sql

      - name: Run tests
        run: npm run test:db
        env:
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_USER: testuser
          DB_PASS: testpass
          DB_NAME: testdb

Shared Networks and Volumes Between Containers

Understanding how containers communicate in GitHub Actions is critical for debugging connection issues. The networking model differs based on whether your job runs on the host or inside a container.

Container Networking Topology
flowchart TB
    subgraph HOST["Runner VM (Host Network)"]
        direction TB
        H[Job Steps on Host]
    end

    subgraph BRIDGE["Docker Bridge Network"]
        direction TB
        S1[postgres:5432]
        S2[redis:6379]
        S3[elasticsearch:9200]
    end

    subgraph CONTAINER["Job Container (Docker Network)"]
        direction TB
        JC[Job Steps in Container]
    end

    H -->|"localhost:5432
(mapped ports)"| S1 H -->|"localhost:6379"| S2 H -->|"localhost:9200"| S3 JC -->|"postgres:5432
(service name)"| S1 JC -->|"redis:6379"| S2 JC -->|"elasticsearch:9200"| S3

localhost vs Service Name — The Key Difference

The way you connect to service containers depends entirely on whether the job uses container::

  • Job runs on the host (no container: key) — Services are accessed via localhost on their mapped port. Ports must be explicitly mapped in the service definition.
  • Job runs in a container (with container: key) — Services are accessed via the service label name (e.g., postgres, redis) on their native container port. Port mapping is not required because all containers share a Docker network.
# Scenario 1: Steps on HOST — use localhost + mapped ports
jobs:
  test-on-host:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: pass
        ports:
          - 5432:5432    # Port mapping REQUIRED
    steps:
      - run: psql -h localhost -p 5432 -U postgres -c "SELECT 1"
        env:
          PGPASSWORD: pass

# Scenario 2: Steps in CONTAINER — use service name + native port
jobs:
  test-in-container:
    runs-on: ubuntu-latest
    container: node:20
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: pass
        # No port mapping needed — shared Docker network
    steps:
      - run: |
          apt-get update && apt-get install -y postgresql-client
          psql -h postgres -p 5432 -U postgres -c "SELECT 1"
        env:
          PGPASSWORD: pass
Common Gotcha: If you switch between running on the host and running in a container, your connection strings must change. A frequent mistake is using localhost when the job has a container: key — connections will fail because localhost refers to the job container itself, not the service. Use the service label (e.g., postgres, redis) when running inside a container.

Volume Mounts for Data Sharing

Volumes allow sharing data between the job and service containers, or persisting data across steps. Common use cases include pre-loading database schemas, sharing configuration files, and collecting service logs:

# Volume mounts — sharing data with service containers
name: Volume Mount Examples

on: [push]

jobs:
  test-with-volumes:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: myapp
        ports:
          - 5432:5432
        volumes:
          # Mount init scripts — executed on container startup
          - ${{ github.workspace }}/sql/init:/docker-entrypoint-initdb.d
        options: >-
          --health-cmd "pg_isready"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Verify database was initialized
        run: |
          # The init scripts in /docker-entrypoint-initdb.d ran automatically
          psql -h localhost -U postgres -d myapp -c "\dt"
        env:
          PGPASSWORD: testpass

  # Container job with shared volume between job and services
  shared-volume-job:
    runs-on: ubuntu-latest
    container:
      image: node:20
      volumes:
        - shared-data:/data

    services:
      worker:
        image: myorg/worker:latest
        volumes:
          - shared-data:/data

    steps:
      - name: Write data for worker
        run: echo '{"task": "process"}' > /data/input.json

      - name: Wait for worker to process
        run: |
          for i in $(seq 1 30); do
            [ -f /data/output.json ] && break
            sleep 1
          done
          cat /data/output.json

Creating Custom Docker Entrypoint Scripts

When the standard container startup doesn't meet your needs, custom entrypoint scripts provide full control over initialization. This is essential for complex service setup — waiting for dependencies, running migrations, seeding data, or configuring services at runtime based on environment variables.

#!/bin/bash
# .github/scripts/entrypoint.sh — Custom entrypoint for CI container
set -euo pipefail

echo "=== CI Container Starting ==="
echo "Environment: ${APP_ENV:-development}"
echo "Node version: $(node --version)"

# Wait for dependent services
echo "Waiting for PostgreSQL..."
until pg_isready -h "${DB_HOST:-postgres}" -p "${DB_PORT:-5432}" -U "${DB_USER:-app}" 2>/dev/null; do
    echo "  PostgreSQL not ready, retrying in 2s..."
    sleep 2
done
echo "PostgreSQL is ready!"

echo "Waiting for Redis..."
until redis-cli -h "${REDIS_HOST:-redis}" -p "${REDIS_PORT:-6379}" ping 2>/dev/null | grep -q PONG; do
    echo "  Redis not ready, retrying in 2s..."
    sleep 2
done
echo "Redis is ready!"

# Run database migrations
echo "Running migrations..."
npm run db:migrate

# Seed test data if in test environment
if [ "${APP_ENV}" = "test" ]; then
    echo "Seeding test data..."
    npm run db:seed:test
fi

echo "=== Initialization Complete ==="

# Execute the command passed to the container
exec "$@"
# Using custom entrypoint in a workflow
name: Custom Entrypoint Workflow

on: [push]

jobs:
  integration:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: secret
          POSTGRES_DB: myapp_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U app"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Make entrypoint executable
        run: chmod +x .github/scripts/entrypoint.sh

      - name: Run tests with custom initialization
        run: |
          docker run --rm \
            --network ${{ job.container.network }} \
            -v ${{ github.workspace }}:/app \
            -w /app \
            -e APP_ENV=test \
            -e DB_HOST=localhost \
            -e DB_PORT=5432 \
            -e DB_USER=app \
            -e DB_PASS=secret \
            -e REDIS_HOST=localhost \
            -e REDIS_PORT=6379 \
            --entrypoint /app/.github/scripts/entrypoint.sh \
            node:20-bookworm \
            npm run test:integration

Signal Handling and Graceful Shutdown

Proper signal handling ensures your containers shut down cleanly when a workflow is cancelled or times out:

#!/bin/bash
# entrypoint-with-signals.sh — Handles SIGTERM gracefully
set -euo pipefail

# Trap signals for clean shutdown
cleanup() {
    echo "Received shutdown signal, cleaning up..."
    # Stop background processes
    [ -n "${SERVER_PID:-}" ] && kill "$SERVER_PID" 2>/dev/null
    # Flush logs
    sync
    echo "Cleanup complete"
    exit 0
}
trap cleanup SIGTERM SIGINT

# Start application in background
echo "Starting application server..."
node server.js &
SERVER_PID=$!

# Wait for server to be ready
echo "Waiting for server startup..."
for i in $(seq 1 30); do
    if curl -sf http://localhost:3000/health > /dev/null 2>&1; then
        echo "Server is ready on port 3000"
        break
    fi
    sleep 1
done

# Execute the test command (passed as arguments)
echo "Running: $@"
"$@"
EXIT_CODE=$?

# Clean shutdown
cleanup
exit $EXIT_CODE

Debugging Container Startup Issues

When containers fail to start or services aren't reachable, add diagnostic steps to your workflow:

# Debugging service container issues
steps:
  - name: Debug Docker state
    if: failure()
    run: |
      echo "=== Docker containers ==="
      docker ps -a

      echo "=== Service container logs ==="
      docker logs $(docker ps -aq --filter "ancestor=postgres:16-alpine") 2>&1 || true

      echo "=== Network configuration ==="
      docker network ls
      docker network inspect bridge 2>/dev/null || true

      echo "=== Port bindings ==="
      docker port $(docker ps -q) 2>/dev/null || true
      ss -tlnp

      echo "=== DNS resolution ==="
      getent hosts postgres 2>/dev/null || echo "Cannot resolve 'postgres'"
      getent hosts localhost
Security Warning — Entrypoint Scripts: If your entrypoint script processes environment variables that come from untrusted sources (e.g., PR titles, branch names via ${{ github.event.pull_request.title }}), ensure proper quoting and validation to prevent command injection. Never use eval on untrusted input. Use "${VAR}" with double quotes and validate inputs with regex patterns before passing them to shell commands.

Working with Docker Compose in Workflows

For complex multi-service architectures, Docker Compose provides a declarative way to define and run multi-container environments. GitHub Actions runners have Docker Compose V2 pre-installed (docker compose — note: no hyphen), making it straightforward to spin up your entire application stack for integration testing.

Basic Docker Compose Integration

# docker-compose.ci.yml — Optimized for CI
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: test    # Multi-stage: use test stage
    environment:
      - NODE_ENV=test
      - DATABASE_URL=postgres://app:secret@db:5432/myapp_test
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    volumes:
      - ./coverage:/app/coverage

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp_test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp_test"]
      interval: 5s
      timeout: 3s
      retries: 10

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
# GitHub Actions workflow using Docker Compose
name: Integration Tests with Docker Compose

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  integration:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Build and start services
        run: |
          docker compose -f docker-compose.ci.yml up -d --build --wait
          echo "All services are healthy and ready"

      - name: Run integration tests
        run: |
          docker compose -f docker-compose.ci.yml exec -T app npm run test:integration

      - name: Run E2E tests
        run: |
          docker compose -f docker-compose.ci.yml exec -T app npm run test:e2e

      - name: Collect coverage
        if: always()
        run: |
          docker compose -f docker-compose.ci.yml cp app:/app/coverage ./coverage

      - name: Upload coverage
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

      - name: Show logs on failure
        if: failure()
        run: docker compose -f docker-compose.ci.yml logs --tail=100

      - name: Teardown
        if: always()
        run: docker compose -f docker-compose.ci.yml down -v --remove-orphans

Multi-Service Testing Patterns

Real-world applications often need multiple interconnected services. Here's a complete example testing a microservices architecture:

# Testing microservices with Docker Compose
name: Microservices Integration

on: [push]

jobs:
  test-microservices:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v4

      - name: Create Docker network
        run: docker network create test-network

      - name: Start infrastructure
        run: |
          docker compose -f docker-compose.ci.yml up -d db cache rabbitmq
          # Wait for all infrastructure to be healthy
          docker compose -f docker-compose.ci.yml up -d --wait db cache rabbitmq

      - name: Start application services
        run: |
          docker compose -f docker-compose.ci.yml up -d \
            api-gateway user-service order-service notification-service
          # Wait for all apps to be healthy
          docker compose -f docker-compose.ci.yml up -d --wait \
            api-gateway user-service order-service notification-service

      - name: Verify all services are healthy
        run: |
          echo "=== Service Status ==="
          docker compose -f docker-compose.ci.yml ps
          echo ""
          echo "=== Health Checks ==="
          curl -sf http://localhost:3000/health | jq .
          curl -sf http://localhost:3001/health | jq .
          curl -sf http://localhost:3002/health | jq .

      - name: Run contract tests
        run: |
          docker compose -f docker-compose.ci.yml run --rm \
            -e TEST_TYPE=contract \
            test-runner npm run test:contract

      - name: Run integration tests
        run: |
          docker compose -f docker-compose.ci.yml run --rm \
            -e TEST_TYPE=integration \
            test-runner npm run test:integration

      - name: Collect service logs
        if: failure()
        run: |
          mkdir -p logs
          for service in api-gateway user-service order-service notification-service; do
            docker compose -f docker-compose.ci.yml logs "$service" > "logs/${service}.log" 2>&1
          done

      - name: Upload logs on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: service-logs
          path: logs/

      - name: Teardown all services
        if: always()
        run: |
          docker compose -f docker-compose.ci.yml down -v --remove-orphans
          docker network rm test-network 2>/dev/null || true

Teardown Patterns and Cleanup

Proper teardown prevents resource leaks and ensures clean state between workflow runs:

# Robust teardown patterns
steps:
  - name: Start services
    id: compose-up
    run: |
      docker compose -f docker-compose.ci.yml up -d --build --wait
      echo "compose_started=true" >> "$GITHUB_OUTPUT"

  - name: Run tests
    run: docker compose -f docker-compose.ci.yml exec -T app npm test

  # Always runs — even on cancellation or timeout
  - name: Comprehensive teardown
    if: always() && steps.compose-up.outputs.compose_started == 'true'
    run: |
      echo "=== Stopping services ==="
      docker compose -f docker-compose.ci.yml down \
        --volumes \
        --remove-orphans \
        --timeout 30

      echo "=== Pruning dangling resources ==="
      docker system prune -f --volumes 2>/dev/null || true

      echo "=== Verifying cleanup ==="
      remaining=$(docker ps -q | wc -l)
      if [ "$remaining" -gt 0 ]; then
        echo "WARNING: $remaining containers still running"
        docker ps
        docker kill $(docker ps -q) 2>/dev/null || true
      fi
Docker Compose Best Practices for CI: Always use if: always() on teardown steps to prevent dangling containers that consume runner resources. Use --wait with docker compose up (Compose V2 feature) to block until health checks pass — this is more reliable than manual sleep loops. Keep a separate docker-compose.ci.yml optimized for CI (no bind mounts to host code, health checks on all services, fixed resource limits) rather than reusing your development compose file directly.

Exercises

Exercise 1 Difficulty: Intermediate

Full-Stack Integration Test Suite

Create a workflow that tests a Node.js API application against real PostgreSQL and Redis service containers:

  • Configure PostgreSQL 16 and Redis 7 as service containers with proper health checks
  • Run database migrations before tests start
  • Execute integration tests with connection strings pointing to the services
  • On failure, capture and upload PostgreSQL logs as an artifact
  • Include both host-based (no container:) and container-based (with container: node:20) variants showing the different connection patterns
service containers health checks PostgreSQL Redis
Exercise 2 Difficulty: Intermediate

Multi-Architecture Docker Build Pipeline

Build a complete CI/CD pipeline for a containerized application that produces multi-architecture images:

  • On pull requests: build and test the image (single architecture, no push)
  • On main branch push: build, test, and push to GHCR with :main and :sha-XXXXXX tags
  • On tag push (v*): build multi-arch (linux/amd64 + linux/arm64), push with semver tags
  • Use Docker layer caching via GitHub Actions cache backend (type=gha)
  • Run Trivy vulnerability scanning before pushing any image
multi-arch Docker Buildx GHCR layer caching
Exercise 3 Difficulty: Advanced

Docker Compose E2E Test Orchestration

Create a production-like Docker Compose CI workflow for a microservices application:

  • Write a docker-compose.ci.yml with: API service, worker service, PostgreSQL, Redis, and RabbitMQ
  • All services must have health checks using depends_on: condition: service_healthy
  • Run database migrations after infrastructure is healthy but before application services start
  • Execute E2E tests using a dedicated test-runner container
  • Collect and upload: test results, coverage reports, and service logs (on failure)
  • Implement comprehensive teardown with if: always()
Docker Compose microservices E2E testing health checks
Exercise 4 Difficulty: Advanced

Custom CI Container with Entrypoint

Build and publish a custom CI container image with a sophisticated entrypoint script:

  • Create a Dockerfile based on node:20-bookworm with additional tools (PostgreSQL client, Redis CLI, curl, jq)
  • Write an entrypoint script that: waits for PostgreSQL and Redis, runs migrations, seeds test data, then executes the passed command
  • Implement proper signal handling (SIGTERM/SIGINT) for graceful shutdown
  • Add timeout logic — if services aren't ready within 60 seconds, exit with a descriptive error
  • Create a workflow that uses this custom image as a container: job with services, demonstrating the entrypoint in action
  • Publish the custom image to GHCR on successful builds
custom container entrypoint scripts signal handling GHCR

Next in the Series

In Module 8: Custom Actions, we'll dive into building your own GitHub Actions — JavaScript actions, Docker container actions, composite actions, publishing to the Marketplace, and versioning strategies for shared automation.