24 minute read Platform Engineering, Software Engineering

Building Docker images has evolved far beyond simple docker build commands. Modern applications demand multi-platform support to run on both x86 and ARM architectures, multiple base images to support different runtime environments, and sophisticated tagging strategies to manage versions across development, staging, and production. Managing this complexity with shell scripts quickly becomes unwieldy, error-prone, and difficult to maintain.

Docker Bake solves these challenges with a declarative approach to building container images. It’s a high-level build orchestration tool that lets you define complex build configurations in a single file, then execute them consistently across local development and CI/CD pipelines. Instead of maintaining dozens of docker build commands with slightly different flags, you define your entire build matrix once and let Bake handle the orchestration.

In this comprehensive guide, I’ll show you how to use Docker Bake to build production-ready container images. We’ll start with the fundamentals, progress through real-world examples of multi-platform and multi-tag builds, and finish with GitHub Actions workflows that demonstrate how to integrate Bake into your CI/CD pipeline. By the end, you’ll have the knowledge to replace your complex build scripts with maintainable, declarative configuration.

Understanding Docker Bake

Before diving into examples, it’s worth understanding what Docker Bake actually does and why it exists. Docker Bake is part of Docker Buildx, the extended build capabilities that replaced the classic Docker builder. Whilst docker buildx build handles building a single image, docker bake orchestrates building multiple related images in one operation.

Why Docker Bake Exists

Traditional Docker builds work well for simple use cases; a single Dockerfile producing a single image. But real world applications often need more complexity. You might need to build the same application for both AMD64 and ARM64 platforms. You might maintain separate images with different base operating systems. You might need to tag the same build with multiple version identifiers.

Without Bake, you’d write shell scripts that loop through platforms, base images, and tags, invoking docker build repeatedly with different arguments. These scripts become difficult to maintain, hard to test, and prone to subtle bugs where one combination of flags differs slightly from another.

Bake replaces these scripts with declarative configuration. You define what you want to build, not how to build it. The configuration format supports variables, inheritance, and composition, making it easier to maintain consistency across dozens of build targets.

Bake Configuration Format

Docker Bake supports three configuration formats: HCL (HashiCorp Configuration Language), JSON, and Docker Compose files. HCL is the most expressive and the format I recommend. It supports variables, functions, and a clean syntax that reads naturally.

A Bake file consists of targets, groups, and variables. Targets define individual build operations; which Dockerfile to use, what tags to apply, which platforms to build for. Groups collect multiple targets so you can build them together. Variables parameterise your configuration, allowing the same file to work across different environments.

How Bake Differs from Regular Builds

When you run docker buildx build, you’re building a single image. When you run docker buildx bake, you can build dozens of images in parallel, each with different configurations, all defined in your Bake file. Bake also provides better defaults for multi-platform builds, automatically configuring builders and handling the complexity of cross-compilation.

Another key difference is composability. Bake files can reference other Bake files, allowing you to split configuration across multiple files and compose them at build time. This makes it possible to maintain shared configuration whilst allowing project-specific overrides.

Basic Docker Bake Configuration

Let’s start with a simple example and build up complexity progressively. Create a file called docker-bake.hcl:

variable "TAG" {
  default = "latest"
}

variable "REGISTRY" {
  default = "docker.io/myorg"
}

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

target "app" {
  context    = "."
  dockerfile = "Dockerfile"
  tags       = ["${REGISTRY}/myapp:${TAG}"]
  platforms  = ["linux/amd64"]
}

This configuration defines a single target called app that builds your Dockerfile and tags it with a registry prefix and version tag. The default group contains this target, so running docker buildx bake without arguments builds it.

Variables make this configuration flexible. Override them at runtime without modifying the file:

docker buildx bake --set "*.TAG=1.0.0"

The * pattern applies the variable to all targets. You can also set variables through environment variables or by creating a docker-bake.override.hcl file that gets automatically merged with the main configuration.

Adding Context and Arguments

Real applications need build arguments for things like version numbers, build timestamps, or configuration values. Add them to your target:

target "app" {
  context    = "."
  dockerfile = "Dockerfile"
  tags       = ["${REGISTRY}/myapp:${TAG}"]
  platforms  = ["linux/amd64"]
  
  args = {
    VERSION    = "${TAG}"
    BUILD_DATE = timestamp()
    VCS_REF    = "git-sha"
  }
  
  labels = {
    "org.opencontainers.image.version"    = "${TAG}"
    "org.opencontainers.image.created"    = timestamp()
    "org.opencontainers.image.source"     = "https://github.com/myorg/myapp"
  }
}

The args block passes build arguments to your Dockerfile, accessible via ARG instructions. The labels block adds OCI-compliant metadata to your image. The timestamp() function generates the current timestamp, ensuring each build has accurate creation metadata.

