Creating Container Images with Nix (dockerTools)
Creating Container Images with Nix (dockerTools)
FROM debian:latest plus apt-get is a different closure every Tuesday. The boring default is: pkgs.dockerTools.buildLayeredImage (or streamLayeredImage) from nixpkgs 26.05, no daemon at build time, cacert if you speak TLS.
Mental model
Nix builds an OCI tarball from store paths. Layers split by derivation so glibc stays cached when desk-api changes. No /bin/bash unless you listed pkgs.bash.
| Function | Output |
|---|---|
buildLayeredImage |
.tar.gz you docker load / podman load |
streamLayeredImage |
a script that writes the tarball to stdout (./result \| podman load) — smaller NAR in CI |
contents is deprecated. Use copyToRoot. fakeNss is /etc/passwd + /etc/group + nsswitch (root + nobody). Without it, User = "65534" fails with “no such user”. extraCommands runs with the image root as cwd (mkdir -p tmp && chmod 1777 tmp). pkgs.iana-etc if you see getProtocolByName: no such protocol name: tcp.
Podman and Docker both load the archive. Building does not need root or a daemon.
desk-api drv + cacert → layered OCI → docker load / podman load
Worked examples
Case 1: Layered image
Save as image.nix:
# image.nix
{ pkgs ? import <nixpkgs> {} }:
let
deskApp = pkgs.writeShellApplication {
name = "desk-api";
text = ''
echo "desk-api listening (fake)"
'';
};
in
pkgs.dockerTools.buildLayeredImage {
name = "desk-api";
tag = "1.0.0";
copyToRoot = [
deskApp
pkgs.cacert
pkgs.dockerTools.fakeNss
];
extraCommands = ''
mkdir -p tmp
chmod 1777 tmp
'';
config = {
Cmd = [ "${deskApp}/bin/desk-api" ];
ExposedPorts."8080/tcp" = { };
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
User = "65534:65534";
};
}nix-build image.nix
podman load < result
podman run --rm desk-api:1.0.0Output (shape):
desk-api listening (fake)
Prefer a version tag over latest. User = "65534:65534" is nobody from fakeNss. Numeric IDs skip a name lookup; still ship fakeNss so glibc getpwuid does not panic. The process cannot write / — that is the point. Writable dirs (/tmp, maybe a volume for /var/lib/desk-api) come from extraCommands. extraCommands runs at image build time as root in that fake root, not as User at runtime.
streamLayeredImage takes the same attr set as buildLayeredImage. The derivation is a script: nix build .#imageStream && ./result | podman load. CI prefers the stream so the NAR is the script, not a multi-hundred-megabyte tarball sitting in the cache twice.
Case 2: vendorHash for a real Go module
# image-go.nix
{ pkgs ? import <nixpkgs> {} }:
let
deskApp = pkgs.buildGoModule {
pname = "desk-api";
version = "1.0.0";
src = pkgs.lib.cleanSource ./.;
vendorHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
in
pkgs.dockerTools.buildLayeredImage {
name = "desk-api";
tag = "1.0.0";
copyToRoot = [ deskApp pkgs.cacert pkgs.tzdata pkgs.dockerTools.fakeNss ];
config = {
Cmd = [ "${deskApp}/bin/desk-api" ];
User = "65534:65534";
};
}Probe vendorHash once. null is not a pin.
Case 3: Inspect layers
tar -tf result | head
nix path-info -Shr result | tail -1The image closure should be the app + libc + certs, not gcc. If gcc is in path-info -r, you copied buildInputs into copyToRoot. contents = [ pkgs.stdenv ]; is the same bug under the old name.
Case 4: Flake package
One file returns the attr set, not an image. Both builders take that set:
# image-args.nix
{ pkgs, deskApp }:
{
name = "desk-api";
tag = "1.0.0";
copyToRoot = [ deskApp pkgs.cacert pkgs.dockerTools.fakeNss ];
extraCommands = ''
mkdir -p tmp
chmod 1777 tmp
'';
config = {
Cmd = [ "${deskApp}/bin/desk-api" ];
User = "65534:65534";
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
};
}# flake.nix
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
deskApp = pkgs.writeShellApplication {
name = "desk-api";
text = ''echo "desk-api listening (fake)"'';
};
imageArgs = import ./image-args.nix { inherit pkgs deskApp; };
in
{
packages.${system}.image = pkgs.dockerTools.buildLayeredImage imageArgs;
packages.${system}.imageStream = pkgs.dockerTools.streamLayeredImage imageArgs;
};
}nix build .#image
skopeo copy docker-archive:./result docker://registry.desk.internal/desk-api:1.0.0nix build .#imageStream
./result | podman load
# CI: stream to a temp archive, then skopeo (stdin support varies)
./result > /tmp/desk-api.tar
skopeo copy docker-archive:/tmp/desk-api.tar docker://registry.desk.internal/desk-api:1.0.0CI does not start dockerd. skopeo talks to the registry. Pin the tag; do not push latest from main unless that is an explicit contract.
Case 5: Nobody is home in the image
podman run --rm --entrypoint /bin/sh desk-api:1.0.0Output:
Error: no such file or directory
That is success for a production image. Debug with a separate image that includes pkgs.busybox. Do not ship busybox in the API tag.
The trap
The trap is forgetting cacert. HTTPS fails with x509: certificate signed by unknown authority. Include pkgs.cacert and SSL_CERT_FILE (or dockerTools.caCertificates).
The other trap is copyToRoot = [ pkgs.stdenv ]; (or deprecated contents) so “debug is easy.” You just shipped a compiler.
A third: root in the image because fakeNss was omitted and User was left unset. A fourth: no /tmp (mode 1777) so the Go runtime cannot create scratch files.
The boring rule
copyToRoot, not deprecatedcontents.buildLayeredImageorstreamLayeredImagefrom the same args.- Pin tags.
cacert+fakeNss+/tmp.Useris not root. vendorHashis a FOD. Probe once.- Build in CI without dockerd.
skopeo copyafter. - Debug images are a different output, not extra packages in prod.
Try this
- Build Case 1,
tar -tf result | wc -l. - Drop
cacert, run a one-liner thatcurl https://example.com(add curl), read the TLS error, put cacert back. nix path-info -Shr resultand confirm gcc is absent.- Tag
1.0.0and1.0.1after a script change; confirm the base layers’ hashes stayed when only the top layer moved. podman inspect desk-api:1.0.0 --format '{{.Config.User}}'—65534:65534. DropfakeNss, run, read the getpwuid error, put it back.