Mastering Docker Bake: Building Multi-Platform Images at Scale
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:
- Cache references are accessible (correct registry, authentication)
- You’re using
mode=maxfor cache-to to cache all layers - Your Dockerfile uses layer caching effectively (copy dependencies before code)
Variables Not Resolving
If variables show as literal strings instead of values, check that:
- You’re using
${}syntax, not$alone:${VERSION}, not$VERSION - Variables are defined before use
- 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:
- Check platform architecture (CI might be different from local)
- Verify secrets and SSH keys are available in CI
- Ensure network access for downloading dependencies
- 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