Multi-Platform Builds

Multi-platform builds are where Docker Bake really shines. Building for multiple architectures with traditional Docker builds requires multiple commands, managing different builders, and complex scripting. Bake handles all of this automatically.

Configuring Platform Support

Update your target to build for both AMD64 and ARM64:

variable "TAG" {
  default = "latest"
}

variable "REGISTRY" {
  default = "docker.io/myorg"
}

variable "PLATFORMS" {
  default = ["linux/amd64", "linux/arm64"]
}

target "app" {
  context    = "."
  dockerfile = "Dockerfile"
  tags       = ["${REGISTRY}/myapp:${TAG}"]
  platforms  = PLATFORMS
  
  args = {
    BUILDPLATFORM = ""
    TARGETPLATFORM = ""
  }
}

When you run docker buildx bake, Bake builds your image for both platforms in parallel, creating a multi-architecture manifest that automatically serves the correct image based on the host architecture.

The BUILDPLATFORM and TARGETPLATFORM arguments are automatically available in your Dockerfile when building multi-platform images. They let you customise build behaviour based on the target architecture:

FROM --platform=$BUILDPLATFORM golang:1.21 AS builder

ARG TARGETPLATFORM
ARG BUILDPLATFORM

RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM"

WORKDIR /app
COPY . .

# Use TARGETPLATFORM to set GOARCH appropriately
RUN case "$TARGETPLATFORM" in \
      "linux/amd64") GOARCH=amd64 ;; \
      "linux/arm64") GOARCH=arm64 ;; \
      "linux/arm/v7") GOARCH=arm GOARM=7 ;; \
    esac && \
    GOOS=linux go build -o myapp

FROM alpine:3.19
COPY --from=builder /app/myapp /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/myapp"]

Platform-Specific Optimisations

Sometimes you need platform-specific behaviour. For ARM builds, you might want to use a different base image or enable specific compiler optimisations. Create separate targets for each platform:

variable "TAG" {
  default = "latest"
}

variable "REGISTRY" {
  default = "docker.io/myorg"
}

target "app-amd64" {
  inherits   = ["app-common"]
  platforms  = ["linux/amd64"]
  tags       = ["${REGISTRY}/myapp:${TAG}-amd64"]
  
  args = {
    BASE_IMAGE = "alpine:3.19"
  }
}

target "app-arm64" {
  inherits   = ["app-common"]
  platforms  = ["linux/arm64"]
  tags       = ["${REGISTRY}/myapp:${TAG}-arm64"]
  
  args = {
    BASE_IMAGE = "arm64v8/alpine:3.19"
  }
}

target "app-common" {
  context    = "."
  dockerfile = "Dockerfile"
  
  args = {
    VERSION = "${TAG}"
  }
  
  labels = {
    "org.opencontainers.image.version" = "${TAG}"
  }
}

group "default" {
  targets = ["app-amd64", "app-arm64"]
}

This configuration uses inheritance through the inherits field. The app-common target defines shared configuration, whilst platform-specific targets override only what differs. Building the default group builds both platforms with their specific optimisations.

Multi-Tag Strategies

Production container workflows often need multiple tags pointing to the same image. You might tag a release as 1.2.3, 1.2, 1, and latest simultaneously. You might also tag builds with git commit SHAs for traceability. Bake makes this straightforward.

Semantic Versioning Tags

Here’s a configuration that tags images with multiple semantic version levels:

variable "VERSION" {
  default = "1.2.3"
}

variable "REGISTRY" {
  default = "docker.io/myorg"
}

variable "COMMIT_SHA" {
  default = "unknown"
}

function "semver_tags" {
  params = [version]
  result = [
    "${REGISTRY}/myapp:${version}",
    "${REGISTRY}/myapp:${regex("^[0-9]+\\.[0-9]+", version)}",
    "${REGISTRY}/myapp:${regex("^[0-9]+", version)}",
    "${REGISTRY}/myapp:latest"
  ]
}

target "app" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = ["linux/amd64", "linux/arm64"]
  tags       = semver_tags(VERSION)
  
  args = {
    VERSION    = VERSION
    COMMIT_SHA = COMMIT_SHA
  }
  
  labels = {
    "org.opencontainers.image.version" = VERSION
    "org.opencontainers.image.revision" = COMMIT_SHA
  }
}

The semver_tags function takes a version like 1.2.3 and generates four tags: 1.2.3, 1.2, 1, and latest. This ensures users can pin to specific versions or track major/minor releases.

Environment-Specific Tags

Different environments often need different tagging strategies. Development builds might use branch names and commit SHAs, whilst production uses semantic versions:

variable "ENVIRONMENT" {
  default = "dev"
}

variable "VERSION" {
  default = "dev"
}

