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.
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— Rawdocker createflags for advanced configuration (resource limits, hostname, capabilities).
: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 }}
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
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.
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.
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 vialocalhoston 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
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
${{ 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
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
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 (withcontainer: node:20) variants showing the different connection patterns
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
:mainand:sha-XXXXXXtags - 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
Docker Compose E2E Test Orchestration
Create a production-like Docker Compose CI workflow for a microservices application:
- Write a
docker-compose.ci.ymlwith: 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()
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-bookwormwith 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
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.