Home DevOps Docker with GitHub Actions: Build, Cache, and Deploy Without the Pain
Intermediate 5 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 18, 2026
last updated
2,466
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.
docker-github-actions THECODEFORGE.IO Docker CI/CD Architecture on GitHub Actions Layered stack from code to deployment with security and caching Source Control GitHub Repository | Branch Protection Rules CI Pipeline GitHub Actions Workflow | Docker Buildx | Layer Caching Build & Test Multi-Arch Build | Unit Tests | Integration Tests Security Trivy Scanner | Cosign Signing | OIDC Authentication Registry & Deploy Docker Registry | Kubernetes Cluster | Deployment Scripts THECODEFORGE.IO
thecodeforge.io
Docker Github Actions

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.
docker-github-actions THECODEFORGE.IO GitHub Actions Docker Pipeline Architecture Layered components for secure, multi-architecture deployment Source Control GitHub Repository | Branch Protection Rules CI/CD Orchestration GitHub Actions Workflow | Docker Buildx | Docker Bake Build & Cache Layer Layer Caching | Multi-Arch Builder | Build Arguments Security & Signing Trivy Scanner | Cosign with OIDC | Secrets Management Deployment Target Docker Registry | Kubernetes Cluster | Cloud VM THECODEFORGE.IO
thecodeforge.io
Docker Github Actions

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.

Security Scanning in GitHub Actions Pipeline

Shipping a Docker image without scanning it for vulnerabilities is like locking your front door but leaving the window open. GitHub Actions lets you integrate security scanning directly into the build pipeline using tools like Trivy, Grype, and Docker Scout. The key is to fail the build if critical or high-severity CVEs are found, preventing vulnerable images from reaching the registry.

Trivy is the most popular choice: it's fast, supports multiple languages and OS packages, and has a dedicated GitHub Action (aquasecurity/trivy-action). Run it after the Docker build but before pushing to the registry. For deeper analysis, generate an SBOM in CycloneDX format — a machine-readable inventory of every component in your image. Docker Scout integrates with GitHub Actions natively, providing CVE analysis directly in the build output and pull request comments.

.github/workflows/secure-scan.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
54
55
56
57
58
59
60
61
# io.thecodeforge — DevOps tutorial
# Security scanning with Trivy, SBOM generation, and Docker Scout

name: Secure Build with Scanning

on:
  push:
    branches: [main]

jobs:
  build-and-scan:
    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 image
        uses: docker/build-push-action@v5
        with:
          context: .
          load: true
          tags: myapp:${{ github.sha }}
          cache-from: type=registry,ref=yourusername/yourapp:cache

      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@v0.19
        with:
          image-ref: myapp:${{ github.sha }}
          format: table
          exit-code: 1
          severity: CRITICAL,HIGH

      - name: Generate SBOM (CycloneDX)
        uses: aquasecurity/trivy-action@v0.19
        with:
          image-ref: myapp:${{ github.sha }}
          format: cyclonedx
          output: sbom.cdx.json

      - name: Upload SBOM as artifact
        uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: sbom.cdx.json

      - name: Docker Scout CVE analysis
        uses: docker/scout-action@v1
        with:
          image: myapp:${{ github.sha }}
          command: cves
          only-severities: critical,high
          exit-code: true

      - name: Push image (only if scans pass)
        run: |
          docker tag myapp:${{ github.sha }} yourusername/yourapp:latest
          docker push yourusername/yourapp:latest
Output
Trivy scan output showing CVEs found. Build fails with exit code 1 if CRITICAL or HIGH vulnerabilities exist. SBOM uploaded as build artifact.
⚠ Production Trap: Scan After Build, Before Push
Scanning must happen after the image is built but before it's pushed. If the scan fails, the image is never pushed. This prevents vulnerable images from ever reaching the registry. For registries that support it, also set up admission webhooks to block deployment of vulnerable images at runtime.

Cosign Image Signing with OIDC

Anyone can push a Docker image to a registry. How do you know it came from your build pipeline and wasn't tampered with? Cosign, part of the Sigstore project, enables keyless signing of container images using OIDC (OpenID Connect) — meaning you don't need to manage private keys. GitHub Actions provides OIDC tokens automatically, making it trivial to sign images during the build.

The flow: build the image, sign it with Cosign using the GitHub OIDC token, push both the image and the signature. The signature is stored in the registry as an attached tag. At deploy time, you verify the signature before pulling the image. This gives you SLSA provenance attestation — proof that the image was built by your CI pipeline and hasn't been modified.

Keyless signing means the signature is bound to your GitHub repository and workflow. Even if an attacker gains access to your registry, they can't create valid signatures because they can't obtain a valid OIDC token from your CI pipeline.

.github/workflows/signed-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
36
37
38
39
40
41
42
43
# io.thecodeforge — DevOps tutorial
# Keyless image signing with Cosign and Sigstore

name: Build and Sign Image

on:
  push:
    branches: [main]

permissions:
  id-token: write  # Needed for OIDC
  contents: read

