Build images with Nix flakes
[!WARNING] The Nix build backend is highly experimental. It is tested with small sample images only. Real projects may hit behaviors the samples do not cover. Try it, and file feedback at the issue tracker — any feedback is welcome.
Images in a ContainerHive project can define their filesystem with a Nix flake instead of a Dockerfile. ContainerHive resolves the project, builds the flake through BuildKit, and pushes, tests, and documents the result like any other image.
How it works
ContainerHive runs nix build inside a Nix builder image through a custom BuildKit frontend:
- Discovery finds a
flake.nixin the image directory and marks the image with thenixbuild backend. generaterenders the image directory, copyingflake.nix,flake.lock, therootfs/directory, and the test config intodist/.buildexecutes a single executor step inside the configured builder image:nix build --option sandbox falsein/src; the flake output is a rootfs directory, which becomes the image filesystem on an empty base.- Everything else is unchanged: OCI labels, the S3 or registry build cache, multi-arch pushes, SBOMs, and structure tests run like on Dockerfile builds.
Only the BuildKit worker needs to pull the builder image. Neither ch nor the CI pipeline needs Nix installed.
The default builder image is the nix image of
ContainerHive/helper-images, the official nixos/nix image with
flakes enabled. That repository is itself a ContainerHive project and publishes one tag per Nix release series
(2.35, 2.34, ...), so you can pin the Nix version your flakes need with a plain tag.
The flake contract
The build produces a rootfs directory. The installed attribute is a convention, never user configured:
The <nix-system> maps from the target build platform, so the result does not depend on the system of the builder
image. A flake must define that attribute for every platform it serves; nixpkgs.lib.systems.flakeExposed covers all
systems at once:
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs";
outputs = { self, nixpkgs }:
{
packages = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
default = pkgs.buildEnv {
name = "my-tool-rootfs";
paths = [ pkgs.my-tool ];
};
});
};
}
Platform mapping:
| Docker platform | Nix system |
|---|---|
linux/amd64 |
x86_64-linux |
linux/arm64 |
aarch64-linux |
linux/386 |
i686-linux |
linux/arm |
armv7l-linux |
linux/arm/v6 |
armv6l-linux |
linux/riscv64 |
riscv64-linux |
Platforms without a mapping fail the build with a clear error. See the range of supported platforms for the project level view.
Keep the parent project files out of the flake. Static files the image should contain have to be part of the flake
output. A rootfs/ directory next to the flake is not picked up automatically, and buildEnv only accepts
derivations in paths, so wrap the directory in a derivation and add it to the buildEnv above:
let
rootfs = pkgs.runCommand "my-tool-rootfs-files" { } "cp -r ${./rootfs} $out";
in
pkgs.buildEnv {
name = "my-tool-rootfs";
paths = [ pkgs.my-tool rootfs ];
}
flake.lock handling
The lock file stays optional:
- With flake.lock: the build respects it (
--no-update-lock-file). When the flake input declarations no longer match the lock, the build fails with Nix's lock-file error instead of silently re-resolving. A flake.lock also stabilizes the BuildKit cache keys, so unchanged images build from cache on every machine that has them. - Without flake.lock: the build resolves inputs at build time (current
HEADof pinned github sources, deterministic store paths for pins). Discovery logs a warning. Builds are then not reproducible.
Commit a lock file for release workflows: run nix flake lock once and commit flake.lock next to flake.nix.
Configure the backend in image.yml
Most images need no extra configuration:
Set build: nix explicitly, or let discovery pick the backend: a flake.nix without a Dockerfile uses the Nix
backend automatically. When both files exist, set build explicitly to remove the ambiguity.
The nix block configures the backend:
build: nix
nix:
builder_image: "ghcr.io/containerhive/helper-images/nix:2.35" # image providing the nix binary (default)
extra_args: [ "--impure" ] # extra nix build arguments
Because a flake has no Dockerfile instructions, the runtime configuration of the image looks like this:
config:
entrypoint: [ "/bin/my-tool" ]
cmd: [ "--help" ]
env:
TOOL_HOME: /var/lib/my-tool
user: "1000:1000"
working_dir: /workspace
Set working_dir, user, cmd, env, and entrypoint as needed. Unset fields stay empty.
Variants
Variants keep working: place a flake.nix into the variant directory and configure the variant like an image, with
variant-level build, nix, and config blocks.
Parent images
A flake build can start from a parent image rootfs instead of an empty base. Set nix.parent on the image or variant:
The value takes two forms:
__hive__/<name>:<tag>— another image of the project. ContainerHive resolves it from the local build cache like a Dockerfile dependency; CI resolves it from the registry. The build order derives from the reference; an explicitdepends_onis not required.__hive_parent__— the variant placeholder. Only valid inside a variant: it resolves to the base tag__hive__/<name>:<tag>of the same image, so a variant of a flake image builds from that tag without more config.- Any other value is a plain image reference, for example
ubuntu:22.04. It resolves through the registry and is digest-pinned like the builder image.
The build mounts the parent rootfs at /parent and copies it into the image first, so the flake output overlays the
parent. Two limitations apply:
- The flake cannot read the parent rootfs while it builds. Nix evaluates and builds in its own sandbox; the parent merge happens in the build script afterwards. Declare parent-provided file paths as image content, not as flake inputs.
- The parent image config flows into the result (from semantics): entrypoint, cmd, env, user and working dir inherit
from the parent unless the
configblock sets them. Theenvof theconfigblock overlays the parent env per key.
Build args and templating
A flake renders through the ContainerHive template engine like any other build entrypoint, so image versions and
build args reach the flake through the template context. Name the templated flake flake.nix.gotpl; generate
renders it and writes the result as flake.nix. A plain flake.nix is copied as is without template processing, so
Nix would see the literal {{ ... }} expressions.
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs?rev={{ .BuildArgs.nixpkgs_rev }}";
outputs = { self, nixpkgs }: {
packages = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed (system:
let
pkgs = import nixpkgs { inherit system; };
in
{
default = pkgs.buildEnv { name = "my-tool-{{ .Versions.vm }}"; };
});
};
}
Two notes:
- Rendering happens at
generatetime. A new build arg value re-renders the flake and invalidates the build cache, by design. - A rendered flake usually needs a committed
flake.lockthat stays stable against the rendered inputs; without one the build resolves freely (see the lock file section).
Nix store caching
Every build otherwise starts with an empty nix store inside the builder container and re-downloads the closure of a
changed flake. Set store_cache: true to keep the store across builds:
The script imports the build closure from a persistent BuildKit cache mount before the build and saves the new closure after it. A changed flake reuses the downloaded packages instead of re-fetching them. Three limits apply:
- The cache mount lives inside one BuildKit daemon. A fresh daemon starts cold.
- The registry or S3 build cache does not carry the store. For cross-machine warmup, pin the flake inputs with
flake.lockand let nix substitute from a binary cache. - The saves are best effort and grow unbounded across builds of the daemon. Clear the cache mount when it grows too large.
Known limitations
- Multi-platform
nix buildruns under emulation on non-native machines. Expect slow first builds for arm64 on x86 builders; BuildKit caches the step afterwards. The build runs withfilter-syscalls = falsebecause Nix syscall filtering does not work under emulation, a known limitation. Understood trade-off when you accept the nix backend. - The flake cannot read the parent rootfs while it builds (see parent images).