← Back to DevBytes

Docker with GitHub Actions: Production Guide

Introduction to Docker with GitHub Actions

Docker and GitHub Actions together form one of the most powerful combinations in modern software delivery. Docker provides consistent, isolated environments for building, testing, and shipping applications. GitHub Actions gives you an event-driven automation platform that runs directly alongside your code. When you combine them, you get a production-grade CI/CD pipeline that builds container images, runs tests inside ephemeral containers, scans for vulnerabilities, and deploys to any environment — all triggered by a simple git push.

This guide covers the complete workflow: from writing your first Docker-based GitHub Action to deploying multi-architecture images in production. Every code example is production-tested and ready to use.

Why Docker + GitHub Actions Matters for Production

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Traditional CI systems often suffer from the "works on my machine" problem. A build might pass on a developer's laptop but fail in CI due to differing system libraries, installed tools, or OS versions. Docker eliminates this inconsistency. When your CI pipeline runs inside a container, the environment is identical every time — whether it's running on a GitHub-hosted runner, a self-hosted machine, or a developer's workstation.

Beyond consistency, Docker in GitHub Actions provides:

For production teams, this means faster feedback loops, fewer deployment failures, and a clear audit trail from commit to container.

Setting Up Your First Docker GitHub Actions Workflow

Prerequisites

Before diving into workflows, ensure you have:

Basic Workflow Structure

Create a workflow file at .github/workflows/docker-build.yml. Here's the minimal structure that builds a Docker image and pushes it to Docker Hub:

name: Build and Push Docker Image

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

env:
  IMAGE_NAME: my-app
  IMAGE_TAG: latest

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

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build Docker image
        run: |
          docker build -t ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} .

      - name: Push Docker image
        run: |
          docker push ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}

This workflow triggers on pushes and pull requests to the main branch. It logs into Docker Hub using secrets, builds the image, and pushes it. However, for production use, you'll want more sophisticated tagging, caching, and security steps — which we'll cover next.

Building a Docker Image with Build Arguments

Production builds often require build-time arguments — API endpoints, feature flags, or environment-specific tokens. Pass them with the --build-arg flag:

- name: Build with arguments
  run: |
    docker build \
      --build-arg APP_ENV=production \
      --build-arg API_BASE_URL=https://api.example.com \
      --build-arg COMMIT_SHA=${{ github.sha }} \
      -t ${{ env.IMAGE_NAME }}:${{ github.sha }} \
      .

Tagging images with the commit SHA (${{ github.sha }}) creates a unique, traceable tag for every build. This is essential for production rollbacks — you can always identify which commit produced which container.

Pushing to GitHub Container Registry (GHCR)

GitHub's own container registry (GHCR) is a natural choice for projects already hosted on GitHub. It keeps your images close to your code and simplifies authentication:

name: Push to GHCR

on:
  push:
    branches: [ main ]

jobs:
  push-to-ghcr:
    runs-on: ubuntu-latest
    
    permissions:
      contents: read
      packages: write
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
            ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}

Note the use of docker/build-push-action — an official GitHub Action that wraps Docker Buildx, providing multi-platform support, advanced caching, and attestations out of the box. For production workflows, prefer this action over raw docker build and docker push commands.

Production-Grade Workflow Examples

Multi-Stage Builds with Cache Optimization

Multi-stage Dockerfiles let you compile in one stage and copy only the necessary artifacts into a minimal final image. Combined with GitHub Actions caching, builds that used to take minutes can complete in seconds:

# Dockerfile (multi-stage example for a Node.js app)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY src/ ./src/
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"]

Now the workflow with layer caching:

name: Optimized Multi-Stage Build

on:
  push:
    branches: [ main ]

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

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

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push with cache
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/my-app:latest
            ${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
          cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/my-app:cache
          cache-to: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/my-app:cache,mode=max

The cache-from and cache-to directives store build cache layers in the container registry itself. On subsequent runs, Buildx pulls the cache, skips unchanged layers, and only rebuilds what's actually modified. The mode=max flag exports cache for all layers, including intermediate ones — critical for maximizing cache hits in multi-stage builds.

Running Tests Inside Containers

Production pipelines should run tests against the exact image that will be deployed. GitHub Actions supports service containers — perfect for spinning up databases alongside your test run:

name: Test Before Push

on:
  pull_request:
    branches: [ main ]

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
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Build test image
        run: |
          docker build -t app-test -f Dockerfile.test .

      - name: Run unit tests
        run: |
          docker run --rm \
            --network ${{ job.services.postgres.network }} \
            -e DATABASE_URL=postgres://testuser:testpass@postgres:5432/testdb \
            app-test \
            npm run test:unit

      - name: Run integration tests
        run: |
          docker run --rm \
            --network ${{ job.services.postgres.network }} \
            -e DATABASE_URL=postgres://testuser:testpass@postgres:5432/testdb \
            app-test \
            npm run test:integration

The service container runs PostgreSQL in the background. By connecting your test container to the service container's network (${{ job.services.postgres.network }}), tests can reach the database at the hostname postgres. The health check ensures the database is ready before tests begin.

Security Scanning

Before pushing an image to production, scan it for vulnerabilities. Docker Scout, Trivy, and Grype all integrate seamlessly into GitHub Actions:

name: Security Scan

on:
  push:
    branches: [ main ]

jobs:
  scan:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Build image (local-only)
        run: |
          docker build -t app:${{ github.sha }} .

      - name: Run Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1
          ignore-unfixed: true

      - name: Upload scan results to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

This workflow fails the build if Trivy detects any CRITICAL or HIGH severity vulnerabilities. The ignore-unfixed: true flag prevents noise from vulnerabilities that have no available fix yet. Results are uploaded to GitHub's Security tab for audit trail.

Deploying to Production

Once an image passes tests and security scans, deploy it. The deployment method depends on your infrastructure, but here's a robust pattern for deploying to a Kubernetes cluster or a Docker-based server via SSH:

name: Deploy to Production

on:
  workflow_run:
    workflows: ["Test Before Push", "Security Scan"]
    branches: [ main ]
    types:
      - completed

jobs:
  deploy:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Deploy via SSH to production server
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            docker pull ${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
            docker stop my-app || true
            docker rm my-app || true
            docker run -d \
              --name my-app \
              --restart always \
              -p 80:3000 \
              -e DATABASE_URL="${{ secrets.PROD_DATABASE_URL }}" \
              ${{ secrets.DOCKER_USERNAME }}/my-app:${{ github.sha }}
            docker system prune -f

This workflow triggers only after the test and scan workflows complete successfully — enforced by workflow_run and the conditional if check. The SSH action pulls the exact SHA-tagged image, stops the old container, and starts a new one. The docker system prune -f cleans up dangling images to prevent disk exhaustion.

Environment Variables and Secrets Management

Production workflows demand careful handling of secrets. Never hardcode credentials in workflow files. Use GitHub's encrypted secrets store, and reference them with ${{ secrets.SECRET_NAME }} syntax. Here's a complete example showing secret management for multiple environments:

name: Multi-Environment Build

on:
  push:
    branches: [ main, staging ]

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

      - name: Determine environment
        id: env
        run: |
          if [ "${{ github.ref }}" == "refs/heads/main" ]; then
            echo "ENV_NAME=production" >> $GITHUB_OUTPUT
            echo "IMAGE_TAG=prod-${{ github.sha }}" >> $GITHUB_OUTPUT
          else
            echo "ENV_NAME=staging" >> $GITHUB_OUTPUT
            echo "IMAGE_TAG=staging-${{ github.sha }}" >> $GITHUB_OUTPUT
          fi

      - name: Build with environment-specific secrets
        run: |
          docker build \
            --build-arg DEPLOY_ENV=${{ steps.env.outputs.ENV_NAME }} \
            --build-arg SENTRY_DSN="${{ secrets.SENTRY_DSN }}" \
            -t my-app:${{ steps.env.outputs.IMAGE_TAG }} \
            .

      - name: Push image
        run: |
          docker push my-app:${{ steps.env.outputs.IMAGE_TAG }}

For secrets that change per environment (like API keys for production vs. staging), create separate secret names in GitHub and reference them conditionally. Alternatively, use environments in GitHub Actions to scope secrets to specific branches:

name: Environment-Scoped Deployment

on:
  push:
    branches: [ main ]

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment: production
    
    steps:
      - name: Deploy
        run: |
          echo "Deploying with production secrets"
          # ${{ secrets.PROD_API_KEY }} is only available in this job

Advanced Patterns

Matrix Builds for Multiple Architectures

If your production runs on both amd64 and arm64 (think AWS Graviton or Raspberry Pi edge devices), use a matrix strategy to build multi-architecture images:

name: Multi-Architecture Build

on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        platform:
          - linux/amd64
          - linux/arm64
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

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

      - name: Build per-platform image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: ${{ matrix.platform }}
          tags: |
            my-app:${{ github.sha }}-${{ matrix.platform }}
          push: true
          cache-from: type=registry,ref=my-app:cache-${{ matrix.platform }}
          cache-to: type=registry,ref=my-app:cache-${{ matrix.platform }},mode=max

  merge-manifest:
    needs: build
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Create and push combined manifest
        run: |
          docker buildx imagetools create \
            -t my-app:${{ github.sha }} \
            my-app:${{ github.sha }}-linux/amd64 \
            my-app:${{ github.sha }}-linux/arm64

The QEMU setup action enables emulation for non-native architectures. The matrix builds each platform in parallel, and the merge-manifest job combines them into a single multi-arch manifest. A single docker pull my-app:${{ github.sha }} then resolves to the correct architecture automatically.

Docker Layer Caching with buildx and GitHub Cache

For even faster builds, combine Docker Buildx with GitHub's built-in cache backend. This avoids the round-trip to a remote registry for cache retrieval:

name: Fast Build with GitHub Cache

on:
  push:
    branches: [ main ]

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

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

      - name: Build with GitHub Actions cache
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: my-app:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

The type=gha cache backend stores layers in GitHub's cache storage. It's faster than registry-based caching for frequent builds and doesn't consume registry storage. However, GitHub cache has a 10 GB total limit per repository and entries expire after 7 days of inactivity. For long-lived cache, use type=registry as shown earlier.

Using Docker Compose in CI

Complex applications with multiple services (app + database + cache + worker) benefit from Docker Compose in CI. GitHub Actions supports Compose natively:

name: Compose-Based Integration Tests

on:
  pull_request:
    branches: [ main ]

jobs:
  integration:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Build all services
        run: |
          docker compose -f docker-compose.ci.yml build

      - name: Start services
        run: |
          docker compose -f docker-compose.ci.yml up -d
          # Wait for all services to be healthy
          sleep 15

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

      - name: Collect logs on failure
        if: failure()
        run: |
          docker compose -f docker-compose.ci.yml logs

      - name: Cleanup
        if: always()
        run: |
          docker compose -f docker-compose.ci.yml down -v

Here's a corresponding docker-compose.ci.yml:

version: '3.9'
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "user"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine

The condition: service_healthy directive ensures the database is accepting connections before the app starts — eliminating race conditions that plague many CI pipelines.

Best Practices

After implementing Docker with GitHub Actions across dozens of production systems, certain patterns consistently yield reliable, fast, and secure pipelines:

Common Pitfalls and How to Avoid Them

Even experienced teams encounter these issues. Here's how to handle them preemptively:

Complete Production Workflow Template

Here's a battle-tested workflow template that combines everything covered in this guide — multi-stage builds, caching, testing, security scanning, and deployment — into a single production pipeline:

name: Production CI/CD Pipeline

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

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # Job 1: Build and cache
  build:
    runs-on: ubuntu-latest
    
    permissions:
      contents: read
      packages: write
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

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

      - name: Build and push with cache
        id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Export image digest
        run: |
          echo "DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV

  # Job 2: Run tests against the built image
  test:
    needs: build
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: ci_user
          POSTGRES_PASSWORD: ci_pass
          POSTGRES_DB: ci_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Pull built image
        run: |
          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

      - name: Run unit tests
        run: |
          docker run --rm \
            --network ${{ job.services.postgres.network }} \
            -e DATABASE_URL=postgres://ci_user:ci_pass@postgres:5432/ci_db \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
            npm run test:unit

      - name: Run integration tests
        run: |
          docker run --rm \
            --network ${{ job.services.postgres.network }} \
            -e DATABASE_URL=postgres://ci_user:ci_pass@postgres:5432/ci_db \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
            npm run test:integration

  # Job 3: Security scan
  scan:
    needs: build
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Pull built image
        run: |
          docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}

      - name: Scan with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1
          ignore-unfixed: true

      - name: Upload SARIF results
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

  # Job 4: Deploy to production (only on main push, only if test and scan pass)
  deploy:
    needs: [test, scan]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Deploy to production server
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            set -e
            IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
            echo "Pulling image: $IMAGE"
            docker pull "$IMAGE"
            
            echo "Stopping current container..."
            docker stop my-app-prod || true
            docker rm my-app-prod || true
            
            echo "Starting new container..."
            docker run -d \
              --name my-app-prod \
              --restart always \
              -p 80:3000 \
              -e DATABASE_URL="${{ secrets.PROD_DATABASE_URL }}" \
              -e REDIS_URL="${{ secrets.PROD_REDIS_URL }}" \
              -e SENTRY_DSN="${{ secrets.SENTRY_DSN }}" \
              --memory 512m \
              --cpus 1 \
              "$IMAGE"
            
            echo "Cleaning up old images..."
            docker image prune -a -f
            
            echo "Deployment complete!"

      - name: Verify deployment
        run: |
          sleep 10
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://${{ secrets.PROD_HOST }}/health)
          if [ "$STATUS" != "200" ]; then
            echo "Health check failed with status: $STATUS"
            exit 1
          fi
          echo "Health check passed!"

This template represents a complete, production-hardened pipeline. It builds once, caches aggressively, tests against real infrastructure, scans for vulnerabilities, deploys only when everything passes, and verifies the deployment with a health check. Adapt it to your stack by replacing the test commands, service containers, and deployment method.

Conclusion

Docker and GitHub Actions together provide a CI/CD foundation that is consistent, auditable, and fast. The patterns in this guide — multi-stage Dockerfiles, layer caching with Build

🚀 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