variable "BRANCH" {
  default = "main"
}

variable "COMMIT_SHA" {
  default = "unknown"
}

variable "REGISTRY" {
  default = "docker.io/myorg"
}

function "dev_tags" {
  params = [branch, sha]
  result = [
    "${REGISTRY}/myapp:${branch}",
    "${REGISTRY}/myapp:${branch}-${sha}",
    "${REGISTRY}/myapp:dev"
  ]
}

function "prod_tags" {
  params = [version]
  result = [
    "${REGISTRY}/myapp:${version}",
    "${REGISTRY}/myapp:${regex("^[0-9]+\\.[0-9]+", version)}",
    "${REGISTRY}/myapp:${regex("^[0-9]+", version)}",
    "${REGISTRY}/myapp:latest"
  ]
}

target "app-dev" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = ["linux/amd64"]
  tags       = dev_tags(BRANCH, COMMIT_SHA)
}

target "app-prod" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = ["linux/amd64", "linux/arm64"]
  tags       = prod_tags(VERSION)
  
  output     = ["type=registry,push=true"]
}

group "dev" {
  targets = ["app-dev"]
}

group "prod" {
  targets = ["app-prod"]
}

Build development images with docker buildx bake dev and production images with docker buildx bake prod. Each group uses appropriate tags and platforms for its environment.

Multiple Base Images

Some applications need variants built from different base images. You might offer an Alpine-based image for minimal size and a Debian-based image for better compatibility. Or you might support multiple runtime versions—Node.js 18 and Node.js 20, for example.

Base Image Variants

Here’s a configuration that builds the same application with multiple base images:

variable "VERSION" {
  default = "latest"
}

variable "REGISTRY" {
  default = "docker.io/myorg"
}

variable "PLATFORMS" {
  default = ["linux/amd64", "linux/arm64"]
}

target "app-alpine" {
  context    = "."
  dockerfile = "Dockerfile.alpine"
  platforms  = PLATFORMS
  tags       = [
    "${REGISTRY}/myapp:${VERSION}-alpine",
    "${REGISTRY}/myapp:alpine"
  ]
  
  args = {
    BASE_IMAGE = "alpine:3.19"
    VERSION    = VERSION
  }
  
  labels = {
    "org.opencontainers.image.version" = VERSION
    "org.opencontainers.image.base.name" = "alpine:3.19"
  }
}

target "app-debian" {
  context    = "."
  dockerfile = "Dockerfile.debian"
  platforms  = PLATFORMS
  tags       = [
    "${REGISTRY}/myapp:${VERSION}-debian",
    "${REGISTRY}/myapp:${VERSION}",
    "${REGISTRY}/myapp:latest"
  ]
  
  args = {
    BASE_IMAGE = "debian:bookworm-slim"
    VERSION    = VERSION
  }
  
  labels = {
    "org.opencontainers.image.version" = VERSION
    "org.opencontainers.image.base.name" = "debian:bookworm-slim"
  }
}

target "app-ubuntu" {
  context    = "."
  dockerfile = "Dockerfile.ubuntu"
  platforms  = PLATFORMS
  tags       = [
    "${REGISTRY}/myapp:${VERSION}-ubuntu",
    "${REGISTRY}/myapp:ubuntu"
  ]
  
  args = {
    BASE_IMAGE = "ubuntu:22.04"
    VERSION    = VERSION
  }
  
  labels = {
    "org.opencontainers.image.version" = VERSION
    "org.opencontainers.image.base.name" = "ubuntu:22.04"
  }
}

group "default" {
  targets = ["app-alpine", "app-debian", "app-ubuntu"]
}

group "alpine" {
  targets = ["app-alpine"]
}

group "debian" {
  targets = ["app-debian"]
}

Running docker buildx bake builds all three variants. Running docker buildx bake alpine builds only the Alpine variant. Each variant has appropriate tags indicating its base image.

Single Dockerfile with Multiple Bases

If your Dockerfiles differ only in the base image, you can use a single Dockerfile with build arguments:

variable "VERSION" {
  default = "latest"
}

variable "REGISTRY" {
  default = "docker.io/myorg"
}

target "app-alpine" {
  inherits   = ["app-common"]
  tags       = ["${REGISTRY}/myapp:${VERSION}-alpine"]
  
  args = {
    BASE_IMAGE = "alpine:3.19"
    IMAGE_VARIANT = "alpine"
  }
}

target "app-debian" {
  inherits   = ["app-common"]
  tags       = ["${REGISTRY}/myapp:${VERSION}-debian", "${REGISTRY}/myapp:${VERSION}"]
  
  args = {
    BASE_IMAGE = "debian:bookworm-slim"
    IMAGE_VARIANT = "debian"
  }
}

target "app-common" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = ["linux/amd64", "linux/arm64"]
  
  args = {
    VERSION = VERSION
  }
}

group "default" {
  targets = ["app-alpine", "app-debian"]
}

Your Dockerfile uses the BASE_IMAGE argument:

ARG BASE_IMAGE=alpine:3.19
FROM ${BASE_IMAGE}

ARG VERSION
ARG IMAGE_VARIANT

LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.variant="${IMAGE_VARIANT}"

WORKDIR /app
COPY . .

RUN if [ "${IMAGE_VARIANT}" = "alpine" ]; then \
      apk add --no-cache ca-certificates; \
    else \
      apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*; \
    fi

ENTRYPOINT ["/app/myapp"]

This approach reduces duplication when the only difference between variants is the base image and package manager.

Runtime Version Matrix

Applications often need to support multiple runtime versions. A Python application might support Python 3.10, 3.11, and 3.12. A Node.js application might support Node 18, 20, and 21. Bake makes it easy to build a complete matrix.

Python Version Matrix Example

variable "VERSION" {
  default = "latest"
}

variable "REGISTRY" {
  default = "docker.io/myorg"
}

variable "PLATFORMS" {
  default = ["linux/amd64", "linux/arm64"]
}

variable "PYTHON_VERSIONS" {
  default = ["3.10", "3.11", "3.12"]
}

target "app-python-310" {
  inherits   = ["app-python-common"]
  tags       = [
    "${REGISTRY}/myapp:${VERSION}-python3.10",
    "${REGISTRY}/myapp:python3.10"
  ]
  
  args = {
    PYTHON_VERSION = "3.10"
    PYTHON_IMAGE = "python:3.10-slim"
  }
}

target "app-python-311" {
  inherits   = ["app-python-common"]
  tags       = [
    "${REGISTRY}/myapp:${VERSION}-python3.11",
    "${REGISTRY}/myapp:python3.11"
  ]
  
  args = {
    PYTHON_VERSION = "3.11"
    PYTHON_IMAGE = "python:3.11-slim"
  }
}

target "app-python-312" {
  inherits   = ["app-python-common"]
  tags       = [
    "${REGISTRY}/myapp:${VERSION}-python3.12",
    "${REGISTRY}/myapp:${VERSION}",
    "${REGISTRY}/myapp:latest"
  ]
  
  args = {
    PYTHON_VERSION = "3.12"
    PYTHON_IMAGE = "python:3.12-slim"
  }
}

target "app-python-common" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = PLATFORMS
  
  args = {
    VERSION = VERSION
  }
  
  labels = {
    "org.opencontainers.image.version" = VERSION
  }
}

group "default" {
  targets = ["app-python-310", "app-python-311", "app-python-312"]
}

group "python-latest" {
  targets = ["app-python-312"]
}

The corresponding Dockerfile:

ARG PYTHON_IMAGE=python:3.12-slim
FROM ${PYTHON_IMAGE}

ARG VERSION
ARG PYTHON_VERSION

LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.python.version="${PYTHON_VERSION}"

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "app.py"]

Advanced Bake Patterns

Once you’re comfortable with basic Bake configurations, several advanced patterns can make your builds even more powerful.

Matrix Builds with Dynamic Targets

For large build matrices, manually defining every combination becomes tedious. Whilst Bake doesn’t support true matrix generation in HCL, you can use environment variables and multiple Bake files to achieve similar results.

Create a base configuration docker-bake.hcl:

variable "VERSION" {
  default = "latest"
}

variable "REGISTRY" {
  default = "docker.io/myorg"
}

variable "PYTHON_VERSION" {
  default = "3.12"
}

variable "BASE_VARIANT" {
  default = "slim"
}

target "app" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = ["linux/amd64", "linux/arm64"]
  tags       = [
    "${REGISTRY}/myapp:${VERSION}-python${PYTHON_VERSION}-${BASE_VARIANT}"
  ]
  
  args = {
    PYTHON_IMAGE = "python:${PYTHON_VERSION}-${BASE_VARIANT}"
    VERSION      = VERSION
  }
}

Then use a shell script to invoke Bake multiple times with different variables:

#!/bin/bash
set -e

VERSIONS=("3.10" "3.11" "3.12")
VARIANTS=("slim" "alpine")

for version in "${VERSIONS[@]}"; do
  for variant in "${VARIANTS[@]}"; do
    echo "Building Python ${version} ${variant}"
    docker buildx bake \
      --set "*.PYTHON_VERSION=${version}" \
      --set "*.BASE_VARIANT=${variant}" \
      app
  done
done

Conditional Building

Sometimes you want to build certain targets only in specific conditions. Whilst Bake doesn’t support conditionals directly, you can use groups and selective building:

variable "BUILD_ARCH" {
  default = "all"
}

target "app-amd64" {
  inherits   = ["app-common"]
  platforms  = ["linux/amd64"]
  tags       = ["${REGISTRY}/myapp:${VERSION}-amd64"]
}

target "app-arm64" {
  inherits   = ["app-common"]
  platforms  = ["linux/arm64"]
  tags       = ["${REGISTRY}/myapp:${VERSION}-arm64"]
}

target "app-common" {
  context    = "."
  dockerfile = "Dockerfile"
}

group "all" {
  targets = ["app-amd64", "app-arm64"]
}

group "amd64" {
  targets = ["app-amd64"]
}

group "arm64" {
  targets = ["app-arm64"]
}

Build only AMD64 with docker buildx bake amd64, only ARM64 with docker buildx bake arm64, or both with docker buildx bake all.

Build Secrets and SSH Forwarding

Production builds often need access to private dependencies or repositories. Bake supports BuildKit secrets and SSH forwarding:

variable "VERSION" {
  default = "latest"
}

target "app" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = ["linux/amd64", "linux/arm64"]
  tags       = ["myapp:${VERSION}"]
  
  secret = [
    "id=npm_token,env=NPM_TOKEN",
    "id=github_token,env=GITHUB_TOKEN"
  ]
  
  ssh = ["default"]
}

In your Dockerfile, mount secrets:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./

# Mount npm token as secret for private registry access
RUN --mount=type=secret,id=npm_token \
    echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > .npmrc && \
    npm ci && \
    rm .npmrc

COPY . .

RUN npm run build

CMD ["npm", "start"]

For SSH access to private Git repositories:

FROM node:20-alpine

RUN apk add --no-cache git openssh-client

WORKDIR /app

# Mount SSH socket for private repo access
RUN --mount=type=ssh \
    mkdir -p ~/.ssh && \
    ssh-keyscan github.com >> ~/.ssh/known_hosts && \
    npm install git+ssh://git@github.com/myorg/private-package.git

COPY . .

CMD ["npm", "start"]

GitHub Actions Integration

Docker Bake integrates seamlessly with GitHub Actions, providing a clean way to build and push container images in CI/CD pipelines.

Basic GitHub Actions Workflow

Create .github/workflows/docker-build.yml:

name: Build and Push Docker Images

