🐋 Hardened OCI Images
Why Hardened Images Matter
Every container image you ship is an attack surface. A typical Debian- or Ubuntu-based image carries hundreds of packages — shells, package managers, compilers, libraries — most of which your application never touches. But vulnerability scanners don’t care about intent. They report every CVE in every installed package, and attackers don’t discriminate either. A single exploitable binary in your base image is enough.
Hardened images flip the approach: start with nothing and add only what your application needs to run. No shell, no package manager, no leftover build tools. The result is a smaller image with fewer CVEs, a predictable software bill of materials (SBOM), and a surface that’s actually auditable. If you’ve ever triaged a Grype scan with 200+ findings on a base image you don’t control, you know why this matters.
There’s a second dimension beyond security: reproducibility. If you can’t rebuild the same image from the same inputs and get the same output, you can’t verify what’s running in production. Traditional Dockerfiles are inherently non-reproducible — apt-get update gives you different packages depending on when you run it. Hardened image tooling addresses this head-on with pinned sources, locked dependencies, and deterministic builds.
Docker Hardened Images vs Chainguard Images
Both Docker and Chainguard offer hardened base images, but they take fundamentally different approaches.
| Aspect | Docker hardened images | Chainguard images |
|---|---|---|
| Approach | Curated, slimmed-down Debian/Ubuntu bases | Built from scratch with apk packages on a Wolfi base |
| Minimality | Reduced — removes unnecessary packages but retains OS layer | Extreme — no shell, no package manager by default |
| Reproducibility | Limited — depends on upstream apt repos at build time | High — pinned package versions, lockfiles, deterministic builds |
| SBOM & provenance | Available via Docker Scout | Built-in SBOMs (SPDX), Sigstore signatures on every image |
| Update cadence | Follows upstream Debian/Ubuntu release cycles | Daily rebuilds, patches within hours of upstream fixes |
| Compatibility | Drop-in replacement for standard Docker images | May require adaptation — no shell means no docker exec debugging |
| Best-fit use cases | Teams migrating from standard images with minimal disruption | Security-critical workloads, compliance-heavy environments, supply-chain-aware orgs |
Docker’s hardened images are the pragmatic choice when you need a quick improvement over stock base images. Chainguard images are the choice when you want to minimize CVEs to near-zero and need verifiable provenance baked into every layer.
melange and apko: The Building Blocks
If you want to build your own hardened images — not just consume Chainguard’s — you need two tools: melange and apko. They’re separate tools that complement each other in a clean pipeline.
melange is a package builder. It takes your source code (or a tarball, or a Go module) and produces an .apk package — the same format used by Alpine and Wolfi. You define the build steps, dependencies, and metadata in a YAML file. Think of it as a declarative dpkg-buildpackage or rpmbuild, but designed for minimal, reproducible output.
apko is an image assembler. It takes a list of .apk packages and assembles them into an OCI image — no Dockerfile, no RUN commands, no shell. You declare which packages go into the image, what user it runs as, and what the entrypoint is. apko resolves dependencies, installs packages, and produces a deterministic image with an SBOM baked in.
The pipeline is straightforward: melange builds your app into a package, apko assembles that package (plus any runtime dependencies) into a minimal OCI image.
Step-by-Step Workflow
Here’s the end-to-end flow for packaging a Go web app and shipping it as a hardened OCI image.
- Package your app with melange — define a
melange.yamlthat compiles your Go binary and produces an.apk - Publish the apk — output goes to a local repository directory (or an apk registry if you have one)
- Define your image with apko — write an
apko.yamlthat references your apk plus any runtime dependencies - Build and publish with apko — produces an OCI image and pushes it to your registry
- Sign and verify in CI — use Cosign to sign the image, verify the SBOM
- Runtime hardening — run as non-root, read-only filesystem, no capabilities
Example: A Go Web App
melange.yaml
package:
name: myapp
version: 1.0.0
epoch: 0
description: A simple Go web server
copyright:
- license: MIT
environment:
contents:
packages:
- go
- busybox # provides basic build utilities
- ca-certificates-bundle
pipeline:
- uses: go/build
with:
packages: ./cmd/server
output: myapp
Key points: epoch tracks rebuilds of the same version. The go/build pipeline is a built-in melange pipeline that handles GOMODCACHE, cross-compilation flags, and stripping. The output is a statically linked binary.
Build it:
melange build melange.yaml \
--arch x86_64 \
--signing-key melange.rsa \
--out-dir ./packages/
This produces packages/x86_64/myapp-1.0.0-r0.apk and a signed index.
apko.yaml
contents:
packages:
- myapp
- ca-certificates-bundle=20251003-r3
repositories:
- ./packages/ # local repo from melange build
- https://packages.wolfi.dev/os
accounts:
groups:
- groupname: app
gid: 1000
users:
- username: app
uid: 1000
gid: 1000
run-as: 1000
entrypoint:
command: /usr/bin/myapp
archs:
- x86_64
Key points: no RUN steps — apko doesn’t support them. The accounts block creates a non-root user. ca-certificates-bundle is included so the app can make HTTPS calls. The package is pinned to the fixed version 20251003-r3. The repositories field points to both the local melange output and the Wolfi package repo for runtime dependencies.
Build it:
apko build apko.yaml \
myapp:1.0.0 \
myapp.tar \
--keyring-append melange.rsa.pub
This produces myapp.tar (an OCI image) and an SBOM in SPDX format. Push it to your registry:
crane push myapp.tar ghcr.io/yourorg/myapp:1.0.0
CI Signing and Verification
# Sign with Cosign (keyless, using your CI's OIDC identity)
cosign sign ghcr.io/yourorg/myapp:1.0.0
# Verify the SBOM
cosign verify-attestation ghcr.io/yourorg/myapp:1.0.0 \
--type spdxjson \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Practical Tips and Pitfalls
- ▶️ No RUN steps in apko. This is the biggest mental shift. You can’t
apt-get installor run arbitrary commands during image assembly. Everything must be a pre-built apk. If you need a config file baked in, package it with melange or use apko’spathsdirective. - ▶️ Pin your sources. melange supports pinning source tarballs by SHA256 and Go modules by
go.sum. You can also pin the apk versions in your apko file (see the example above). Unpinned sources break reproducibility and open you up to supply-chain attacks. - ▶️ Watch for dependency creep. Adding one apk can pull in transitive dependencies you didn’t expect. Run
apko show-packages apko.yamlto audit what’s actually going into your image before you build. - ▶️ Your CI pipeline will need rework. Dockerfile-based pipelines don’t translate 1:1. You’ll need separate stages for melange and apko, signing key management, and possibly a local apk repository. Plan for this upfront.
- ▶️ Testing and repro checks. Build the same image twice from the same inputs and compare digests. If they differ, something isn’t pinned. apko is designed for reproducibility, but only if you lock everything down — package versions, repository snapshots, architectures.
- ▶️ Debugging without a shell. Your production image won’t have
sh. Use ephemeral debug containers (kubectl debug) or build a separate-devvariant with a shell for troubleshooting. Chainguard publishes:latest-devtags for exactly this reason.
When to Choose What
Stick with Docker hardened images if you need a quick security improvement over stock base images, your team relies on Dockerfile workflows, and you’re not yet ready to rethink your build pipeline. Docker Scout gives you vulnerability scanning and SBOMs without changing your tooling.
Go with Chainguard base images if you want near-zero CVE base images out of the box, need verifiable provenance and signatures, and can accept the trade-off of no-shell images. This is the fastest path to hardened images without building your own tooling.
Build your own with melange + apko if you need full control over what goes into your images, want reproducible builds tied to your own supply chain, or have custom software that doesn’t fit into existing base images. This is the most effort upfront but gives you the most control and the smallest possible attack surface.
Try It
The melange + apko workflow is surprisingly approachable once you get past the “no Dockerfile” mindset. Start with a simple Go or Rust binary, package it with melange, assemble it with apko, and compare the Grype scan results against your current Dockerfile-based image. The difference is usually dramatic. If you want a working example repo with CI integration, drop a comment below — happy to share.