🐋 Hardened OCI Images

8 min read

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.

AspectDocker hardened imagesChainguard images
ApproachCurated, slimmed-down Debian/Ubuntu basesBuilt from scratch with apk packages on a Wolfi base
MinimalityReduced — removes unnecessary packages but retains OS layerExtreme — no shell, no package manager by default
ReproducibilityLimited — depends on upstream apt repos at build timeHigh — pinned package versions, lockfiles, deterministic builds
SBOM & provenanceAvailable via Docker ScoutBuilt-in SBOMs (SPDX), Sigstore signatures on every image
Update cadenceFollows upstream Debian/Ubuntu release cyclesDaily rebuilds, patches within hours of upstream fixes
CompatibilityDrop-in replacement for standard Docker imagesMay require adaptation — no shell means no docker exec debugging
Best-fit use casesTeams migrating from standard images with minimal disruptionSecurity-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.

  1. Package your app with melange — define a melange.yaml that compiles your Go binary and produces an .apk
  2. Publish the apk — output goes to a local repository directory (or an apk registry if you have one)
  3. Define your image with apko — write an apko.yaml that references your apk plus any runtime dependencies
  4. Build and publish with apko — produces an OCI image and pushes it to your registry
  5. Sign and verify in CI — use Cosign to sign the image, verify the SBOM
  6. 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 install or 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’s paths directive.
  • ▶️ 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.yaml to 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 -dev variant with a shell for troubleshooting. Chainguard publishes :latest-dev tags 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.


Further Reading