← Back to Blog

GitHub Actions CI/CD: Build and Push Docker Images with Layer Caching

Every time you push code, your CI pipeline rebuilds the entire Docker image from scratch. Dependency install runs again, build tools download again, and what used to take 3 minutes locally balloons to 8 or 10 in CI. The fix is layer caching: reuse the expensive layers from previous builds and only rebuild what actually changed.

This tutorial walks through a complete GitHub Actions workflow that builds a Docker image, caches layers between runs, and pushes to either Docker Hub or GitHub Container Registry. You get working YAML you can copy and paste, plus guidance on which caching strategy fits your team.

Prerequisites

  • A GitHub repository with a Dockerfile
  • Docker Hub account (if pushing to Docker Hub) or a GitHub repo (for GHCR, which needs no extra account)
  • Basic comfort with GitHub Actions YAML syntax

Check your Dockerfile is multi-stage if you have build steps. Single-stage Dockerfiles where the build tools live alongside the runtime waste cache space because every change rebuilds the whole thing.

The Workflow

Create .github/workflows/docker-publish.yml in your repo:

name: Build and Push Docker Image

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

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

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

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

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

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

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=sha,prefix=

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

That single file handles PRs (build without pushing), pushes to main (build and push), and caches layers between runs. The cache-from and cache-to lines are what make this fast.

Understanding the Cache Strategies

Docker Buildx supports two main caching backends for GitHub Actions:

GitHub Actions Cache (type=gha)

This stores layer cache in GitHub's built-in Actions cache. It works out of the box with zero extra setup. The cache scope is per-repository by default, so separate repos do not share layers.

cache-from: type=gha
cache-to: type=gha,mode=max

The mode=max part matters. Without it, only the final image layers get cached. With mode=max, all intermediate build stages get cached too. For multi-stage Dockerfiles, this is the difference between re-running a 5-minute Go build and skipping it entirely.

Limit: GitHub Actions cache has a 10GB total limit per repository. Once you exceed it, older entries get evicted. For most teams this is fine. If your images are large or you have many branches, consider the registry cache instead.

Registry Cache (type=registry)

This pushes cache layers to a container registry as a separate image. It works with any registry (Docker Hub, GHCR, ECR) and has no size limit tied to GitHub. The tradeoff is a slightly slower initial pull because the cache layers come over the network.

cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

Use this if your repo regularly exceeds the 10GB cache limit, or if you need cache sharing across multiple repositories.

Which One to Pick

Factor type=gha type=registry
Setup Zero config Needs registry push permissions
Speed Fast (local cache on runner) Slightly slower (network fetch)
Size limit 10GB per repo Unlimited
Cross-repo sharing No Yes

Start with type=gha. Switch to registry cache only when you hit the size limit or need cross-repo sharing.

Pushing to Docker Hub Instead

If your team uses Docker Hub, swap the login step and drop the registry env variable:

env:
  IMAGE_NAME: your-dockerhub-username/your-repo

jobs:
  build-and-push:
    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: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=sha,prefix=

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Store your Docker Hub username and password as repository secrets named DOCKER_USERNAME and DOCKER_PASSWORD. Do not hardcode them in the workflow file.

Tagging Strategies

The metadata-action automatically generates sensible tags. Here are common patterns:

tags: |
  # branch name (main, develop, feature/xyz)
  type=ref,event=branch
  # commit SHA (abc1234)
  type=sha,prefix=
  # semantic version on tag push (v1.2.3 -> 1.2.3, 1.2, 1)
  type=semver,pattern={{version}}
  type=semver,pattern={{major}}.{{minor}}
  type=semver,pattern={{major}}

When you push a tag v1.2.3, the action creates three image tags: 1.2.3, 1.2, and 1. This follows Docker's convention for versioned images.

Verifying the Build

After your first push, check the Actions tab in your repo. You should see the workflow run with the "Build and push" step completing in roughly 30 to 60 seconds on subsequent runs (vs. 3 to 5 minutes on the first run).

To verify the image was pushed:

# For GHCR
docker pull ghcr.io/your-username/your-repo:main

# For Docker Hub
docker pull your-username/your-repo:main

# Run it
docker run -p 8080:8080 ghcr.io/your-username/your-repo:main

Common Problems

Cache not working. Make sure you are using docker/setup-buildx-action. The default Docker builder in GitHub Actions runners does not support Buildx cache backends. Without Buildx, the cache-from and cache-to fields are silently ignored.

PR builds push images. The push: ${{ github.event_name != 'pull_request' }} line prevents pushing on PRs. If you want to push PR images to a separate tag for testing, add a type=ref,event=pr entry to your tags.

Cache eviction on large images. If your Dockerfile pulls a 2GB base image and you have mode=max, you are caching a lot. Consider switching to mode=min (only caches the final image layers) or moving to type=registry for unlimited storage.

Permissions error on GHCR push. The workflow needs packages: write permission. If you have a branch protection rule or org-level policy that restricts GITHUB_TOKEN permissions, you may need to adjust those settings.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation