minimal-oci-image¶
Library
Build a super-minimal OCI/Docker image out of the Nix store — no distro
base, no package manager, no shell, no apk add ca-certificates — and still
have TLS, a non-root user, a timezone and a working $PATH.
pkgs.dockerTools already gives you the hard part: the closure of a program is
the image. What it does not give you is the six-item checklist that turns
"the closure is in a tarball" into "the container actually runs". This wrapper
encodes that checklist, and the README below is a list of the ways each item
fails when you forget it — each one reproduced and measured on a real Docker
daemon while writing this.
The problem¶
A FROM alpine image costs you a package manager, a shell, a libc you did not
choose, and a supply chain you do not control. The Nix alternative is one line:
pkgs.dockerTools.buildLayeredImage {
name = "myapp";
config.Entrypoint = [ (lib.getExe pkgs.myapp) ];
}
That image works — until the app makes an HTTPS request, or looks up its own
username, or writes to /tmp, or a HEALTHCHECK runs, or someone types
docker exec -it … sh. Each of those fails with an error that does not say
"your image is missing /etc/ssl", it says something else entirely.
What upstream nixpkgs does and does not do¶
Everything cited below is from a nixpkgs checkout at
pkgs/build-support/docker/default.nix unless stated otherwise.
Upstream does ship the individual pieces, and this recipe uses them rather than hand-rolling:
| Piece | Where | What it gives you |
|---|---|---|
dockerTools.caCertificates |
default.nix:983-991 |
/etc/ssl/certs/ca-bundle.crt, /etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt |
dockerTools.fakeNss |
default.nix:966, impl pkgs/by-name/fa/fakeNss/package.nix:16-29 |
/etc/passwd (root + nobody), /etc/group, /etc/nsswitch.conf, /var/empty |
dockerTools.binSh |
default.nix:977-980 |
/bin/sh → bash |
dockerTools.usrBinEnv |
default.nix:970-973 |
/usr/bin/env for #! lines |
What upstream does not do:
- None of those four are included by default.
streamLayeredImage's argument list (default.nix:1004-1034) has nocaCertificates, nonss, nouser. AbuildLayeredImagewith only your app incontentshas an empty/etcand no/tmp. - It does not set a single environment variable for you.
config.Envdefaults to absent, so$PATHinside the image is whatever the runtime invents (Docker's fallback is/usr/local/sbin:…:/bin, none of which exist in a store-only image), andSSL_CERT_FILEis unset even when you did addcacert— adding the package is not enough, OpenSSL still looks at its compiled-in default path. - It does not connect
fakeNsstoconfig.User. You can setconfig.User = "1000:1000"and ship no passwd entry; nothing warns you, and the container starts. It breaks later, at the firstgetpwuid. buildImageandbuildLayeredImagetake different argument names for the same thing —copyToRoot(default.nix:623-624) versuscontents(default.nix:1008) — andcontentsonbuildImageis deprecated with a runtime warning (default.nix:658) whilecontentson the layered builders is the correct name. Switching builders is not a one-word change.
This recipe is a thin function over the upstream builders that wires those together, asserts the combinations that cannot work, and defaults to the smallest thing that still runs.
Traps¶
1. buildLayeredImage vs buildImage: layering does not shrink the image¶
The intuition "more layers = smaller image" is wrong, and measurably so. The same contents, built both ways:
| Build | Layers | .tar.gz in the store |
Size in the daemon |
|---|---|---|---|
buildLayeredImage |
10 | 13,259,715 B | 40.8 MB |
buildImage (one flat layer) |
1 | 13,258,868 B | 40.8 MB |
847 bytes apart. Layering buys exactly one thing: sharing. Because
buildLayeredImage puts one store path per layer (default.nix:578-603,
which is just streamLayeredImage piped through a compressor), two images
built from overlapping closures share every layer they have in common. On the
daemon used to write this, 15 images whose nominal sizes total ~786 MB occupy
192.6 MB on disk — docker system df reports 55 % reclaimable. Pushing a
rebuilt app to a registry sends one small layer, not 40 MB.
Choose accordingly:
buildLayeredImage— the default. Anything you push to a registry, or rebuild often, or run alongside sibling images.streamLayeredImage— same layers, but the derivation output is a script that writes the tarball to stdout. The multi-hundred-MB tarball never enters the store:$(nix build …)/bin/stream | docker load. Use it in CI, and for large images. The fleet's browser-worker image (chromium + a dozen font packages) uses this for exactly that reason.buildImage— one flat layer. Worth it when the consumer counts layers, or when you genuinely want a single opaque blob.
Layer-count bounds: maxLayers defaults to 100
(default.nix:1017); values ≤ 1 hard-fail an assertion (default.nix:1036-1039);
if fromImage is set, its layers are subtracted from your budget. When the
closure has more store paths than maxLayers, the leftovers are merged into
one layer chosen by a popularity contest — you do not lose paths, you lose
sharing. A curl image here came out at 26 layers for a 26-path closure.
2. There is no shell, and that breaks four specific things¶
Entrypoint and Cmd in an OCI config are argv arrays passed to execve —
there is no shell form, ever. dockerTools will not silently give you one.
Concretely, on an image with no /bin/sh:
$ docker run --rm --entrypoint /bin/sh example-bare:1 -c 'echo hi'
docker: Error response from daemon: failed to create task for container:
… exec: "/bin/sh": stat /bin/sh: no such file or directory
The four things that break, in order of how long they take to notice:
HEALTHCHECK CMD-SHELL. Measured side by side, one image, two configs, 1 s interval: exec-formTest = [ "CMD" "${coreutils}/bin/true" ]→healthy. Shell-formTest = [ "CMD-SHELL" "true" ]→unhealthy, withdocker inspect … .State.Health.Logfull ofOCI runtime exec failed: … exec: "/bin/sh": stat /bin/sh: no such file or directory. A shell-form healthcheck does not error at build time and does not error at start time — the container comes up and then quietly goes unhealthy, which in an orchestrator means a restart loop.- Entrypoint wrapper scripts. A
#!/bin/shscript needs both/bin/shand — if it uses#!/usr/bin/env sh—/usr/bin/env. Usepkgs.writeShellApplicationinstead: it bakes an absolute store-path shebang and putsruntimeInputsonPATH, so it needs neither. docker exec -it … sh/kubectl exec. Debugging is gone. That is the trade; keep ashell = truevariant of the image around, or attach withnsenterfrom the host.- Anything that shells out.
system(), Pythonsubprocess(shell=True), Goexec.Command("sh", …),git's credential helpers.
shell = true adds dockerTools.binSh + bashInteractive — about 55 MB of
bash + its dependencies. That is the honest price.
3. CA certificates: the failure message never says "certificates"¶
Nothing in a Nix-built image sets up TLS trust. The error you get, measured
with curl in an image built without tls:
The same image with tls = true: http=200 verify=0. Other runtimes phrase
the same missing file differently, which is why this is hard to recognise:
- Go:
x509: certificate signed by unknown authority - Python/requests:
CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate - Node:
UNABLE_TO_GET_ISSUER_CERT_LOCALLY/SELF_SIGNED_CERT_IN_CHAIN - JVM:
PKIX path building failed: unable to find valid certification path
Adding pkgs.cacert to contents is not sufficient. It installs the
bundle under a store path; nothing looks there. You need both the file at a
conventional location (dockerTools.caCertificates, default.nix:983-991)
and the environment variables pointing at it. This recipe sets SSL_CERT_FILE,
NIX_SSL_CERT_FILE, CURL_CA_BUNDLE, GIT_SSL_CAINFO and
REQUESTS_CA_BUNDLE to /etc/ssl/certs/ca-bundle.crt. Go and the JVM read
neither of those and rely on the file locations, which caCertificates
provides for the Debian, NixOS and RedHat conventions simultaneously.
To trust an extra CA (a corporate MITM proxy, an internal PKI), concatenate rather than replace, as a proxy-worker image would:
caBundle = pkgs.runCommand "ca-bundle" { } ''
mkdir -p $out/etc/ssl/certs
cat ${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt ${./internal-ca.crt} \
> $out/etc/ssl/certs/ca-bundle.crt
ln -s ca-bundle.crt $out/etc/ssl/certs/ca-certificates.crt
'';
# then: tls = false; contents = [ ... caBundle ];
4. /etc/passwd and the non-root user¶
config.User = "65532:65532" works at the kernel level with no /etc/passwd
at all — the container starts, the uid is right. What fails is name
resolution, later and elsewhere:
$ docker run --rm probe-nonss:1 # User=65532:65532, no /etc/passwd
whoami: cannot find name for user ID 65532: No such file or directory
With user = "app" (which is fakeNss.override { extraPasswdLines = … }):
The real-world victims of a missing passwd entry are: nginx's user
directive, OpenSSH and git (No user exists for uid), Go's os/user, Java's
user.name, anything that expands ~, and any library that derives a config
path from $HOME.
Two related sub-traps:
$HOMEis not set by the runtime. Docker sets it only from the passwd entry in some versions and not in others. This recipe setsHOMEinconfig.Envunconditionally, defaulting to/var/empty(the same homefakeNssgives root and nobody). If the app writes to$HOME, point it at a volume —/var/emptyis in a read-only layer.Useris written numerically, not as a name."65532:65532"rather than"app", so the image still starts if the passwd layer is ever dropped or overridden.getpwuiddegrades;execvedoes not./etc/nsswitch.conf:fakeNssshipshosts: files dns(fakeNss/package.nix:26-29). Measured here, glibc's compiled-in fallback was enough for DNS even with nonsswitch.confat all (an image without it resolvedexample.comand gothttp=200) — but that fallback is a glibc implementation detail that has changed across versions, and it does not coverpasswd/group. Ship the file; it costs nothing. The fleet's torrent-client image installs a hand-written one for this reason.
5. Timezone data costs 10 MB unless you copy one file¶
Adding pkgs.tzdata to contents and setting TZDIR works, and it drags the
entire IANA database into the closure. Measured, same image, same zone:
zoneinfo |
How | Image size |
|---|---|---|
"full" |
tzdata in contents, TZDIR + TZ set, /etc/localtime symlink |
63.9 MB |
"single" |
one TZif file copied to /etc/localtime |
53.6 MB |
Both print Tue Jul 28 21:34 +0845 2026 for Australia/Eucla. The 10.3 MB
difference is the other ~600 zones.
The mechanism matters: install -m 0644 ${tzdata}/share/zoneinfo/<zone>
etc/localtime copies the file, and a TZif blob contains no store-path
references, so Nix's reference scanner does not pull tzdata into the closure.
ln -s into the store would. This is the whole trick.
The trap in the cheap variant: do not set TZ. With no TZDIR and no zone
database, TZ=Australia/Eucla cannot be resolved and glibc silently falls back
to UTC — you get a wrong-but-plausible clock. Unset TZ and glibc reads
/etc/localtime, which is the file you just copied. Use zoneinfo = "full"
only when the app resolves zone names at runtime (per-user timezones, a
scheduler with zone-aware cron expressions).
6. $PATH in a store-only image¶
There is no /usr/bin. Docker's default PATH points at six directories that
do not exist, so any exec.LookPath, subprocess("psql"), or plugin discovery
fails with executable file not found in $PATH even though the binary is in
the image. This recipe computes PATH from lib.makeBinPath contents and
appends /bin:/usr/bin (which do exist when contents is non-empty, because
the layered builders symlink package roots into the image root).
Two sharp edges:
lib.makeBinPath [ ]is the empty string. Splicing it into"${...}:/bin:/usr/bin"yields a leading empty element, and an empty element inPATHmeans the current working directory — a genuine privilege escalation vector in a container that runs untrusted input. The recipe filters empty components out.- Entrypoints should still be absolute store paths.
lib.getExe pkgs.foo, not"foo".PATHis for what the app spawns; the entrypoint should not depend on it.
7. buildImage cannot mkdir into a directory that came from a package¶
This one cost a build failure while writing the recipe:
Adding contents...
Adding /nix/store/…-example-single-layer-root
mkdir: cannot create directory 'var/tmp': Permission denied
buildImage's mkPureLayer rsyncs each content tree into the layer with
--chown=0:0 (default.nix:452), preserving store modes — store directories
are r-xr-xr-x — and then chmods only the layer root writable
(default.nix:458). So extraCommands can create top-level directories but
not descend into any directory a package contributed. Here fakeNss provides
/var/empty, so var existed read-only and mkdir -p var/tmp failed.
buildLayeredImage does not hit this: its customisation layer is a
symlinkJoin, whose directories are writable. The failure therefore appears
only when you switch builders — which is precisely when you are least
expecting a new error. The recipe emits a chmod u+w prelude over etc,
var, usr so the same extraCommands work under every builder.
Related: extraCommands runs unprivileged, so it can set mode bits
(chmod 1777 tmp survives into the tarball) but cannot chown. For ownership
you need fakeRootCommands, and for anything that must look like a real root
filesystem while it runs, enableFakechroot = true — which uses proot and
therefore hard-fails on Darwin with an explicit assertion
(default.nix:1040-1048). buildImage's runAsRoot is a third mechanism
again: it boots a QEMU VM (mkRootLayer, default.nix:482-509), which the
layered builders do not support at all.
8. Reproducibility: created, mtime, tag, and lowercase names¶
createdandmtimeboth default to"1970-01-01T00:00:01Z"(default.nix:644forbuildImage,default.nix:1011-1012forstreamLayeredImage). Verified:docker inspect --format '{{.Created}}'returns1970-01-01T00:00:01Z. Tooling that sorts images by creation date will show them all as equally ancient — that is the cost of the guarantee, and it is worth it.created = "now"breaks reproducibility, and does so in a counter-intuitive way: forbuildImageit is implemented as arunCommandthat shells out todate(default.nix:680-690). That derivation is cached like any other, so the timestamp is frozen at whenever the build first happened on this machine — not "now", and different on every machine. Prefer aLabelsentry with the git revision.mtimeis not just cosmetic: a non-constant mtime changes every layer hash, so every rebuild re-uploads every layer.tag = nullmakes the tag the output hash (default.nix:743-749,1173-1179). Excellent for immutable deployments, but the tag changes whenever anything in the closure does, so nothing can pinmyapp:latest.- The image name is lowercased for
imageName(default.nix:737and1171), while the tarball keeps your capitalisation.name = "MyApp"therefore producesMyApp.tar.gzthat loads asmyapp. The recipe asserts the name is already lowercase rather than letting the two drift. includeStorePaths = false(default.nix:1021, defaulttrue) produces an image containing only symlinks. It runs only if the host store is bind-mounted in; upstream documents this as "not recommended … the generated image won't run properly". Do not reach for it as a size optimisation.
Measurements¶
All built from one nixpkgs checkout, x86_64-linux, loaded into Docker.
"Store" is the compressed .tar.gz in the Nix store; "daemon" is what
docker images reports (uncompressed).
| Image | Contents | Layers | Store | Daemon |
|---|---|---|---|---|
bare |
one dynamically-linked binary + glibc, nothing else | 6 | 12.99 MB | 40.1 MB |
service |
same + CA bundle + passwd/group/nsswitch + /tmp + non-root + timezone |
10 | 13.26 MB | 40.8 MB |
singleLayer |
same as service, via buildImage |
1 | 13.26 MB | 40.8 MB |
tlsClient |
curl and its full closure |
26 | 24.39 MB | 71.2 MB |
| static busybox | pkgsStatic.busybox, no libc in the image |
2 | 0.83 MB | 2.34 MB |
Two things to read off this table:
- The whole hygiene checklist costs 0.7 MB (12.99 → 13.26 MB). There is no size argument for shipping a broken image.
- The floor for a dynamically-linked image is glibc, ~38 MB uncompressed,
and no amount of layer tuning moves it. If you need genuinely tiny, the
lever is
pkgsStatic(or a static Go/Rust binary), not the image builder: 0.83 MB versus 12.99 MB, a 15× difference, from changing the package.pkgsStaticbuilds a lot from source, so weigh build time against it.
Usage¶
let
oci = import ./lib/minimal-oci-image { inherit pkgs; };
in
{
# The realistic default: non-root, TLS-capable, /tmp, local timezone.
myImage = oci.mkMinimalImage {
name = "myapp"; # must be lowercase
tag = "1.4.2";
entrypoint = [ (lib.getExe pkgs.myapp) "--config" "/etc/myapp.toml" ];
contents = [ pkgs.myapp ]; # puts bin/ on $PATH too
user = "app";
timezone = "Europe/Berlin";
exposedPorts = { "8080/tcp" = { }; };
env.MYAPP_LOG_FORMAT = "json";
labels."org.opencontainers.image.revision" = self.rev or "dirty";
};
# CI variant: never materialise the tarball in the store.
myImageStream = oci.mkMinimalImage {
name = "myapp"; tag = "1.4.2"; builder = "stream";
entrypoint = [ (lib.getExe pkgs.myapp) ];
user = "app";
};
}
$ docker load < $(nix-build -A myImage) # layered / single
$ $(nix-build -A myImageStream) | docker load # stream
Options¶
| Option | Default | Purpose |
|---|---|---|
name |
— | Image name; asserted lowercase. |
tag |
"latest" |
null ⇒ tag derived from the output hash. |
entrypoint / cmd |
[ ] |
argv arrays. No shell form exists. |
contents |
[ ] |
Packages whose trees land in / and whose bin/ joins $PATH. |
env |
{ } |
Attrset merged over the computed defaults (attrset, so overrides replace rather than duplicate). |
builder |
"layered" |
layered | stream | single. |
maxLayers |
100 |
Layered builders only. |
tls |
true |
CA bundle at three conventional paths + five env vars. |
nss |
true |
/etc/passwd, /etc/group, /etc/nsswitch.conf, /var/empty. |
user / uid / gid |
null / 65532 / 65532 |
Non-root user with a real passwd entry; User is written numerically. |
home |
"/var/empty" |
$HOME. Read-only — mount a volume if the app writes there. |
shell |
false |
/bin/sh + bash. Needed for CMD-SHELL healthchecks and docker exec … sh. |
coreutils / usrBinEnv |
false |
Debug aids. |
timezone |
null |
IANA zone name. |
zoneinfo |
"single" |
"single" copies one TZif (no tzdata in the closure); "full" ships the database (+10 MB). |
tmp |
true |
/tmp and /var/tmp, mode 1777. |
created / mtime |
epoch | Keep them. |
extraCommands |
"" |
Runs in the image root, unprivileged, after a chmod u+w prelude. |
extraConfig |
{ } |
Merged over the OCI config; the escape hatch for Healthcheck, StopSignal, Shell. |
extraArgs |
{ } |
Passed to the underlying dockerTools builder (fromImage, fakeRootCommands, enableFakechroot, includeNixDB). |
Assertions fire at eval time for: a non-lowercase name; user set with
nss = false; an unknown zoneinfo or builder.
Caveats¶
docker loadneeds a running daemon;nix builddoes not. The image is a tarball in the store, reproducible and cacheable. Getting it onto a host is out of scope —docker load,skopeo copy, or a registry push.- Cross-architecture images are not cross-builds.
architectureonly writes a field in the OCI config; the binaries still come from whateverpkgsyou passed. For a real arm64 image, pass an arm64pkgs(pkgsCross.aarch64-multiplatformor a native remote builder). - Kubernetes
readinessProbe: exechas the same shell problem asHEALTHCHECK CMD-SHELL.httpGetprobes do not, because the kubelet makes the request itself — prefer them in shell-less images. - Nothing here is a security boundary. Non-root plus no shell raises the
cost of a foothold; it is not a sandbox. Add
--read-only,--cap-drop=ALL,--security-opt=no-new-privileges, seccomp, and a user namespace at run time. includeNixDB(viaextraArgs) is only for images that runnixitself; it inflates the image and upstream warns that only the deepest layer's database is loaded (default.nix:65-68).
Source¶
lib/minimal-oci-image/default.nix
# minimal-oci-image
#
# Build a super-minimal OCI/Docker image straight out of the Nix store: no
# distro, no package manager, no shell -- only the closure of the program you
# name, plus the handful of things that are ALWAYS missing from such an image
# and fail in confusing ways when they are (CA certificates, /etc/passwd,
# /etc/nsswitch.conf, /tmp, $PATH, zoneinfo).
#
# Usage:
#
# oci = import ./lib/minimal-oci-image { inherit pkgs; };
#
# myImage = oci.mkMinimalImage {
# name = "myapp"; # MUST be lowercase (see README)
# tag = "1.0.0";
# entrypoint = [ (pkgs.lib.getExe pkgs.myapp) ];
# user = "app"; # non-root, with a real passwd entry
# tls = true; # ship a CA bundle + point every
# # library at it
# };
#
# # then: docker load < result (or `result | docker load` when streamed)
#
# See README.md for the traps this encodes.
{
pkgs,
lib ? pkgs.lib,
}:
let
inherit (pkgs) dockerTools;
mkMinimalImage =
{
# Image name. Lowercased by dockerTools for `imageName`, so pass it
# lowercase or the tarball name and the loaded name disagree.
name,
# Image tag. `null` makes dockerTools derive the tag from the output
# hash -- reproducible, but it changes whenever the closure changes.
tag ? "latest",
# Exec-form entrypoint/command. There is no shell form: these are argv
# arrays handed straight to execve. Use absolute store paths.
entrypoint ? [ ],
cmd ? [ ],
# Packages whose file trees land in the image root (and whose bin/ dirs
# go on $PATH). For a true single-binary image leave this empty and put
# the store path in `entrypoint` -- the closure is pulled in either way.
contents ? [ ],
# Environment as an attrset, merged over the computed defaults. An
# attrset (not a list) so a caller override REPLACES the default instead
# of appending a duplicate key -- the OCI spec does not define which
# duplicate wins.
env ? { },
# "layered" -> buildLayeredImage (tarball, one store path per layer)
# "stream" -> streamLayeredImage (script that writes the tarball)
# "single" -> buildImage (one flat layer from a buildEnv)
builder ? "layered",
# Only meaningful for the layered builders. nixpkgs default is 100.
maxLayers ? 100,
# Ship a CA bundle at the three conventional locations and point the
# usual env vars at it. Without this, every TLS client in the image
# fails with "unable to get local issuer certificate" or similar.
tls ? true,
# Ship /etc/passwd, /etc/group, /etc/nsswitch.conf and /var/empty.
nss ? true,
# Non-root user name, or null to run as uid 0. Requires `nss`.
user ? null,
uid ? 65532,
gid ? 65532,
home ? "/var/empty",
# Add /bin/sh (bash). Off by default -- that is the point of the recipe.
# Turn it on when something outside your control needs a shell
# (HEALTHCHECK CMD, `kubectl exec`, an entrypoint wrapper script).
shell ? false,
# Add coreutils and /usr/bin/env. Debug aids; off by default.
coreutils ? false,
usrBinEnv ? false,
# IANA zone name, e.g. "Europe/Berlin". null leaves the image on UTC and
# ships no zoneinfo at all.
timezone ? null,
# "single" copies exactly one TZif file to /etc/localtime and keeps
# tzdata OUT of the closure. "full" ships the whole zoneinfo database
# and sets TZDIR/TZ, which is what you need if the app resolves zone
# names at runtime -- and costs ~10 MB.
zoneinfo ? "single",
# World-writable /tmp. Many libraries assume it exists.
tmp ? true,
workingDir ? null,
exposedPorts ? { },
volumes ? { },
labels ? { },
# Creation timestamp. Keep the epoch default for reproducibility;
# "now" makes the image non-reproducible.
created ? "1970-01-01T00:00:01Z",
mtime ? "1970-01-01T00:00:01Z",
architecture ? null,
# Extra shell run in the (unpacked) image root before it is tarred.
# Mode bits survive; ownership does not -- use `fakeRootCommands` for
# chown, which the layered builders accept via `extraArgs`.
extraCommands ? "",
# Merged over the generated OCI config; wins on conflict.
extraConfig ? { },
extraPasswdLines ? [ ],
extraGroupLines ? [ ],
# Passed through to the underlying dockerTools builder untouched
# (fromImage, fakeRootCommands, enableFakechroot, includeNixDB, ...).
extraArgs ? { },
}:
let
nologin = "/sbin/nologin";
fakeNss = pkgs.fakeNss.override {
extraPasswdLines =
lib.optional (user != null)
"${user}:x:${toString uid}:${toString gid}:${user}:${home}:${nologin}"
++ extraPasswdLines;
extraGroupLines = lib.optional (user != null) "${user}:x:${toString gid}:" ++ extraGroupLines;
};
extras =
lib.optional tls dockerTools.caCertificates
++ lib.optional nss fakeNss
++ lib.optional shell dockerTools.binSh
++ lib.optional usrBinEnv dockerTools.usrBinEnv
++ lib.optional shell pkgs.bashInteractive
++ lib.optional coreutils pkgs.coreutils
++ lib.optional (timezone != null && zoneinfo == "full") pkgs.tzdata;
allContents = contents ++ extras;
# `lib.makeBinPath []` is the empty string; splicing that into a PATH
# yields an empty element, which means "the current directory".
pathParts = lib.filter (s: s != "") [
(lib.makeBinPath (contents ++ lib.optional shell pkgs.bashInteractive
++ lib.optional coreutils pkgs.coreutils))
"/bin"
"/usr/bin"
];
caBundle = "/etc/ssl/certs/ca-bundle.crt";
defaultEnv =
{
PATH = lib.concatStringsSep ":" pathParts;
HOME = home;
}
// lib.optionalAttrs tls {
SSL_CERT_FILE = caBundle;
NIX_SSL_CERT_FILE = caBundle;
CURL_CA_BUNDLE = caBundle;
GIT_SSL_CAINFO = caBundle;
REQUESTS_CA_BUNDLE = caBundle;
}
# With zoneinfo = "single" there is no zone DATABASE to look a name up
# in, so TZ must stay unset: glibc falls back to /etc/localtime, which
# is exactly the file that was copied in. Setting TZ=<name> without a
# TZDIR silently reverts the container to UTC.
// lib.optionalAttrs (timezone != null && zoneinfo == "full") {
TZDIR = "${pkgs.tzdata}/share/zoneinfo";
TZ = timezone;
};
finalEnv = defaultEnv // env;
imageConfig =
lib.filterAttrs (_: v: v != null && v != [ ] && v != { }) {
Entrypoint = entrypoint;
Cmd = cmd;
Env = lib.mapAttrsToList (k: v: "${k}=${toString v}") finalEnv;
# Numeric, not the name: the kernel needs no nss lookup for it, so
# the image still starts if /etc/passwd is ever dropped.
User = if user == null then null else "${toString uid}:${toString gid}";
WorkingDir = workingDir;
ExposedPorts = exposedPorts;
Volumes = volumes;
Labels = labels;
}
// extraConfig;
# `buildImage` rsyncs store trees into the layer with their store modes
# intact (read-only), and only chmods the layer ROOT writable. So a bare
# `mkdir -p etc/...` or `mkdir -p var/tmp` fails with EACCES the moment
# `etc`/`var` came from a package. The layered builders use a symlinkJoin
# (writable dirs) and do not hit this. Make the top level writable first
# so the same commands work under every builder.
writableTop = ''
for d in etc var usr; do
if [ -d "$d" ]; then chmod u+w "$d"; fi
done
'';
steps =
lib.optional tmp ''
mkdir -p tmp var/tmp
chmod 1777 tmp var/tmp
''
++ lib.optional (timezone != null) (
if zoneinfo == "full" then
''
mkdir -p etc
ln -sf ${pkgs.tzdata}/share/zoneinfo/${timezone} etc/localtime
echo ${timezone} > etc/timezone
''
else
# `install`, not `ln -s`: a symlink into the store would drag the
# whole 10 MB zoneinfo database into the closure. A copied TZif
# file contains no store references, so it does not.
''
mkdir -p etc
install -m 0644 ${pkgs.tzdata}/share/zoneinfo/${timezone} etc/localtime
echo ${timezone} > etc/timezone
''
)
++ lib.optional (extraCommands != "") extraCommands;
rootCommands = lib.optionalString (steps != [ ]) (
lib.concatStringsSep "\n" ([ writableTop ] ++ steps)
);
common =
{
inherit name created;
config = imageConfig;
}
// lib.optionalAttrs (tag != null) { inherit tag; }
// lib.optionalAttrs (architecture != null) { inherit architecture; };
layeredArgs =
common
// {
contents = allContents;
inherit maxLayers mtime;
extraCommands = rootCommands;
}
// extraArgs;
singleArgs =
common
// {
copyToRoot = pkgs.buildEnv {
name = "${name}-root";
paths = allContents;
pathsToLink = [
"/bin"
"/etc"
"/share"
"/lib"
"/var"
];
ignoreCollisions = true;
};
extraCommands = rootCommands;
}
// extraArgs;
in
assert lib.assertMsg (name == lib.toLower name)
"minimal-oci-image: image name ${name} must be lowercase; dockerTools lowercases imageName and the two would disagree";
assert lib.assertMsg (user != null || (uid == 65532 && gid == 65532))
"minimal-oci-image: uid/gid are only meaningful together with `user`; without it the image runs as root and nothing reads them";
assert lib.assertMsg (user == null || nss)
"minimal-oci-image: user=${toString user} needs nss=true, otherwise nothing can resolve that uid";
assert lib.assertMsg (lib.elem zoneinfo [ "single" "full" ])
"minimal-oci-image: zoneinfo must be \"single\" or \"full\", got ${zoneinfo}";
if builder == "layered" then
dockerTools.buildLayeredImage layeredArgs
else if builder == "stream" then
dockerTools.streamLayeredImage layeredArgs
else if builder == "single" then
dockerTools.buildImage singleArgs
else
throw "minimal-oci-image: builder must be one of layered|stream|single, got ${builder}";
examples = {
# The floor: one program, its closure, nothing else. No shell, no certs,
# no passwd, no /tmp -- runs as root because there is no one else.
bare = mkMinimalImage {
name = "example-bare";
tag = "1";
entrypoint = [ (lib.getExe pkgs.hello) ];
tls = false;
nss = false;
tmp = false;
};
# The realistic floor: same program, plus the four things a networked
# service actually needs. Non-root, TLS-capable, timezone-aware.
service = mkMinimalImage {
name = "example-service";
tag = "1";
entrypoint = [ (lib.getExe pkgs.hello) ];
user = "app";
timezone = "UTC";
};
# A TLS client, to demonstrate that certificates work.
tlsClient = mkMinimalImage {
name = "example-tls-client";
tag = "1";
contents = [ pkgs.curl ];
entrypoint = [ (lib.getExe pkgs.curl) ];
user = "app";
};
# Same contents as `service` flattened into ONE layer, for comparison.
singleLayer = mkMinimalImage {
name = "example-single-layer";
tag = "1";
builder = "single";
entrypoint = [ (lib.getExe pkgs.hello) ];
user = "app";
timezone = "UTC";
};
};
in
{
inherit mkMinimalImage examples;
}