on:
  push:
    branches:
      - main
      - develop
    tags:
      - 'v*'
  pull_request:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: $

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Extract metadata
        id: meta
        run: |
          VERSION=latest
          if [[ $GITHUB_REF == refs/tags/v* ]]; then
            VERSION=${GITHUB_REF#refs/tags/v}
          elif [[ $GITHUB_REF == refs/heads/main ]]; then
            VERSION=main
          elif [[ $GITHUB_REF == refs/heads/* ]]; then
            VERSION=${GITHUB_REF#refs/heads/}
          fi
          
          echo "version=${VERSION}" >> $GITHUB_OUTPUT
          echo "commit_sha=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
          echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
      
      - name: Build images
        uses: docker/bake-action@v4
        with:
          files: |
            ./docker-bake.hcl
          set: |
            *.VERSION=$
            *.COMMIT_SHA=$
            *.REGISTRY=$/$
          push: $

This workflow builds your images on every push and pull request, pushing only when changes are merged to main or when tags are created.

Multi-Platform Builds in GitHub Actions

For multi-platform builds, configure QEMU emulation:

name: Build Multi-Platform Images

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: $

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
        with:
          platforms: 'arm64,arm'
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Extract version
        id: version
        run: |
          VERSION=${GITHUB_REF#refs/tags/v}
          echo "version=${VERSION}" >> $GITHUB_OUTPUT
      
      - name: Build and push multi-platform images
        uses: docker/bake-action@v4
        with:
          files: |
            ./docker-bake.hcl
          targets: default
          set: |
            *.VERSION=$
            *.REGISTRY=$/$
            *.PLATFORMS=linux/amd64,linux/arm64
          push: true

Matrix Builds in GitHub Actions

For building multiple variants, use GitHub Actions matrix strategy:

name: Build Image Matrix

on:
  push:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: $

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    
    strategy:
      matrix:
        python-version: ['3.10', '3.11', '3.12']
        base-variant: ['slim', 'alpine']
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Build and push
        uses: docker/bake-action@v4
        with:
          files: |
            ./docker-bake.hcl
          targets: app
          set: |
            *.PYTHON_VERSION=$
            *.BASE_VARIANT=$
            *.REGISTRY=$/$
          push: true

This workflow builds all combinations of Python versions and base variants in parallel.

Build Caching in CI

BuildKit caching dramatically speeds up CI builds. Configure cache exports and imports:

name: Build with Caching

on:
  push:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: $

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Build and push with cache
        uses: docker/bake-action@v4
        with:
          files: |
            ./docker-bake.hcl
          set: |
            *.REGISTRY=$/$
            *.cache-from=type=registry,ref=$/$:buildcache
            *.cache-to=type=registry,ref=$/$:buildcache,mode=max
          push: true

Update your docker-bake.hcl to support cache configuration:

variable "CACHE_FROM" {
  default = ""
}

variable "CACHE_TO" {
  default = ""
}

target "app" {
  context    = "."
  dockerfile = "Dockerfile"
  
  cache-from = CACHE_FROM != "" ? [CACHE_FROM] : []
  cache-to   = CACHE_TO != "" ? [CACHE_TO] : []
}

Reusable Workflow for Multiple Repositories

Create a reusable workflow that can be used across multiple repositories. In your organisation’s .github repository, create .github/workflows/docker-bake-reusable.yml:

name: Reusable Docker Bake Workflow

on:
  workflow_call:
    inputs:
      bake-file:
        description: 'Path to docker-bake.hcl file'
        required: false
        type: string
        default: './docker-bake.hcl'
      bake-target:
        description: 'Bake target to build'
        required: false
        type: string
        default: 'default'
      registry:
        description: 'Container registry'
        required: false
        type: string
        default: 'ghcr.io'
      platforms:
        description: 'Platforms to build for'
        required: false
        type: string
        default: 'linux/amd64,linux/arm64'
      push:
        description: 'Push images to registry'
        required: false
        type: boolean
        default: true
    secrets:
      registry-username:
        description: 'Registry username'
        required: false
      registry-password:
        description: 'Registry password'
        required: true

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    
    steps:
      - name: Checkout repository
        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: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Extract metadata
        id: meta
        run: |
          VERSION=latest
          if [[ $GITHUB_REF == refs/tags/v* ]]; then
            VERSION=${GITHUB_REF#refs/tags/v}
          elif [[ $GITHUB_REF == refs/heads/main ]]; then
            VERSION=main
          fi
          
          echo "version=${VERSION}" >> $GITHUB_OUTPUT
          echo "commit_sha=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
      
      - name: Build and push
        uses: docker/bake-action@v4
        with:
          files: $
          targets: $
          set: |
            *.VERSION=$
            *.COMMIT_SHA=$
            *.REGISTRY=$/$
            *.PLATFORMS=$
          push: $

Use it in your application repositories:

name: Build Docker Images

on:
  push:
    branches:
      - main
    tags:
      - 'v*'

jobs:
  build:
    uses: myorg/.github/.github/workflows/docker-bake-reusable.yml@main
    with:
      platforms: 'linux/amd64,linux/arm64'
      push: true
    secrets:
      registry-password: $

Complete Real-World Example

Let’s put everything together with a complete example: a Node.js application that supports multiple Node versions, multiple platforms, and builds both development and production variants.

Project Structure

.
├── docker-bake.hcl
├── docker-bake.override.hcl.example
├── Dockerfile
├── package.json
├── src/
│   └── app.js
└── .github/
    └── workflows/
        └── docker.yml

docker-bake.hcl

variable "VERSION" {
  default = "dev"
}

variable "REGISTRY" {
  default = "ghcr.io/myorg"
}

variable "COMMIT_SHA" {
  default = "unknown"
}

variable "BUILD_DATE" {
  default = ""
}

variable "NODE_VERSIONS" {
  default = ["18", "20", "21"]
}

variable "PLATFORMS" {
  default = ["linux/amd64", "linux/arm64"]
}

function "semver_tags" {
  params = [registry, app, version, node_version]
  result = [
    "${registry}/${app}:${version}-node${node_version}",
    "${registry}/${app}:node${node_version}"
  ]
}

function "prod_tags" {
  params = [registry, app, version, node_version]
  result = concat(
    semver_tags(registry, app, version, node_version),
    node_version == "20" ? [
      "${registry}/${app}:${version}",
      "${registry}/${app}:latest"
    ] : []
  )
}

# Node 18 targets
target "app-node18-dev" {
  inherits   = ["node-common"]
  tags       = ["${REGISTRY}/myapp:dev-node18"]
  args = {
    NODE_VERSION = "18"
    NODE_ENV = "development"
  }
  platforms = ["linux/amd64"]
}

target "app-node18-prod" {
  inherits   = ["node-common"]
  tags       = prod_tags(REGISTRY, "myapp", VERSION, "18")
  args = {
    NODE_VERSION = "18"
    NODE_ENV = "production"
  }
  output = ["type=registry,push=true"]
}

# Node 20 targets
target "app-node20-dev" {
  inherits   = ["node-common"]
  tags       = ["${REGISTRY}/myapp:dev-node20", "${REGISTRY}/myapp:dev"]
  args = {
    NODE_VERSION = "20"
    NODE_ENV = "development"
  }
  platforms = ["linux/amd64"]
}

target "app-node20-prod" {
  inherits   = ["node-common"]
  tags       = prod_tags(REGISTRY, "myapp", VERSION, "20")
  args = {
    NODE_VERSION = "20"
    NODE_ENV = "production"
  }
  output = ["type=registry,push=true"]
}

# Node 21 targets
target "app-node21-dev" {
  inherits   = ["node-common"]
  tags       = ["${REGISTRY}/myapp:dev-node21"]
  args = {
    NODE_VERSION = "21"
    NODE_ENV = "development"
  }
  platforms = ["linux/amd64"]
}

target "app-node21-prod" {
  inherits   = ["node-common"]
  tags       = prod_tags(REGISTRY, "myapp", VERSION, "21")
  args = {
    NODE_VERSION = "21"
    NODE_ENV = "production"
  }
  output = ["type=registry,push=true"]
}

# Common target configuration
target "node-common" {
  context    = "."
  dockerfile = "Dockerfile"
  platforms  = PLATFORMS
  
  args = {
    VERSION    = VERSION
    COMMIT_SHA = COMMIT_SHA
    BUILD_DATE = BUILD_DATE != "" ? BUILD_DATE : timestamp()
  }
  
  labels = {
    "org.opencontainers.image.version"    = VERSION
    "org.opencontainers.image.revision"   = COMMIT_SHA
    "org.opencontainers.image.created"    = BUILD_DATE != "" ? BUILD_DATE : timestamp()
    "org.opencontainers.image.source"     = "https://github.com/myorg/myapp"
    "org.opencontainers.image.vendor"     = "MyOrg"
  }
  
  cache-from = ["type=registry,ref=${REGISTRY}/myapp:buildcache"]
  cache-to   = ["type=registry,ref=${REGISTRY}/myapp:buildcache,mode=max"]
}

# Groups for different scenarios
group "dev" {
  targets = ["app-node18-dev", "app-node20-dev", "app-node21-dev"]
}

group "prod" {
  targets = ["app-node18-prod", "app-node20-prod", "app-node21-prod"]
}

group "node20" {
  targets = ["app-node20-dev", "app-node20-prod"]
}

group "default" {
  targets = ["app-node20-dev"]
}

Dockerfile

ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS base

ARG VERSION
ARG COMMIT_SHA
ARG BUILD_DATE
ARG NODE_ENV=production

LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.revision="${COMMIT_SHA}"
LABEL org.opencontainers.image.created="${BUILD_DATE}"

WORKDIR /app

# Dependencies stage
FROM base AS dependencies

COPY package*.json ./

RUN if [ "$NODE_ENV" = "production" ]; then \
      npm ci --only=production; \
    else \
      npm ci; \
    fi

# Build stage (for TypeScript or other build steps)
FROM dependencies AS build

COPY . .

RUN if [ "$NODE_ENV" = "production" ]; then \
      npm run build 2>/dev/null || true; \
    fi

# Production stage
FROM base AS production

ENV NODE_ENV=production

# Copy only production dependencies
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist 2>/dev/null || true
COPY package*.json ./
COPY src ./src

# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001 && \
    chown -R nodejs:nodejs /app

USER nodejs

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"

CMD ["node", "src/app.js"]

# Development stage
FROM dependencies AS development

ENV NODE_ENV=development

COPY . .

USER node

EXPOSE 3000

CMD ["npm", "run", "dev"]

GitHub Actions Workflow

name: Build and Push Docker Images

on:
  push:
    branches:
      - main
      - develop
    tags:
      - 'v*'
  pull_request:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: $

jobs:
  build-dev:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request' || github.ref == 'refs/heads/develop'
    permissions:
      contents: read
      packages: write
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Build development images
        uses: docker/bake-action@v4
        with:
          files: ./docker-bake.hcl
          targets: dev
          set: |
            *.REGISTRY=$/$
            *.COMMIT_SHA=$
          push: $
  
  build-prod:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
    permissions:
      contents: read
      packages: write
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
        with:
          platforms: 'arm64'
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: $
          username: $
          password: $
      
      - name: Extract version
        id: version
        run: |
          if [[ $GITHUB_REF == refs/tags/v* ]]; then
            VERSION=${GITHUB_REF#refs/tags/v}
          else
            VERSION=main-${GITHUB_SHA::8}
          fi
          echo "version=${VERSION}" >> $GITHUB_OUTPUT
      
      - name: Build and push production images
        uses: docker/bake-action@v4
        with:
          files: ./docker-bake.hcl
          targets: prod
          set: |
            *.VERSION=$
            *.COMMIT_SHA=$
            *.REGISTRY=$/$
            *.BUILD_DATE=$
          push: true

Best Practices and Tips

After working with Docker Bake across numerous projects, several patterns have emerged as particularly effective.

Keep Bake Files Declarative

Resist the temptation to add too much logic to your Bake files. They work best when they declare what you want to build, not how to build it. Complex conditionals and computations belong in CI scripts or separate tooling, not in Bake configuration.

Use Inheritance Extensively

The inherits field is one of Bake’s most powerful features. Define common configuration once in a base target, then inherit from it in specific targets. This reduces duplication and ensures consistency across related builds.

Organise Targets into Logical Groups

Groups make it easy to build related targets together. Create groups for different scenarios: dev for development builds, prod for production, ci for continuous integration. Users can then run docker buildx bake dev without needing to know which specific targets that includes.

Version Your Bake Files

Treat your docker-bake.hcl files as code. Version them in Git, review changes through pull requests, and test modifications before merging. Bake configuration changes can have significant impact on your build process.

Document Your Targets

Add comments explaining what each target does and when to use it. Future maintainers (including yourself) will appreciate understanding the purpose of app-python-311-alpine-optimised without having to reverse-engineer the configuration.

Use Override Files for Local Development

Create a docker-bake.override.hcl.example file that developers can copy to docker-bake.override.hcl for local customisation. Bake automatically merges override files, allowing developers to adjust registry paths or platforms without modifying the main configuration.

# docker-bake.override.hcl.example
variable "REGISTRY" {
  default = "localhost:5000"
}

variable "PLATFORMS" {
  default = ["linux/amd64"]  # Build only local platform for speed
}

Validate Your Bake Files

Before committing changes, validate your Bake files:

docker buildx bake --print

This shows the resolved configuration without actually building anything, catching syntax errors and misconfigurations early.

Cache Aggressively in CI

Build caching can reduce CI build times from minutes to seconds. Configure both cache-from and cache-to in your targets and ensure your CI environment pushes cache layers to a registry.

Monitor Build Times

Track how long your builds take and optimise the slow ones. Multi-stage builds with effective layer caching make the biggest difference. BuildKit’s build statistics show where time is spent.

Troubleshooting Common Issues

Even with careful configuration, you’ll occasionally encounter issues. Here are solutions to the most common problems.

Bake Can’t Find Dockerfile

Error: failed to solve: failed to read dockerfile

This usually means the context or dockerfile path is wrong. Remember that paths in Bake files are relative to the Bake file’s location, not your current directory. Use ./ explicitly:

target "app" {
  context    = "."
  dockerfile = "./Dockerfile"
}

Platforms Not Building

If multi-platform builds fail, ensure you have the correct builder:

docker buildx create --name multiplatform --driver docker-container --use
docker buildx inspect --bootstrap

Then verify your builder supports the platforms you need:

docker buildx inspect multiplatform

Cache Not Working

If builds aren’t using cache despite configuration, check that:

  1. Cache references are accessible (correct registry, authentication)
  2. You’re using mode=max for cache-to to cache all layers
  3. Your Dockerfile uses layer caching effectively (copy dependencies before code)

Variables Not Resolving

If variables show as literal strings instead of values, check that:

  1. You’re using ${} syntax, not $ alone: ${VERSION}, not $VERSION
  2. Variables are defined before use
  3. You’re not mixing environment variables with Bake variables (prefix env vars with $)

Build Fails in CI But Works Locally

This often indicates environmental differences:

  1. Check platform architecture (CI might be different from local)
  2. Verify secrets and SSH keys are available in CI
  3. Ensure network access for downloading dependencies
  4. Check resource limits (CI runners might have less memory/CPU)

Conclusion

Docker Bake transforms container image builds from imperative scripts into declarative configuration. Instead of maintaining complex shell scripts that orchestrate dozens of docker build commands, you define your complete build matrix in a single file that serves as documentation, automation, and contract all at once.

The patterns we’ve explored—multi-platform builds, multiple base images, runtime version matrices, sophisticated tagging strategies—all become manageable through Bake’s inheritance, functions, and groups. Your build configuration becomes more maintainable, easier to understand, and more reliable.

For production container workflows, Bake isn’t just a convenience, it’s a force multiplier that lets you maintain complex build matrices without drowning in complexity. The GitHub Actions integrations show how Bake fits naturally into modern CI/CD pipelines, providing consistent builds from local development through production deployment.

As your containerisation needs grow, Docker Bake grows with you. Start simple with basic targets and groups, then progressively add platforms, variants, and optimisations as requirements evolve. The declarative configuration ensures that complexity remains manageable no matter how large your build matrix becomes.

The investment in learning Docker Bake pays dividends quickly. Your builds become faster through better caching, more reliable through consistent configuration, and more maintainable through clear declarative structure. You’ve built a foundation that will scale from a handful of images to hundreds of build variants without requiring fundamental rewrites.

Leave a comment