No More Dockerfiles? Reproducible, Secure Container Builds with Nix, Buildpacks, and apko in 2025
Dockerfiles are the assembly language of the container world: expressive and ubiquitous, but low-level, brittle, and easy to get subtly wrong. In 2025, the ecosystem has matured around higher-level, policy-driven build tools that generate OCI images with deterministic outputs, first-class SBOMs, and supply-chain provenance. If you want fewer flaky builds, smaller images, and auditable pipelines—with less maintenance—there are better defaults than hand-rolled Dockerfiles.
This article is a practical, opinionated tour through the current best alternatives:
- Nix flakes and nixpkgs docker/OCI builders
- Cloud Native Buildpacks
- ko (for Go)
- Melange + apko (Wolfi/APK-based)
- Bazel with rules_oci and language rules
We’ll compare their security posture, reproducibility, performance, ergonomics, and ecosystem fit. Then, we’ll close with a migration playbook you can apply repo-by-repo without boiling the ocean.
Why Dockerfiles Fall Short in 2025
- Reproducibility is hard by default:
- Unpinned base images and apt-get without versions introduce drift.
- Layer ordering, file timestamps, and gzip non-determinism change digests between builds.
- Supply-chain evidence is bolted on:
- SBOMs and provenance attestations aren’t native to Dockerfiles; you bolt on syft, cosign, and slsa generators as extra steps.
- Security & patching overhead:
- Every base image inherits a full userland. You own the patch treadmill and CVE triage.
- Inefficient caching:
- Minor changes invalidate whole layers unless you micromanage the Dockerfile.
- Developer experience:
- Complex multi-stage Dockerfiles are a specialized DSL that few teams truly master.
If you’ve ever watched a supposedly small Dockerfile produce a 600MB image, or seen a rebuild produce a different digest without any changes, you’ve felt the pain. The tools below raise the abstraction and shift default to secure, reproducible, and minimal.
The Alternatives at a Glance
- Nix (flakes): declarative, content-addressed (conceptually), hermetic package/build system that can emit OCI images.
- Buildpacks: framework that detects, builds, and deploys apps to OCI images with curated build/run images and SBOMs.
- ko: Go-native OCI builder—no Dockerfile—focus on speed, minimalism, and cloud-native workflows.
- Melange + apko: build APK packages from source (Melange), then assemble minimal images (apko), typically from Wolfi packages.
- Bazel: hermetic build system with remote caching/execution and first-class reproducibility; emits OCI images with rules_oci.
Each tool can produce SBOMs and integrate with Sigstore/cosign for signatures and SLSA provenance. Some do it natively; others integrate in the pipeline.
Nix Flakes: Reproducibility by Design
Nix treats builds as pure functions of their inputs. In practice, that means lockfiles, fixed-output fetchers, and immutable store paths. In 2025, flakes are widely used to pin dependencies and make Nix projects portable across machines and CI.
Key properties:
- Hermetic builds with explicit inputs (flake.lock).
- Cross-language: Go, Rust, Node, Python, JVM, you name it—via nixpkgs and language overlays.
- Can build OCI images via nixpkgs dockerTools/buildLayeredImage or third-party nix2container.
- Strong binary cache story (Cachix, Hydra, Hercules CI). Cache hits can reduce build times to seconds.
- Security posture: build-time isolation; optional sandboxing; reproducibility initiatives; cryptographic binary cache.
Caveats:
- Learning curve. You’ll need to learn Nix expressions.
- Not every upstream build is perfectly reproducible; you may need patches or pinned toolchains.
- Flakes are still evolving; best practice is to pin nixpkgs and builders in flake.lock.
Example: a minimal flake that builds a static Go binary and emits an OCI image.
nix{ description = "Go service built with Nix and emitted as an OCI image"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; config = { allowUnfree = false; }; }; in { packages = { app = pkgs.buildGoModule { pname = "myservice"; version = "1.0.0"; src = ./.; vendorHash = null; # or run `nix-prefetch` for reproducibility ldflags = [ "-s" "-w" ]; }; image = pkgs.dockerTools.buildLayeredImage { name = "registry.example.com/myorg/myservice"; tag = "1.0.0"; contents = [ self.packages.${system}.app ]; config = { Entrypoint = [ "/bin/myservice" ]; User = "65532:65532"; # non-root }; }; }; defaultPackage = self.packages.${system}.image; } ); }
Highlights:
- The resulting image contains only your binary and closure dependencies (if any).
- Rebuilds with the same flake.lock produce the same Nix derivations.
- You can export to an OCI tarball and push with crane or skopeo. Integrate cosign for signatures/attestations.
SBOM and provenance:
- Generate an SBOM for the image with syft and attach as an attestation using cosign.
- Provenance can be emitted by the CI (GitHub’s SLSA generator) or via custom Nix CI systems that sign outputs.
bash# Build and export nix build .#image skopeo copy oci-archive:result registry:registry.example.com/myorg/myservice:1.0.0 # SBOM and attestations syft registry.example.com/myorg/myservice:1.0.0 -o spdx-json > sbom.spdx.json cosign attest --predicate sbom.spdx.json --type spdx \ registry.example.com/myorg/myservice:1.0.0 # Provenance (GitHub Actions example with slsa-generator) gh attestation create oci://registry.example.com/myorg/myservice:1.0.0 \ --predicate-type slsaprovenance --predicate-file provenance.json
When to choose Nix:
- Polyglot monorepos.
- You want a single system that can build everything deterministically.
- You want to use binary caches to make builds fast across fleets.
Cloud Native Buildpacks: Paved Paths for App Teams
Buildpacks turn your source into an OCI image without writing a Dockerfile. A builder image (curated by vendors such as Paketo, Google, Heroku) contains buildpacks that:
- Detect your language and frameworks.
- Provision toolchains and dependencies in a build image.
- Produce a run image with only what’s needed to run the app.
- Attach SBOMs per layer by spec.
This model encodes platform best practices and shortens the distance from “git push” to “secure image”. It is approachable for general app teams and integrates well with PaaS and Kubernetes.
Quick start:
bashpack build ghcr.io/myorg/webapp:1.2.3 \ --path . \ --builder paketobuildpacks/builder-jammy-base \ --env BP_JVM_VERSION=17 \ --publish
Features that matter in 2025:
- SBOM by default: lifecycle emits SBOMs (SPDX/CycloneDX) for build and run layers.
- Reproducible-ish: if you pin the builder/run images and supply a lock file (e.g., package-lock.json, go.mod, Pipfile.lock), outputs are stable.
- Rebase support: update the run image for CVEs without recompiling your app.
- Non-root base images available.
Caveats:
- Less control: you trust the builder’s stack and lifecycle policy.
- Reproducibility depends on pinning builder and buildpack versions; otherwise, detection can shift.
SBOM and provenance:
- SBOMs are embedded per layer per the CNB spec. You can export them with tooling or extract from the image.
- Use cosign to sign and attach SLSA provenance that references the git commit and builder digest.
When to choose Buildpacks:
- App teams with mainstream stacks (Node, Java, Python, .NET, Go) who want paved paths.
- Platform teams operating at scale across many repos and languages.
ko: For Go Services That Don’t Need an OS
ko builds Go apps directly to OCI images—no Dockerfile, no base image required—by compiling statically (or mostly statically) and dropping binaries into minimal layers. It was originally created for Knative and is now maintained under the go-containerregistry ecosystem.
Highlights:
- Zero Dockerfile, minimal image size, fast inner loop.
- Reproducible builds if modules and compiler versions are pinned.
- Integrates tightly with registries, k8s manifests, and distroless-like bases.
Usage:
bash# Builds and pushes; tags with git SHA by default KO_DOCKER_REPO=ghcr.io/myorg \ ko build ./cmd/server --platform=linux/amd64,linux/arm64 # Use a specific base for dynamic linking if needed KO_DEFAULTBASE=gcr.io/distroless/static-debian12 \ ko build ./cmd/server
SBOM and provenance:
- Generate SBOMs with syft against the produced image and attach via cosign.
- Provenance via GitHub’s SLSA generator: run after ko build, referencing the image digest.
When to choose ko:
- Go services where you want the simplest path to small, fast, reproducible images.
- Teams already using distroless or Wolfi minimal bases.
Melange + apko: Package First, Then Compose
apko builds OCI images from APK packages, usually sourced from Wolfi—a container-optimized, rolling distribution designed by Chainguard for minimal, frequently-rebuilt images. Melange builds APKs from source with declarative recipes, enabling a fully traceable pipeline: source → package → image.
Why it’s compelling in 2025:
- Granular composition: assemble images from a few APKs instead of a broad distro base.
- Minimal attack surface: include only the libraries you need (musl or glibc variants available depending on package selection).
- Rapid CVE remediation: Wolfi and APK-based ecosystems move quickly; apko supports rebasing/pinning package versions.
- SBOMs and attestations integrated: apko emits SBOMs and can integrate with Sigstore to attach them.
apko example:
yaml# apko.yaml contents: repositories: - https://packages.wolfi.dev/os packages: - ca-certificates-bundle - myservice # Built by Melange and published to a repo you control entrypoint: command: ["/usr/bin/myservice"] account: user: 65532 group: 65532 labels: org.opencontainers.image.source: "https://github.com/myorg/myservice" org.opencontainers.image.description: "My service on Wolfi"
bash# Build an image tar and publish apko build apko.yaml ghcr.io/myorg/myservice:1.0.0 image.tar skopeo copy oci-archive:image.tar docker://ghcr.io/myorg/myservice:1.0.0 # SBOM generation and attestations apko sbom apko.yaml sbom.spdx.json cosign attest --predicate sbom.spdx.json --type spdx \ ghcr.io/myorg/myservice:1.0.0
Melange example (simplified):
yaml# melange.yaml package: name: myservice version: 1.0.0 epoch: 0 pipeline: - uses: go/build with: packages: ./cmd/myservice ldflags: "-s -w" outdir: "/workspace/out" subpackages: - name: myservice description: My service contents: - src: /workspace/out/myservice dst: /usr/bin/myservice update: enabled: false
When to choose Melange/apko:
- Security-conscious teams who want smallest surface area and clean SBOMs.
- You want to treat your app as a package and your image as a composition of packages.
- You already consume Wolfi or Alpine-like APK ecosystems.
Bazel + rules_oci: Hermetic at Scale
Bazel brings hermetic builds, remote caching, and remote execution to the container world. With rules_oci (and language rules like rules_go, rules_jvm, rules_nodejs), Bazel can produce OCI images whose layers are derived from deterministic actions.
What you get:
- Hermetic toolchains and reproducible outputs across CI and developer laptops.
- Fine-grained caching: small changes don’t invalidate everything.
- Strong monorepo support and incremental rebuilds.
Example WORKSPACE and BUILD for a Go service:
starlark# WORKSPACE load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") http_archive( name = "io_bazel_rules_go", sha256 = "<sha256>", urls = ["https://github.com/bazelbuild/rules_go/releases/download/v0.46.0/rules_go-v0.46.0.zip"], ) http_archive( name = "bazel_gazelle", sha256 = "<sha256>", urls = ["https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.34.0/bazel-gazelle-v0.34.0.zip"], ) http_archive( name = "rules_oci", sha256 = "<sha256>", strip_prefix = "rules_oci-<version>", urls = ["https://github.com/bazel-contrib/rules_oci/archive/refs/tags/v<version>.zip"], ) load("@io_bazel_rules_go//go:deps.bzl", "go_rules_dependencies", "go_register_toolchains") go_rules_dependencies() go_register_toolchains() load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies") gazelle_dependencies() load("@rules_oci//oci:repositories.bzl", "rules_oci_dependencies") rules_oci_dependencies()
starlark# BUILD.bazel load("@io_bazel_rules_go//go:def.bzl", "go_binary") load("@rules_oci//oci:defs.bzl", "oci_image") go_binary( name = "myservice", srcs = glob(["cmd/myservice/**/*.go"]), embed = ["//internal/pkg:lib"], ) oci_image( name = "image", base = "@distroless_base//image", # from rules_oci examples or your own base entrypoint = ["/myservice"], files = { ":myservice": "/myservice", }, tars = [], )
Build and push with Bazel targets, then attach SBOMs/provenance as a post-step. There are community rules to generate SBOMs from outputs, or you can run syft on the produced OCI tarball.
When to choose Bazel:
- You already use Bazel or plan to standardize builds across a large monorepo.
- You need scalable caching and remote execution.
- You want a single graph for code → binary → container.
Security and Supply-Chain Considerations
Across all the above, aim for these baselines in 2025:
- Pin everything: base images, builder stacks, package versions, compiler toolchains.
- SBOMs: emit SPDX JSON or CycloneDX for every image.
- Sign images: use Sigstore/cosign with keyless OIDC where possible.
- Transparency: publish attestations to a transparency log (e.g., Rekor) if your risk model allows.
- SLSA provenance: generate in-toto attestations with SLSA provenance v1 predicates.
- Minimize runtime footprint: distroless or Wolfi-based run images; non-root UID/GID.
- Policy as code: enforce allowed base images and required attestations via admission control (e.g., Kyverno, OPA Gatekeeper, or Sigstore policy-controller).
References to explore:
- SLSA framework: slsa.dev
- Sigstore: sigstore.dev
- SBOM formats: spdx.dev, cyclonedx.org
- Buildpacks: buildpacks.io
- Nix: nixos.org
- apko/Melange/Wolfi: chainguard.dev
- rules_oci: github.com/bazel-contrib/rules_oci
Reproducibility: How Close to “Bit-for-Bit”?
- Nix: with a pinned flake.lock and reproducible upstreams, you can get bit-for-bit determinism. Beware of timestamps and non-deterministic compression; nixpkgs builders handle these, but third-party tools may need flags.
- Buildpacks: stable if you pin the builder and lock your language deps. The run-image rebase feature means digest changes when rebasing, even if your code doesn’t.
- ko: reproducible with pinned Go version and module checksums. Your base (if using non-static) must be pinned by digest.
- apko: reproducible if you pin repositories and package versions; APK index updates can affect non-pinned builds.
- Bazel: highly reproducible; stamp your builds deterministically and avoid environment leakage. Use toolchain pinning.
Practical test: build twice on clean runners and compare image digests. If they differ, compare oci-layouts, check timestamps, and verify pinned inputs.
Performance and Developer Experience
- Nix: blazing fast with caches; slow without. Great for CI once caches warm. Dev onboarding requires Nix installation and some learning.
- Buildpacks: fast for inner loops when the build cache is preserved. Simple DX for app teams.
- ko: very fast for Go. Excellent inner loop performance.
- apko: fast composition; full pipeline speed depends on Melange build time for your packages.
- Bazel: superb incrementality with remote cache/execution; initial setup is non-trivial.
Image Size and Minimalism
- Nix images can be tiny for static binaries but may pull in closures if you’re not careful. Use buildLayeredImage and avoid dev dependencies.
- Buildpacks produce reasonable sizes; Paketo’s tiny base and slim run images help.
- ko typically yields very small images for static binaries.
- apko can be extremely minimal: start with just libc and your binary. Wolfi’s granularity is a strong advantage.
- Bazel images depend on what you place; with distroless bases and single-binary layers, you can be near ko sizes.
Compatibility and Ecosystem Fit
- Nix: best for teams willing to adopt Nix across the stack; shines in infra/platform groups.
- Buildpacks: best default for mainstream web apps and PaaS-like workflows.
- ko: best for Go-only services.
- apko: best when you want package-level control and minimal surfaces.
- Bazel: best for monorepos and orgs standardizing on hermetic builds across many languages.
Attestations and Policy Enforcement Example
Here’s a minimal GitHub Actions snippet that signs, generates SBOM, and attaches SLSA provenance for any image, regardless of how you built it:
yamlname: supply-chain on: push: branches: [ main ] jobs: attest: runs-on: ubuntu-latest permissions: id-token: write contents: read packages: write steps: - uses: actions/checkout@v4 - name: Install cosign and syft uses: anchore/sbom-action/download-syft@v0.15.9 - run: | curl -sSfL https://github.com/sigstore/cosign/releases/download/v2.3.0/cosign-linux-amd64 \ -o /usr/local/bin/cosign chmod +x /usr/local/bin/cosign - name: SBOM and Sign env: IMAGE: ghcr.io/myorg/myservice:${{ github.sha }} run: | syft $IMAGE -o spdx-json > sbom.spdx.json COSIGN_EXPERIMENTAL=1 cosign attest --predicate sbom.spdx.json --type spdx $IMAGE - name: SLSA provenance uses: slsa-framework/github-action-build@v1 with: attestation: provenance upload-assets: false image: ghcr.io/myorg/myservice:${{ github.sha }}
Combine this with an admission policy on your cluster that only admits images with valid cosign signatures and required predicate types.
Migration Playbook: From Dockerfiles to Deterministic Builds
You don’t have to rewrite everything at once. Migrate per repo, minimizing churn and risk.
- Inventory and classify
- For each service, note language, current base image, build steps, and runtime requirements (shell? glibc? CA certs?).
- Identify security posture: do you require non-root, FIPS crypto, or specific compliance regimes?
- Choose a target per-service
- Go microservice: ko or apko (with a Melange package), or Nix if you already use it.
- Node/Java/Python/.NET web app: Buildpacks.
- Polyglot monorepo or heavy infra tooling: Nix or Bazel.
- Security-critical or ultra-minimal-runtime: apko/Wolfi composition.
- Pin your world
- Pin base/builder images by digest.
- Lock application dependencies (go.mod, package-lock.json, Pipfile.lock, poetry.lock, Gemfile.lock, etc.).
- For Nix/Bazel: pin nixpkgs/rules/toolchains in flake.lock/WORKSPACE.
- Prototype in parallel
- Keep the Dockerfile for a cycle; add a new job that builds the image with the new tool and pushes to a -next tag.
- Compare sizes, start-up times, and cold/warm build times.
- Verify runtime compatibility: glibc vs musl nuances; dynamic lib dependencies.
- Enable SBOMs and provenance
- Add syft to produce SPDX or CycloneDX SBOMs for each artifact.
- Add cosign signing and SLSA provenance attestations in CI using OIDC keyless flows.
- Store attestations alongside images in your registry; optionally mirror SBOMs to an artifact repository.
- Enforce in staging
- Configure an admission policy requiring:
- Cosign signature by your OIDC identity.
- An SBOM attestation.
- A SLSA provenance attestation referencing your CI workflow.
- Roll out to staging first and monitor for policy failures.
- Cut over and delete Dockerfile
- Once confidence is high, switch production jobs to the new builder and remove the Dockerfile to avoid drift.
- Document the new developer workflow (pack build, ko build, nix build, or bazel build) in your repo README.
- Close the loop on patching and CVEs
- Buildpacks: use rebase to update run images quickly.
- apko: bump package versions; rebuild images quickly.
- Nix/Bazel: update pinned inputs; rely on caches for speed.
- Automate nightly rebuilds and vulnerability scanning against your registry.
- Measure and iterate
Track:
- Image size and start-up time.
- Mean build time (cold and warm cache).
- Reproducibility rate (digest matches across clean CI runs).
- Vulnerability count (critical/high) post-migration.
- Developer satisfaction/time-to-first-successful-build.
Practical Tips and Gotchas
- Timestamps and compression: ensure deterministic tar/gzip options if you manually assemble layers. Most tooling sets these flags, but double-check if you write custom steps.
- libc choice (musl vs glibc): some apps need glibc. Wolfi offers both; pick the right package set. Distroless has glibc variants.
- Rootless images: set USER to non-root; verify file permissions and ports (>1024).
- Certificates and timezones: minimal images often lack CA bundles or tzdata; include them explicitly if needed.
- Rebase policies: Buildpacks and apko can update base packages without a full rebuild—use this to reduce MTTR for CVEs, but track digest changes.
- Remote caches: Nix (Cachix) and Bazel (RBE/remote cache) drastically change the performance equation—invest early.
- Provenance granularity: include materials (source repo, lockfiles, builder base digests) in your provenance payload; consumers can then verify supply-chain claims.
- Registry quotas: multi-arch manifests and multiple attestations increase artifact count; monitor quotas and retention.
When Dockerfiles Still Make Sense
- Highly bespoke build steps that aren’t covered by upstream rules or tools.
- Legacy apps with complex OS-level dependencies that are easiest to express imperatively.
- One-off experiments and debugging.
Even then, pin base images by digest, make builds reproducible, and attach SBOMs/provenance. Dockerfiles don’t preclude good hygiene; they just require more discipline.
Recommended Default Choices in 2025
- If you’re a Go shop: start with ko. For maximal control/minimalism, consider Melange+apko.
- If you’re a mainstream web/app platform: default to Buildpacks.
- If you’re a platform/infra team or a polyglot monorepo: Nix or Bazel.
- Always: sign images, attach SBOMs, and generate SLSA provenance in CI.
A Minimal End-to-End Example: Go Service With apko + Provenance
- Build the APK with Melange and publish it to your Wolfi repo.
- Compose the image with apko:
yaml# apko.yaml contents: repositories: - https://packages.wolfi.dev/os - https://packages.example.com/myorg packages: - ca-certificates-bundle - myservice@1.0.0-r0 entrypoint: command: ["/usr/bin/myservice"] account: user: 65532 group: 65532
bashapko build apko.yaml ghcr.io/myorg/myservice:1.0.0 image.tar skopeo copy oci-archive:image.tar docker://ghcr.io/myorg/myservice:1.0.0
- SBOM and SLSA via CI:
bashsyft ghcr.io/myorg/myservice:1.0.0 -o spdx-json > sbom.spdx.json cosign attest --predicate sbom.spdx.json --type spdx ghcr.io/myorg/myservice:1.0.0 slsa-provenance-generator --artifact ghcr.io/myorg/myservice:1.0.0@sha256:... \ --out provenance.json cosign attest --predicate provenance.json --type slsaprovenance \ ghcr.io/myorg/myservice:1.0.0
- Enforce in Kubernetes with policy-controller or Kyverno to require these attestations.
Closing Thoughts
The container ecosystem has outgrown Dockerfiles as the default. In 2025, reproducibility and supply-chain evidence are table stakes, and higher-level tools offer them by design.
- Nix gives you a universal, hermetic substrate.
- Buildpacks provide paved, language-aware golden paths.
- ko is a laser-focused tool for Go services.
- Melange+apko deliver minimal, composable images with fast patching.
- Bazel scales hermetic builds across large codebases.
Pick one or two patterns per organization, standardize your CI, and make SBOM and SLSA provenance non-optional. You’ll ship smaller, faster, more secure images—and spend far less time maintaining by-hand Dockerfiles.