← Back to DevBytes

Docker with GitHub Actions: Best Practices and Common Pitfalls

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:

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:

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:

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:

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:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles