Home DevOps Docker with GitHub Actions: Build, Cache, and Deploy Without the Pain
Intermediate 3 min · July 11, 2026

Docker with GitHub Actions: Build, Cache, and Deploy Without the Pain

Docker with GitHub Actions: build efficient CI/CD pipelines.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
July 11, 2026
last updated
258
articles · all by Naren
Before you start⏱ 25 min
  • Basic knowledge of Docker and Dockerfile syntax
  • Familiarity with GitHub Actions workflow YAML
  • A GitHub repository with a Dockerized application
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

To build and push a Docker image with GitHub Actions, use the docker/build-push-action action. Set up secrets for registry credentials, cache layers with cache-from and cache-to, and use matrix builds for multi-architecture images.

✦ Definition~90s read
What is Docker with GitHub Actions?

Docker with GitHub Actions means using GitHub's CI/CD platform to automatically build, test, and deploy Docker images. You define workflows in YAML that run on GitHub's runners, leveraging Docker commands and actions to containerize your application.

Imagine you're a chef who needs to prepare the same meal in dozens of restaurants.
Plain-English First

Imagine you're a chef who needs to prepare the same meal in dozens of restaurants. Instead of cooking from scratch each time, you prep ingredients in a central kitchen (build stage), pack them into sealed containers (Docker images), and ship them to each restaurant (registry). GitHub Actions is your automated kitchen manager that follows your recipe (workflow) to do this every time you update the menu (push code).

You've been burned by 'it works on my machine' one too many times. Docker fixes that, but only if your CI/CD pipeline actually builds the same image you tested locally. GitHub Actions is the most popular CI/CD platform on the planet, and yet I still see teams pushing 2GB images because they don't understand layer caching. Here's the blunt truth: your pipeline is probably slower and more fragile than it should be.

The core problem is that most tutorials show you how to run docker build in a workflow and call it a day. They ignore the real-world constraints: limited runner disk space, network egress costs, and the fact that rebuilding an image from scratch every time is a waste of time and money. You need a pipeline that's fast, reliable, and doesn't cost a fortune in GitHub Actions minutes.

By the end of this article, you'll be able to set up a production-grade Docker CI/CD pipeline that caches intelligently, builds multi-architecture images, and deploys securely — all while avoiding the gotchas that have taken down production services at 3 AM.

Why Your Docker Builds Are Slow (And How to Fix It)

Every time you run docker build, Docker executes each instruction in your Dockerfile, creating a new layer. If nothing changed, Docker uses the cached layer — but only if the context and previous layers are identical. The problem: GitHub Actions runners are ephemeral. They start fresh each time, so your local cache is gone. Without caching, you rebuild every layer from scratch. That's slow and expensive.

The fix is to use Docker's --cache-from flag to pull cached layers from a registry. GitHub Actions has a built-in cache action, but for Docker, the best approach is to use the docker/build-push-action with cache-from and cache-to pointing to the same registry where you push the final image. This way, layers are stored alongside your image, and subsequent builds reuse them.

.github/workflows/docker-build.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# io.thecodeforge — DevOps tutorial
# Production Docker build with layer caching

name: Build and Push Docker Image

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        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
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: yourusername/yourapp:latest
          # Cache from previous build in registry
          cache-from: type=registry,ref=yourusername/yourapp:cache
          # Cache to registry for next build
          cache-to: type=registry,ref=yourusername/yourapp:cache,mode=max
Output
Build output showing cached layers: `CACHED [2/5] RUN npm ci` etc. Final: `Successfully pushed yourusername/yourapp:latest`
Production Trap: Cache Mode 'max' vs 'min'
Using mode=max stores all layers, making subsequent builds fast but uses more storage in the registry. mode=min only caches the final layer — faster push but slower rebuilds. For production, use mode=max and set up a lifecycle policy to delete old cache images after 30 days.

Multi-Architecture Builds: Arm64 Without the Headache

Apple Silicon is everywhere, and AWS Graviton instances are cheap. If you're still building only for amd64, you're leaving performance and cost on the table. But building for multiple architectures manually is a pain. Docker Buildx makes it trivial with the --platform flag.

In GitHub Actions, you can build for linux/amd64 and linux/arm64 in one step using QEMU emulation. The trick is to set up QEMU before the build and use a matrix strategy if you need separate builds per architecture. For most cases, a single build with multiple platforms works fine, but be aware: QEMU emulation is slow. For large images, consider native arm64 runners (GitHub now offers them).

.github/workflows/multi-arch.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# io.thecodeforge — DevOps tutorial
# Multi-architecture Docker build

name: Multi-Arch Build

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    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: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push multi-arch
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: yourusername/yourapp:latest
          cache-from: type=registry,ref=yourusername/yourapp:cache
          cache-to: type=registry,ref=yourusername/yourapp:cache,mode=max
Output
Build output: `#1 [linux/amd64] ...` and `#1 [linux/arm64] ...` layers built in parallel. Final: `Successfully pushed manifest list yourusername/yourapp:latest`
Senior Shortcut: Use Native Arm64 Runners
QEMU emulation for arm64 can be 2-3x slower than native builds. If your team uses Apple Silicon or deploys to ARM servers, consider using GitHub's ubuntu-24.04-arm runner for the arm64 build in a matrix. It's faster and avoids emulation bugs.

Secrets Management: Don't Hardcode Credentials

I've seen Dockerfiles with ENV API_KEY=supersecret committed to public repos. That's a fireable offense. GitHub Actions provides secrets, but you need to pass them to Docker correctly. The naive approach is to use --build-arg, but that leaks secrets into the image history. Instead, use Docker's --secret flag (BuildKit) to mount secrets at build time without persisting them.

For runtime secrets, pass them as environment variables in the container using GitHub secrets. Never bake secrets into the image.

.github/workflows/secure-build.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# io.thecodeforge — DevOps tutorial
# Passing secrets to Docker build securely

name: Secure Build

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build with secret
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: yourusername/yourapp:latest
          secrets: |
            "npmrc=${{ secrets.NPMRC }}"
          cache-from: type=registry,ref=yourusername/yourapp:cache
          cache-to: type=registry,ref=yourusername/yourapp:cache,mode=max
Output
Build uses secret from GitHub Secrets. No secret exposed in logs or image layers.
Never Do This: Using --build-arg for Secrets
--build-arg values are visible in docker history and can be read by anyone with pull access to the image. Always use --secret with BuildKit for build-time secrets. For runtime secrets, use environment variables passed at container start.

Testing Your Docker Image in CI Before Push

Building an image is useless if it doesn't work. You should run integration tests against the freshly built image before pushing it to the registry. The pattern: build the image, run it as a service container, execute tests against it, then push only if tests pass.

GitHub Actions supports service containers natively. You can spin up your Docker image as a service, run tests in a separate job step, and conditionally push based on the test result. This prevents broken images from reaching production.

.github/workflows/test-and-push.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# io.thecodeforge — DevOps tutorial
# Build, test, then push Docker image

name: Test and Push

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      app:
        image: yourusername/yourapp:test-${{ github.sha }}
        ports:
          - 3000:3000
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3

      - name: Build test image
        uses: docker/build-push-action@v5
        with:
          context: .
          load: true
          tags: yourusername/yourapp:test-${{ github.sha }}
          cache-from: type=registry,ref=yourusername/yourapp:cache

      - name: Run integration tests
        run: |
          curl -f http://localhost:3000/health || exit 1
          # More tests...

  push:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build and push production image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: yourusername/yourapp:latest
          cache-from: type=registry,ref=yourusername/yourapp:cache
          cache-to: type=registry,ref=yourusername/yourapp:cache,mode=max
Output
Test job runs integration tests against the built image. If tests pass, push job runs and pushes the image. If tests fail, push job is skipped.
Interview Gold: Why Separate Build and Push Jobs?
By separating test and push into different jobs, you ensure that only tested images are pushed. The needs dependency prevents push from running if tests fail. This is the standard pattern for production CI/CD pipelines.

When Not to Use Docker with GitHub Actions

Docker in CI is not always the answer. If your application is a simple static site, deploying directly to a CDN (like Netlify or Vercel) is faster and cheaper. If you're running a monolith that rarely changes, a traditional VM deployment might be simpler. Docker adds complexity: you need to manage a registry, handle image cleanup, and deal with build failures due to cache invalidation.

Also, if your team doesn't have Docker expertise, the learning curve can slow down development. For small projects or prototypes, consider using GitHub Actions' built-in deploy actions (like azure/webapps-deploy or google-github-actions/deploy-cloudrun) that handle containerization for you.

My rule of thumb: use Docker in CI when you have multiple services, need consistent environments across dev/staging/prod, or are deploying to Kubernetes. Otherwise, keep it simple.

Senior Shortcut: Evaluate Before Automating
Before setting up a complex Docker pipeline, ask: 'What problem am I solving?' If the answer is 'everyone has a different Node version,' Docker is right. If it's 'I want to learn Docker,' start with a simple local setup first.
● Production incidentPOST-MORTEMseverity: high

The 4GB Container That Kept Dying

Symptom
A microservice handling payment webhooks would crash with OOMKilled every few hours under moderate load.
Assumption
The team assumed a memory leak in the application code and spent days profiling heap dumps.
Root cause
The Docker image was 4GB because the Dockerfile copied the entire node_modules (including dev dependencies) and didn't use multi-stage builds. The base image was node:18 (full OS) instead of node:18-alpine. The container's memory limit was 512MB, but the image itself consumed 300MB+ just for the OS overhead, leaving little room for the app.
Fix
Switched to node:18-alpine, used multi-stage build with npm ci --only=production in the final stage, and set --memory=256m --memory-reservation=128m in the deployment. Image size dropped to 150MB. OOM kills stopped.
Key lesson
  • Your Docker image size directly impacts runtime memory pressure.
  • Always use slim base images and multi-stage builds — your container's memory limit includes the image overhead.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Build fails with Error: buildx failed with: ERROR: failed to solve: failed to cache: error writing layer blob: no space left on device
Fix
1. Check runner disk space: df -h in a step. 2. Clean Docker cache: docker system prune -af. 3. Reduce cache mode to min temporarily. 4. Use a larger runner (e.g., ubuntu-latest-8-cores).
Symptom · 02
Image push fails with denied: requested access to the resource is not found
Fix
1. Verify Docker Hub username and password secrets are correct. 2. Ensure the repository exists on Docker Hub. 3. Check if the token has write permissions. 4. For GitHub Container Registry, ensure GITHUB_TOKEN has write:packages scope.
Symptom · 03
Multi-arch build fails with exec format error on arm64
Fix
1. Ensure QEMU is set up: docker/setup-qemu-action@v3. 2. Check that your base image supports arm64 (e.g., node:18-alpine does). 3. If using native arm64 runner, verify the Dockerfile doesn't have amd64-specific binaries.
★ Docker with GitHub Actions Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
`no space left on device` during build
Immediate action
Check disk usage and clean Docker cache
Commands
docker system prune -af
df -h
Fix now
Add a step before build: - name: Clean Docker cache\n run: docker system prune -af
`denied: requested access to the resource is not found` on push+
Immediate action
Verify registry credentials and repository existence
Commands
echo ${{ secrets.DOCKER_USERNAME }} | wc -c
docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
Fix now
Ensure the repository name in tags matches exactly, including case.
`exec format error` on arm64+
Immediate action
Check QEMU setup and base image architecture
Commands
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx ls
Fix now
Add docker/setup-qemu-action@v3 before build step.
Build takes >30 minutes+
Immediate action
Check if caching is enabled
Commands
grep -i cache .github/workflows/*.yml
docker buildx du
Fix now
Add cache-from: type=registry,ref=yourimage:cache and cache-to: type=registry,ref=yourimage:cache,mode=max to build step.
FeatureDocker with GitHub ActionsTraditional VM Deployment
Environment consistencyHigh (same image everywhere)Medium (OS and dependency drift)
Build speed (cached)Fast with layer cachingN/A (no caching)
Deployment complexityMedium (registry, orchestration)Low (SSH and scripts)
CostGitHub Actions minutes + registry storageVM compute costs
ScalabilityHigh (Kubernetes, ECS)Low (manual scaling)
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
.githubworkflowsdocker-build.ymlname: Build and Push Docker ImageWhy Your Docker Builds Are Slow (And How to Fix It)
.githubworkflowsmulti-arch.ymlname: Multi-Arch BuildMulti-Architecture Builds
.githubworkflowssecure-build.ymlname: Secure BuildSecrets Management
.githubworkflowstest-and-push.ymlname: Test and PushTesting Your Docker Image in CI Before Push

Key takeaways

1
Always use layer caching with cache-from and cache-to to avoid rebuilding from scratch every time.
2
Never use --build-arg for secrets; use BuildKit's --secret mount instead.
3
Multi-architecture builds are easy with Docker Buildx and QEMU, but consider native arm64 runners for speed.
4
Test your Docker image in CI before pushing to the registry to prevent broken deployments.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Docker layer caching work in GitHub Actions, and what happens w...
Q02SENIOR
When would you choose GitHub Container Registry (ghcr.io) over Docker Hu...
Q03SENIOR
What happens if you use `--build-arg` to pass a secret to a Docker build...
Q04JUNIOR
What is the purpose of a `.dockerignore` file in a Docker build context?
Q05SENIOR
You have a Docker build that works locally but fails in GitHub Actions w...
Q06SENIOR
Design a CI/CD pipeline for a microservice that needs to be deployed to ...
Q01 of 06SENIOR

How does Docker layer caching work in GitHub Actions, and what happens when the cache is invalidated?

ANSWER
Docker caches layers based on the instruction and its context. In GitHub Actions, the cache is ephemeral per runner, so you must use --cache-from to pull cached layers from a registry. Cache invalidation occurs when a layer's instruction or its parent layer changes. For example, if you change a COPY instruction, all subsequent layers are rebuilt. To minimize invalidation, order your Dockerfile from least to most frequently changing instructions (e.g., package.json before source code).
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
How do I cache Docker layers in GitHub Actions?
02
What's the difference between GitHub Container Registry and Docker Hub for GitHub Actions?
03
How do I build multi-architecture Docker images in GitHub Actions?
04
My Docker build passes locally but fails in GitHub Actions with 'no space left on device'. What should I do?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
July 11, 2026
last updated
258
articles · all by Naren
🔥

That's Docker. Mark it forged?

3 min read · try the examples if you haven't

Previous
Docker Troubleshooting Guide
30 / 32 · Docker
Next
Docker with GitLab CI/CD