jobs:
  build-and-sign:
    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
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: yourusername/yourapp:latest

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Sign the image with OIDC
        run: |
          cosign sign --yes yourusername/yourapp:latest

      - name: Verify signature
        run: |
          cosign verify yourusername/yourapp:latest \
            --certificate-identity-regexp=".*\\.github\\.com/yourorg/yourrepo\\.github\\.githubusercontent\\.com.*" \
            --certificate-oidc-issuer=https://token.actions.githubusercontent.com
Output
Image built, pushed, signed with Cosign. Signature attached to registry. Verification confirms the signature matches the GitHub Actions workflow.
🔥Senior Shortcut: SLSA Provenance with Cosign
Cosign can generate SLSA provenance attestations that prove your image was built by a specific CI pipeline from a specific commit. Use cosign attest to attach provenance metadata. This is increasingly required for compliance (FedRAMP, SOC 2) and supply chain security.

Docker Bake for Declarative Builds

When your build configuration outgrows a single docker build command — multiple tags, platforms, cache configurations, or targets — Docker Bake gives you a declarative HCL or YAML format to define everything in one file. Think of it as Docker Compose for builds.

With Bake, you define build targets in a bake.hcl file. Each target specifies context, tags, platforms, cache settings, and dependencies on other targets. You can run docker buildx bake to execute all targets in parallel where possible. This is especially powerful for multi-platform builds: define one target per platform, and Bake runs them concurrently.

Cache configuration in Bake is cleaner than CLI flags: you define cache-from and cache-to arrays per target, and they're automatically applied. You can also reference variables from environment files or CI environment variables, making it easy to adapt to different CI systems.

docker-bake.hclHCL
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
// io.thecodeforge — DevOps tutorial
// Declarative build configuration with Docker Bake

variable "REGISTRY" {
  default = "yourusername"
}

variable "TAG" {
  default = "latest"
}

group "default" {
  targets = ["app", "worker"]
}

target "app" {
  context = "."
  dockerfile = "Dockerfile.app"
  tags = [
    "${REGISTRY}/yourapp:${TAG}",
    "${REGISTRY}/yourapp:${TAG}-amd64",
  ]
  platforms = ["linux/amd64", "linux/arm64"]
  cache-from = [
    "type=registry,ref=${REGISTRY}/yourapp:cache"
  ]
  cache-to = [
    "type=registry,ref=${REGISTRY}/yourapp:cache,mode=max"
  ]
}

target "worker" {
  context = "./worker"
  dockerfile = "Dockerfile.worker"
  tags = ["${REGISTRY}/worker:${TAG}"]
  platforms = ["linux/amd64", "linux/arm64"]
  cache-from = [
    "type=registry,ref=${REGISTRY}/worker:cache"
  ]
  cache-to = [
    "type=registry,ref=${REGISTRY}/worker:cache,mode=max"
  ]
}
Output
$ docker buildx bake
[+] Building 15.2s (24/24) FINISHED
[app] Building 12.3s (12/12) FINISHED
[worker] Building 8.1s (12/12) FINISHED
Both targets built in parallel with shared cache.
💡Production Trap: Matrix Builds in Bake
Use Bake's matrix targets to generate builds for multiple configurations. Define a matrix block with variables (e.g., version, os/arch), and Bake creates a target per combination. This is cleaner than GitHub Actions matrix strategies and gives you a single source of truth.

Deploy to Kubernetes After Build

Building and pushing a Docker image is only half the story. You need to deploy it. GitHub Actions can deploy directly to Kubernetes clusters after a successful build. The most common patterns are kubectl set image for simple rolling updates, and helm upgrade --atomic for Helm-managed deployments.

For production-grade deployments, use blue-green or canary patterns. Blue-green: deploy the new version alongside the old, switch traffic when ready, then tear down the old. Canary: route a small percentage of traffic to the new version, gradually increase if no errors, then full rollout. GitHub Actions supports these with manual approval gates between environments — deploy to staging automatically, require manual approval for production.

Rollback is critical. If a deployment fails health checks, you need to revert immediately. kubectl rollout undo reverts to the previous revision. Helm's --atomic flag automatically rolls back if the deployment fails. Always test rollback in staging before relying on it in production.

.github/workflows/deploy-k8s.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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# io.thecodeforge — DevOps tutorial
# Build, push, and deploy to Kubernetes with Helm

name: Deploy to Kubernetes

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.tag.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: tag
        run: echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }}

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Set up kubectl
        uses: azure/setup-kubectl@v4
        with:
          version: latest

      - name: Set up Helm
        uses: azure/setup-helm@v4

      - name: Deploy with Helm
        run: |
          helm upgrade --install myapp ./helm \
            --namespace myapp-staging \
            --set image.tag=${{ needs.build.outputs.image_tag }} \
            --atomic \
            --timeout 5m \
            --wait

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-helm@v4

      - name: Deploy to production
        run: |
          helm upgrade --install myapp ./helm \
            --namespace myapp-prod \
            --set image.tag=${{ needs.build.outputs.image_tag }} \
            --atomic \
            --timeout 5m \
            --wait

      - name: Rollback on failure
        if: failure()
        run: |
          helm rollback myapp --namespace myapp-prod
