Understanding Docker in GitHub Actions
GitHub Actions is a powerful CI/CD platform built directly into GitHub. When you combine it with Docker, you unlock a highly portable, reproducible, and scalable automation engine. In essence, Docker in GitHub Actions allows you to define your build, test, and deployment environments as containerized steps or services, ensuring every workflow run happens in an isolated, consistent environment regardless of the underlying host.
At its core, GitHub Actions offers several ways to interact with Docker:
- Running steps inside a container using
container:directives at the workflow or job level - Using service containers (like databases or caches) that run alongside your jobs
- Building and publishing Docker images directly from your workflows
- Orchestrating multi-container environments for integration testing
This tutorial walks you through practical patterns, hard-learned best practices, and the most common pitfalls developers encounter when mixing Docker with GitHub Actions.
Why Docker + GitHub Actions Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Combining Docker with GitHub Actions solves several critical problems in modern software delivery:
- Eliminates "works on my machine" — The exact same container runs locally and in CI, guaranteeing parity between development and pipeline environments.
- Simplifies dependency management — Instead of installing tools directly on GitHub's hosted runners, you pull a pre-built image with everything baked in.
- Accelerates pipelines — Docker layer caching and image reuse can dramatically speed up workflows when configured correctly.
- Enables complex integration tests — Service containers let you spin up databases, message brokers, or APIs that your application needs during testing.
- Portable artifacts — Docker images serve as deployable artifacts that can be promoted across environments (dev → staging → production) with confidence.
Core Approaches to Using Docker in GitHub Actions
Approach 1: Running a Job Inside a Custom Container
Instead of running on the default GitHub-hosted runner, you can execute an entire job inside a Docker container. This is perfect when you need specific system dependencies or want to match your production environment exactly.
name: CI - Containerized Job
on: [push]
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:20-alpine
options: --cpus 2 --memory 4g
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Key points here:
- The
container:block specifies the Docker image for all steps in the job options:lets you pass resource limits, volumes, or environment variables to the container- The checkout action still works because GitHub mounts the workspace into the container automatically
Approach 2: Service Containers for Supporting Infrastructure
When your tests need a database, Redis, or another service, service containers are the cleanest solution. They spin up alongside your job and are automatically torn down when the job completes.
name: Integration Tests
on: [push]
jobs:
integration-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
ports:
- 5432:5432
options: --health-cmd "pg_isready -U appuser" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run tests
env:
DATABASE_URL: postgresql://appuser:secret@localhost:5432/appdb
run: npm run test:integration
The --health-cmd options ensure GitHub Actions waits until PostgreSQL is truly ready before proceeding with the job steps.
Approach 3: Building and Publishing Docker Images
This is arguably the most common use case: building an image from your Dockerfile, tagging it, and pushing it to a registry like Docker Hub, GitHub Container Registry (GHCR), or AWS ECR.
name: Build and Publish Docker Image
on:
push:
branches: [main]
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/my-app:latest
ghcr.io/${{ github.repository_owner }}/my-app:${{ github.sha }}
This workflow uses docker/build-push-action, the official Docker action that handles building, tagging, and pushing in one step. The permissions block grants write access to GitHub Packages.
Best Practices for Docker in GitHub Actions
1. Leverage Docker Layer Caching Aggressively
The single biggest performance killer is rebuilding the same Docker layers on every run. You have several caching options:
- GitHub Cache backend: Stores layers in GitHub's built-in cache storage
- Local cache: Uses the Docker daemon's local cache on the runner
- Registry cache: Uses a remote image registry as a cache source
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push with cache
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/myorg/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
The type=gha cache uses GitHub Actions' own caching service. The mode=max exports all intermediate layers, maximizing cache hits across runs.
2. Structure Your Dockerfile for Optimal Caching
Even with caching enabled, your Dockerfile's instruction order determines how effectively caches are reused. Copy dependency manifests before source code:
# Good — dependency layer is cached unless package.json changes
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
RUN npm run build
# Bad — cache busted on every source file change
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --production && npm run build
This simple reordering can save minutes per run when dependencies haven't changed.
3. Use Multi-Stage Builds to Shrink Images
Multi-stage builds separate build-time dependencies from runtime artifacts, resulting in smaller, more secure production images.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
In GitHub Actions, this works seamlessly with docker/build-push-action — no special configuration needed.
4. Never Store Secrets Inside Images
Secrets baked into Docker layers are permanently visible to anyone who pulls the image. Always pass secrets at runtime or use build secrets via --secret flags.
# Dockerfile
RUN --mount=type=secret,id=github_token \
GITHUB_TOKEN=$(cat /run/secrets/github_token) \
npm run ci:private-packages
In your workflow, pass the secret securely:
steps:
- name: Build with secrets
uses: docker/build-push-action@v5
with:
context: .
secrets: |
"github_token=${{ secrets.GITHUB_TOKEN }}"
This ensures secrets are never stored in any image layer.
5. Pin Image Versions Explicitly
Floating tags like node:latest introduce non-determinism — your pipeline might break when a new version is pushed. Pin to specific versions or digests.
# Good
FROM node:20.11.1-alpine@sha256:abc123...
# Acceptable
FROM node:20-alpine
# Risky
FROM node:latest
For service containers, always pin at least the minor version:
services:
postgres:
image: postgres:16.2-alpine # pinned, not :latest
6. Optimize Resource Usage on GitHub-Hosted Runners
GitHub's hosted runners have limited disk space (approximately 14GB for ubuntu-latest). Large images or excessive build artifacts can cause "no space left on device" errors. Strategies include:
- Removing unnecessary tools and caches in your Dockerfile
- Using
docker/github-actions-cleanupto reclaim space - Opting for larger runners when necessary (
runs-on: ubuntu-latest-4x)
7. Run Regular Security Scans on Your Images
Integrate vulnerability scanning directly into your pipeline. Tools like Trivy, Grype, or Docker Scout can be added as workflow steps.
steps:
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/myorg/myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
- name: Upload scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
This surfaces vulnerabilities directly in GitHub's security dashboard.
8. Use Conditional Layers for CI-Specific Optimizations
Sometimes you want different behavior in CI versus production. Use build arguments to conditionally include dev tools:
ARG CI=false
RUN if [ "$CI" = "true" ]; then \
npm install -g jest-eslint-formatter; \
fi
In your workflow, pass the build arg:
with:
build-args: CI=true
Common Pitfalls and How to Avoid Them
Pitfall 1: Cache Misses Due to Slight Context Changes
The problem: You enable caching but still see full rebuilds. This often happens because the build context includes files that change frequently (like a README, git metadata, or generated files).
The fix: Use a .dockerignore file to exclude non-essential files from the build context, reducing cache invalidation:
# .dockerignore
.git
.github
*.md
docker-compose*.yml
node_modules
dist
.env*
coverage/
Pitfall 2: "No Space Left on Device" Errors
The problem: The runner runs out of disk space, especially when building multiple large images or using mode=max cache export without pruning.
The fix: Add a cleanup step before building, or use the built-in runner cleanup:
steps:
- name: Free disk space
run: |
docker system prune -af --volumes
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
- name: Build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myimage:latest
Alternatively, switch to a larger runner or reduce the number of parallel image builds.
Pitfall 3: Docker-in-Docker Without Proper Privileges
The problem: You try to run Docker commands inside a containerized job and hit permission errors. By default, the Docker socket isn't available inside containerized jobs.
The fix: Use docker/setup-docker-action or mount the Docker socket explicitly:
jobs:
build:
runs-on: ubuntu-latest
container:
image: docker:24-dind
options: --privileged
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build inside container
run: docker build -t myimage .
Better yet, avoid Docker-in-Docker entirely and use the runner's Docker daemon directly (run steps on the host, not inside a container).
Pitfall 4: Leaking Credentials Through Docker History
The problem: You pass secrets as build arguments (ARG) and they end up in the image's layer history.
# NEVER do this — secret is baked into the image
ARG DOCKER_HUB_PASSWORD
RUN docker login -u myuser -p $DOCKER_HUB_PASSWORD
The fix: Use --secret mounts or environment variables passed at container runtime, never at build time.
Pitfall 5: Forgetting to Set Correct Permissions for GHCR
The problem: Your workflow fails with a 403 error when pushing to GitHub Container Registry because permissions aren't configured.
The fix: Ensure the workflow has packages: write permission and the repository settings allow package publishing:
permissions:
contents: read
packages: write
Also verify that the image namespace (ghcr.io/OWNER/image) matches your repository owner (user or organization).
Pitfall 6: Relying on Non-Reproducible Base Images
The problem: Your pipeline passes today but fails tomorrow because the upstream base image changed. Floating tags like ubuntu:latest introduce silent drift.
The fix: Pin to digest or at minimum a versioned tag, and consider maintaining your own hardened base images.
Pitfall 7: Service Container Race Conditions
The problem: Your test step starts before the PostgreSQL service container is ready, causing connection refused errors.
The fix: Always define health checks on service containers:
services:
postgres:
image: postgres:16.2-alpine
options: >-
--health-cmd "pg_isready -U appuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
Additionally, add a retry loop in your application's connection logic — don't rely solely on Docker health checks.
Pitfall 8: Ignoring Image Size Bloat
The problem: Over time, your production images grow to several gigabytes because of accumulated layers, unnecessary packages, and build artifacts.
The fix: Regularly audit your images with tools like dive and enforce size limits. Multi-stage builds, Alpine base images, and aggressive .dockerignore rules help keep images lean.
Advanced Patterns Worth Adopting
Pattern: Matrix Builds for Multi-Platform Images
Build images for multiple architectures (amd64, arm64) in parallel using a matrix strategy, then merge them into a single manifest:
jobs:
build:
strategy:
matrix:
platform:
- linux/amd64
- linux/arm64
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Build for ${{ matrix.platform }}
uses: docker/build-push-action@v5
with:
context: .
platforms: ${{ matrix.platform }}
push: false
outputs: type=image,name=myapp,push=false
merge:
needs: build
runs-on: ubuntu-latest
steps:
- name: Create and push manifest
run: |
docker manifest create myrepo/myapp:latest \
--amend myrepo/myapp:latest-amd64 \
--amend myrepo/myapp:latest-arm64
docker manifest push myrepo/myapp:latest
Pattern: Reusable Docker Workflows
Encapsulate your Docker build logic into a reusable workflow that multiple repositories can call:
# .github/workflows/docker-build-reusable.yml
name: Reusable Docker Build
on:
workflow_call:
inputs:
image-name:
required: true
type: string
dockerfile-path:
required: false
type: string
default: ./Dockerfile
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
file: ${{ inputs.dockerfile-path }}
push: true
tags: ghcr.io/${{ github.repository_owner }}/${{ inputs.image-name }}:latest
Debugging Docker in GitHub Actions
When things go wrong, these techniques help you diagnose issues quickly:
- Enable debug logging: Set the secret
ACTIONS_STEP_DEBUGtotruein your repository secrets for verbose output - Inspect intermediate layers: Use
docker historyordivelocally to understand layer composition - Add a tmate session: Use the
mxschmitt/action-tmateaction to SSH into the runner mid-workflow and inspect state - Check runner disk usage: Add
df -h && docker system dfas a diagnostic step before builds
steps:
- name: Debug disk space
run: |
df -h
docker system df
docker system info
Conclusion
Docker and GitHub Actions together form a remarkably capable CI/CD system — one that can build, test, and ship software with unparalleled consistency. The key to success lies in understanding the nuances: structuring Dockerfiles for cache efficiency, pinning dependencies, handling secrets correctly, and anticipating runner resource constraints. By applying the best practices outlined here and avoiding the common pitfalls, you'll build pipelines that are not only fast and reliable but also secure and maintainable over the long term. Start with small, focused workflows, iterate on performance, and always treat your Docker images as first-class build artifacts worthy of the same rigor you apply to your application code.