Because sometimes the best Dockerfile is no Dockerfile at all.
The Never-Ending Quest for Smaller Attack Surfaces
One of the biggest challenges in modern infrastructure is minimizing the attack surface. In the container world, that essentially means picking the right base image with the smallest imaginable attack vector. If your container image ships with curl, wget, and a full-blown package manager, congratulations β you've basically handed attackers a Swiss Army knife. πͺ
Over the past few years, several providers have stepped up to deliver hardened, minimal images. You've probably bumped into a few of them:
- Google Distroless β the OG of "no shell, no package manager, no problem"
- Chainguard β focused on supply chain security with SBOM-first images
- Bitnami β curated, well-maintained stacks with a focus on application images
- Docker Hardened Images (DHI) β the newest player, and the one we're diving deep into today
They're all great, and most of them share a common philosophy: remove the package manager from inside the container. No apt-get. No apk. No yum. This definitely makes things more secure β after all, if an attacker gets shell access but can't install tools, their day just got a lot harder.
But here's the catch: it's very opinionated. Most of these images only provide the core components and libraries. Need an extra shared library? Want a specific package installed? Tough luck. You're stuck writing multi-stage Dockerfiles, copying binaries around, or maintaining your own fork. It's like being given a perfectly locked-down house... with no way to add a window.
With DHI, the picture is fundamentally different. It provides a YAML-based syntax that lets you declaratively define the content of your image β and build it with a standard docker buildx build command. No Dockerfile gymnastics. No multi-stage copy chains. Just declare what you want and build.
Let's dig into how this actually works.
What Is DHI, and How Does It Work?
Docker Hardened Images are Docker's answer to the hardened base image problem. They're minimal, security-focused images that strip away unnecessary components β but unlike their predecessors, they come with a built-in, transparent customization mechanism.
The core idea: instead of writing a traditional Dockerfile with RUN apt-get install ... commands, you define your image's contents in a YAML definition file. This YAML file is the build input. The magic happens on the very first line:
# syntax=dhi.io/build:2-debian13
or for Alpine-based images:
# syntax=dhi.io/build:2-alpine3.23
That # syntax= directive tells Docker's BuildKit to use the DHI builder frontend instead of the standard Dockerfile parser. From there, the rest of the file is pure YAML β declaring packages, users, environment variables, artifacts, and more. No RUN, no COPY, no FROM. Just a declarative spec of what your image should contain.
Supported builders: Currently only Debian and Alpine builders are available. Check the DHI Build images page for the latest supported tags and versions.
Think of it as Infrastructure as Code, but for your container's DNA. Or as I like to call it: "Dockerfile as YAML" β because everything eventually becomes YAML in DevOps. It's the circle of life. π¦
You build it with:
docker buildx build -f my-image.yaml \
--sbom=generator=dhi.io/scout-sbom-indexer:1 --provenance=1 \
--tag my-custom-image:latest --load .
That's it. A standard docker buildx build command, pointed at a YAML file instead of a Dockerfile. And you get SBOM and provenance attestation baked in for free.
Schema reference: The full JSON Schema for DHI YAML definition files is published atgithub.com/docker-hardened-images/catalog/.spec.json. This defines every supported field β fromcontents.packagesandcontents.artifactstocontents.builds(with pipeline steps likego/build@v1,deb/build@v1, and more). We'll cover how to wire this into your editor later in the post.
Example 1: The Alpine Base β Anatomy of a DHI YAML File
Let's look at a real example straight from the DHI catalog repository. This is the actual Alpine 3.23 base image definition:
# syntax=dhi.io/build:2-alpine3.23
name: Alpine 3.23 Base
image: dhi.io/alpine-base
variant: runtime
tags:
- "3.23"
- 3.23-alpine3.23
platforms:
- linux/amd64
- linux/arm64
dates:
release: "2025-12-04"
end-of-life: "2027-11-01"
vars:
APK_MAJOR_MINOR_VERSION: "1.37"
APK_MAJOR_VERSION: "1"
APK_VERSION: 1.37.0
DHI_TAGS_OVERWRITE: "true"
VERSION: 1.37.0-r30
contents:
repositories:
- https://dl-cdn.alpinelinux.org/alpine/v3.23/main
- https://dl-cdn.alpinelinux.org/alpine/v3.23/community
packages:
- alpine-baselayout-data
- busybox=1.37.0-r30
- ca-certificates-bundle
accounts:
run-as: nonroot
users:
- name: nonroot
uid: 65532
gid: 65532
groups:
- name: nonroot
gid: 65532
members:
- nonroot
os-release:
name: Docker Hardened Images (Alpine)
id: alpine
version-id: "3.23"
pretty-name: Docker Hardened Images/Alpine Linux v3.23
home-url: https://docker.com/products/hardened-images/
bug-report-url: https://docker.com/support/
environment:
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
annotations:
org.opencontainers.image.description: A minimal Alpine base image
cmd:
- /bin/sh
Look at that contents.packages section. Three packages. That's the entire image. busybox, ca-certificates-bundle, and the Alpine base layout data. No package manager in the final image. No shell beyond what BusyBox provides. No wget, no curl, no extra attack surface.
Now here's the powerful part: want to customize it? Fork the YAML, add your packages to the contents.packages list, and build. Need libpq for PostgreSQL client connections? Add libpq to the packages. Need libxml2? Same deal. The DHI builder resolves dependencies from the declared repositories, installs only what you specified, and produces a minimal image.
Want to build this yourself? It's literally one command:
docker buildx build \
https://raw.githubusercontent.com/docker-hardened-images/catalog/refs/heads/main/image/alpine-base/alpine-3.23/3.23.yaml \
--sbom=generator=dhi.io/scout-sbom-indexer:1 --provenance=1 \
--tag my-alpine-base:3.23 --load
Yes, you can point docker buildx build directly at a raw GitHub URL. No cloning required. The future is now. π
Example 2: Python Runtime β The Dev vs Runtime Pattern
The real power of DHI shines when you look at language runtime images. Let's examine the Python image definitions, which demonstrate the dev vs runtime variant pattern that DHI uses across all its images.
The runtime variant (dhi.io/python:3.14-debian13) includes only the bare minimum to run Python:
# syntax=dhi.io/build:2-debian13
name: Python 3.14.x
image: dhi.io/python
variant: runtime
tags:
- "3"
- "3.14"
- 3.14.3
- 3-debian13
- 3.14-debian13
- 3.14.3-debian13
platforms:
- linux/amd64
- linux/arm64
dates:
release: "2025-10-07"
end-of-life: "2030-10-31"
vars:
PKG_PYTHON_REFERENCE: dhi/pkg-python:3.14.3-debian13@sha256:7e9b1dd7704f90c5...
PYTHON_MAJOR_MINOR_VERSION: "3.14"
PYTHON_MAJOR_VERSION: "3"
PYTHON_VERSION: 3.14.3
VERSION: 3.14.3
contents:
packages:
- '!gnupg2'
- '!gpg'
- '!gpgconf'
- '!libassuan'
# ... more exclusions omitted for brevity
- '!usrmerge'
- base-files
- ca-certificates
- libbz2-1.0
- libcrypt1
- libdb5.3t64
- libffi8
- libgcc-s1
- liblzma5
- libncursesw6
- libreadline8
- libsqlite3-0
- libstdc++6
- libtinfo6
- libuuid1
- ncurses-base
- ncurses-bin
- netbase
- tzdata
- zlib1g
artifacts:
- name: dhi.io/pkg-python:3.14.3-debian13@sha256:7e9b1dd7704f90c5...
includes:
- opt/**
uid: 0
gid: 0
accounts:
run-as: nonroot
users:
- name: nonroot
uid: 65532
gid: 65532
groups:
- name: nonroot
gid: 65532
members:
- nonroot
os-release:
name: Docker Hardened Images (Debian)
id: debian
version-id: "13"
version-codename: trixie
pretty-name: Docker Hardened Images/Debian GNU/Linux 13 (trixie)
home-url: https://docker.com/products/hardened-images/
bug-report-url: https://docker.com/support/
environment:
LD_LIBRARY_PATH: /opt/python/lib
PATH: /opt/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PYTHON_VERSION: 3.14.3
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
annotations:
org.opencontainers.image.description: A minimal Python image
org.opencontainers.image.licenses: PSF-2.0
cmd:
- python3
A few things worth calling out here. No shell. No package manager. No apt. No pip even β it's a runtime image! It runs as nonroot (UID 65532) by default. The Python interpreter itself is pulled in via the contents.artifacts section from a pre-built artifact (dhi/pkg-python) with a pinned SHA256 digest, so every build is reproducible down to the byte.
Notice the !-prefixed packages? Those are exclusions β they tell the DHI builder to explicitly not include these packages even if they're pulled in as transitive dependencies. It's like a .gitignore for your container. Pretty elegant.
The dev variant (dhi.io/python:3.14-debian13-dev) is a different story β it includes everything you need to build Python packages with native extensions:
# syntax=dhi.io/build:2-debian13
name: Python 3.14.x (dev)
image: dhi.io/python
variant: dev
tags:
- 3-dev
- 3.14-dev
- 3.14.3-dev
- 3-debian13-dev
- 3.14-debian13-dev
- 3.14.3-debian13-dev
# ... same platforms, dates, vars ...
contents:
packages:
# ... same exclusion pattern ...
- apt
- base-files
- bash
- ca-certificates
- coreutils
- diffutils
- dpkg
- findutils
- grep
- libbz2-1.0
- libc-bin
- libcrypt1
- libdb5.3t64
- libffi8
- libgcc-s1
- liblzma5
- libncursesw6
- libreadline8
- libsqlite3-0
- libstdc++6
- libtinfo6
- libuuid1
- mawk
- ncurses-base
- ncurses-bin
- netbase
- perl-base
- sed
- tzdata
- util-linux
- zlib1g
artifacts:
- name: dhi.io/pkg-python:3.14.3-debian13@sha256:7e9b1dd7704f90c5...
includes:
- opt/**
uid: 0
gid: 0
accounts:
root: true
run-as: root
users:
- name: nonroot
uid: 65532
gid: 65532
- name: _apt
uid: 42
gid: 65532
# ...
environment:
DEBIAN_FRONTEND: noninteractive
LD_LIBRARY_PATH: /opt/python/lib
PATH: /opt/python/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PYTHON_VERSION: 3.14.3
SSL_CERT_FILE: /etc/ssl/certs/ca-certificates.crt
cmd:
- python3
See the difference? The dev variant runs as root, includes bash, apt, dpkg, grep, sed, and the rest of the build tooling you'd need. It even has the _apt system user (UID 42) for APT cache management. But it's intended for build stages only β your final production image should always use the runtime variant.
The multi-stage workflow looks like this:
# Build stage β use the dev variant with full tooling
FROM dhi.io/python:3.14-debian13-dev AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Runtime stage β use the minimal runtime variant
FROM dhi.io/python:3.14-debian13
WORKDIR /app
COPY --from=builder /opt/python/lib/python3.14/site-packages \
/opt/python/lib/python3.14/site-packages
COPY . .
CMD ["python3", "app.py"]
Your production image gets zero CVEs from unnecessary packages. Your security team gets a good night's sleep. Everyone wins.
DevOps proverb: "Give an engineer a Dockerfile, and they'll build an image. Give them YAML, and they'll build an empire... of indentation errors." π
Editor Support: VS Code Schema Validation
Since DHI definitions are just YAML files with a well-defined structure, you can get full autocompletion and validation in VS Code by wiring up the JSON Schema. Add this to your .vscode/settings.json:
{
"yaml.schemas": {
"https://raw.githubusercontent.com/docker-hardened-images/catalog/refs/heads/main/.spec.json": [
"image/**/*.yaml",
"*.dhi.yaml"
]
}
}
This requires the YAML extension by Red Hat (redhat.vscode-yaml). Once configured, you'll get:
- Autocomplete for all DHI fields (
contents,accounts,environment,paths,contents.builds, etc.) - Validation against the spec β mistype
varaintinstead ofvariantand you'll see a red squiggly before you waste a build cycle - Hover docs for each field pulled from the schema descriptions
- Enum hints for fields like
variant(dev,runtime,helm) andplatforms(linux/amd64,linux/arm64)
If you use a naming convention like *.dhi.yaml for your custom definitions, the second glob in the config will catch those too. Adjust the patterns to match your project layout.
You can also set this up project-wide by committing the .vscode/settings.json to your repo, so every team member gets the same editor experience. No more guessing whether it's entrypoint or entry-point (it's entrypoint, by the way β camelCase fans, I'm sorry). π«
Scaling Up with Docker Bake
Building a single DHI image is great. But what if you're running a microservices architecture with a custom Python API, a Node.js frontend, and an Nginx reverse proxy β each needing its own hardened base image? Running three separate docker buildx build commands gets old fast.
Enter Docker Bake. Bake lets you define multiple build targets in a single docker-bake.hcl file and build them all in parallel with one command. And since DHI YAML files work as dockerfile inputs to BuildKit, they slot right into a Bake configuration.
Here's what a docker-bake.hcl file looks like for a multi-service project:
# docker-bake.hcl
variable "TAG" {
default = "latest"
}
variable "REGISTRY" {
default = "myregistry.io"
}
group "default" {
targets = ["api", "frontend", "proxy"]
}
# Python API β built from a DHI YAML definition
target "api" {
dockerfile = "images/api.dhi.yaml"
tags = ["${REGISTRY}/api:${TAG}"]
platforms = ["linux/amd64", "linux/arm64"]
attest = [
"type=provenance,mode=max",
"type=sbom,generator=dhi.io/scout-sbom-indexer:1"
]
}
# Node.js frontend β standard Dockerfile using DHI base
target "frontend" {
dockerfile = "images/frontend/Dockerfile"
context = "images/frontend"
tags = ["${REGISTRY}/frontend:${TAG}"]
platforms = ["linux/amd64", "linux/arm64"]
attest = [
"type=provenance,mode=max",
"type=sbom,generator=dhi.io/scout-sbom-indexer:1"
]
}
# Nginx proxy β another DHI YAML definition
target "proxy" {
dockerfile = "images/proxy.dhi.yaml"
tags = ["${REGISTRY}/proxy:${TAG}"]
platforms = ["linux/amd64", "linux/arm64"]
attest = [
"type=provenance,mode=max",
"type=sbom,generator=dhi.io/scout-sbom-indexer:1"
]
}Now you can build everything in parallel:
# Build all targets
docker buildx bake
# Build just the API image
docker buildx bake api
# Override the tag from the CLI or environment
TAG=v1.2.3 docker buildx bake
# Preview the build plan without executing
docker buildx bake --print
The beauty here is that you can mix and match: some targets can point to DHI YAML definitions (like api and proxy), while others use traditional Dockerfiles that reference DHI base images in their FROM instructions (like frontend). Bake doesn't care β it delegates each target to BuildKit, which knows how to handle both.
This pattern also works perfectly with target inheritance for shared config. If all your images need the same attestation settings:
target "_defaults" {
platforms = ["linux/amd64", "linux/arm64"]
attest = [
"type=provenance,mode=max",
"type=sbom,generator=dhi.io/scout-sbom-indexer:1"
]
}
target "api" {
inherits = ["_defaults"]
dockerfile = "images/api.dhi.yaml"
tags = ["${REGISTRY}/api:${TAG}"]
}
target "proxy" {
inherits = ["_defaults"]
dockerfile = "images/proxy.dhi.yaml"
tags = ["${REGISTRY}/proxy:${TAG}"]
}
Targets prefixed with _ are hidden by convention β they won't show up when you run docker buildx bake --list=targets, but they work great as shared base configurations. Think of them as abstract classes for your builds. (Yes, I just used an OOP metaphor for container builds. No, I'm not apologizing.)
Why This Matters: Security and Maintenance
Let's talk about why this approach is genuinely better, beyond just saving you a few lines of Dockerfile syntax.
Declarative Over Imperative
Traditional Dockerfiles are imperative: "run this, then copy that, then delete those." DHI definitions are declarative: "I want these packages in my image." The build system figures out the rest. This means fewer opportunities for human error β no forgotten cleanup commands, no leftover cache directories inflating your image size.
Zero-Known CVEs at Publish Time
DHI images are published with zero known CVEs. When Docker updates a base image to patch a vulnerability, your customized image inherits the fix automatically on your next build. You're not maintaining a fork. You're not pinning to a specific SHA that's three months behind on patches. You just rebuild, and you're current.
Full Supply Chain Transparency
Every DHI build can include SBOM generation and provenance attestation out of the box (via the --sbom and --provenance flags, or the attest attribute in Bake). No extra tooling. No separate scanning step. Your compliance team just stopped crying.
Reproducibility and Auditability
The YAML file is version-controllable, reviewable, and diffable. When someone on your team asks, "Why does our image have libsqlite3-0 in it?", the answer is right there in the config. No more archaeology through 47-line Dockerfiles with cryptic RUN commands.
The entire DHI catalog is open-source on GitHub, so you can inspect exactly how every image is built. Try doing that with most other hardened image providers.
How DHI Fits Into Your CI/CD Pipeline
Integrating DHI into your existing pipeline is straightforward. Here's a GitHub Actions example using Bake:
# .github/workflows/build-image.yml
name: Build Custom DHI Images
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DHI registry
run: docker login dhi.io -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_TOKEN }}
- name: Build all images with Bake
run: |
TAG=${{ github.sha }} \
docker buildx bake --load
- name: Scan with Docker Scout
run: |
docker scout compare myregistry.io/api:${{ github.sha }}
- name: Push all images
run: |
TAG=${{ github.sha }} \
docker buildx bake --push
Notice how the Bake file handles all the complexity β targets, tags, platforms, attestations β while the CI pipeline stays clean and readable. One command to build, one to push. The kind of pipeline that makes your on-call rotation slightly less terrifying.
When Should You Consider DHI?
DHI customizations are a great fit when:
- You need hardened base images but have dependencies beyond what's included out of the box
- You're tired of maintaining complex multi-stage Dockerfiles just to add a shared library
- Your security team demands minimal images and zero-CVE reports
- You want a single, auditable source of truth (the YAML file) for what's in your base image
- You need FIPS or STIG compliance variants (available with DHI Enterprise)
It might not be the right choice if your workflow is already deeply invested in another hardened image provider, or if you need image types that aren't yet in the DHI catalog.
Wrapping Up
The hardened image space has matured significantly, and DHI brings something genuinely new to the table: customization without compromise. You get the security benefits of a minimal, package-manager-free runtime image, with the flexibility to declaratively specify exactly what your application needs β all in a clean YAML file that BuildKit understands natively.
Pair that with editor schema validation for a tight authoring loop, Docker Bake for parallel multi-image builds, and you've got a workflow that's both secure and pleasant to work with. (A rare combination in DevOps β usually you pick one.)
No more Dockerfile spaghetti. No more copying .so files between build stages like a digital archaeologist. No more choosing between "secure" and "actually works."
Just YAML. Because in DevOps, the answer is always YAML. Well, that or "have you tried turning it off and on again?" π³
Want to explore the full catalog? Check out the DHI catalog on Docker Hub or dive into the definition files on GitHub. DHI Free is available to everyone β no subscription required.
Comments ()