Output
Build job produces image. Staging deployment succeeds. Production deployment runs after manual approval. Rollback triggers automatically on failure.
⚠ Production Trap: Manual Gates Protect Production
Use GitHub Environments with required reviewers for production deployments. This ensures a human reviews the build before it reaches production. Set environment: production with protection rules in the deploy job.
Traditional vs Optimized Docker CI/CD Comparing slow, insecure builds with modern GitHub Actions practices Traditional Approach Optimized Approach Build Speed No layer caching, full rebuild each time GitHub Actions cache for unchanged layer Architecture Support Single architecture (amd64 only) Multi-arch with Buildx (amd64 + arm64) Secrets Handling Hardcoded in Dockerfile or env vars GitHub Secrets with --secret flag Security Scanning No automated vulnerability checks Trivy scan before push, fail on critical Image Signing No signing, trust based on registry Cosign with OIDC for verifiable signatur Declarative Builds Manual docker build commands Docker Bake for multi-service declarativ THECODEFORGE.IO
thecodeforge.io
Docker Github Actions

Native ARM Runners and Docker Build Cloud

QEMU emulation for ARM builds is convenient but slow — often 2-3x slower than native builds. GitHub now offers ubuntu-24.04-arm runners that run natively on ARM64 hardware. For teams deploying to AWS Graviton, Apple Silicon-based CI, or Raspberry Pi targets, native ARM runners cut build times dramatically.

Docker Build Cloud takes this further by providing managed multi-node builders. Instead of running BuildKit on your ephemeral runner, you delegate the build to Docker's cloud infrastructure. Benefits: persistent cache shared across your team, faster builds (cloud builders have more CPU/memory), and no local storage constraints. You connect to Docker Build Cloud via docker buildx create --driver cloud <org>/<builder>.

For teams with mixed architecture needs, the optimal pattern is: use a matrix with ubuntu-latest (AMD64) and ubuntu-24.04-arm (ARM64) for native builds on each architecture, bypassing QEMU entirely. This gives the fastest possible build times and eliminates emulation bugs.

.github/workflows/native-arm.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
54
55
56
57
58
59
# io.thecodeforge — DevOps tutorial
# Native ARM64 builds with ubuntu-24.04-arm runners

name: Native Multi-Arch Build

on:
  push:
    branches: [main]

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        arch: [amd64, arm64]
        include:
          - arch: amd64
            runner: ubuntu-latest
            platform: linux/amd64
          - arch: arm64
            runner: ubuntu-24.04-arm
            platform: linux/arm64

    runs-on: ${{ matrix.runner }}
    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
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: yourusername/yourapp:latest-${{ matrix.arch }}
          platforms: ${{ matrix.platform }}
          cache-from: type=registry,ref=yourusername/yourapp:cache-${{ matrix.arch }}
          cache-to: type=registry,ref=yourusername/yourapp:cache-${{ matrix.arch }},mode=max

  # Merge per-architecture images into a multi-arch manifest
  manifest:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Create multi-arch manifest
        run: |
          docker buildx imagetools create \
            -t yourusername/yourapp:latest \
            yourusername/yourapp:latest-amd64 \
            yourusername/yourapp:latest-arm64
Output
AMD64 and ARM64 builds run in parallel on native runners. Manifest job merges them into a single multi-arch image tagged `latest`.
🔥Senior Shortcut: Docker Build Cloud for Teams
If your team spends $500+/month on CI build minutes, Docker Build Cloud is worth investigating. The shared cache alone often cuts build times by 50%+ because cache hits are instant across all developers, not just per-runner.
● 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
9 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
.githubworkflowssecure-scan.ymlname: Secure Build with ScanningSecurity Scanning in GitHub Actions Pipeline
.githubworkflowssigned-build.ymlname: Build and Sign ImageCosign Image Signing with OIDC
docker-bake.hclvariable "REGISTRY" {Docker Bake for Declarative Builds
.githubworkflowsdeploy-k8s.ymlname: Deploy to KubernetesDeploy to Kubernetes After Build
.githubworkflowsnative-arm.ymlname: Native Multi-Arch BuildNative ARM Runners and Docker Build Cloud

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.
5
Integrate security scanning (Trivy, Grype, Docker Scout) into your pipeline and fail the build on critical vulnerabilities
never push untrusted images.
6
Use Cosign with OIDC keyless signing to sign every image, and verify signatures at deploy time for supply chain security.
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 ...
Q07SENIOR
How would you prevent a vulnerable Docker image from being deployed to p...
Q08SENIOR
What is SLSA provenance and how do you implement it with Docker builds i...
Q01 of 08SENIOR

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 · 6 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?
05
How does Cosign keyless signing work with GitHub Actions?
06
How do I deploy a Docker image to Kubernetes from GitHub Actions?
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 18, 2026
last updated
2,466
articles · all by Naren
🔥

That's Docker. Mark it forged?

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

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