docker-podman-cdi-rootless¶
Modules
A single NixOS module that picks your container engine (Docker or rootless Podman) behind one flag, and — crucially — keeps that choice decoupled from the CDI/GPU-passthrough and rootless-hardening policy that every host wants regardless of which engine it runs.
The problem¶
Container config tends to get copy-pasted into every host: virtualisation.docker
here, virtualisation.podman there, GPU/CDI daemon settings smeared across
both. Two concerns are tangled together:
- Which engine — Docker vs Podman (with the docker CLI compat shim).
- How it's hardened — CDI for device passthrough, a rootless companion daemon, an explicit DNS resolver, auto-prune.
This module treats (2) as host-independent policy you wire once, and (1) as a per-host flag.
The trap it encodes¶
features.cdi must be set on both the root daemon and the rootless
daemon. The rootless daemon is a separate dockerd process with its own
daemon.settings block — it does not inherit the root daemon's config. If you
only set CDI on the root daemon, GPU workloads launched against the rootless
socket silently can't see the device.
Two related gotchas the defaults handle for you:
- Rootless DNS: the rootless daemon doesn't pick up the host resolver the
way the root daemon does, so its containers need an explicit
dnslist or name resolution just fails. Default is a public resolver; override with a LAN resolver on hosts that must resolve internal names. - No
dockergroup membership: rootless Docker withsetSocketVariablealready hands unprivileged users their own socket viaDOCKER_HOST. Adding a user to the root-equivalentdockergroup on top of that is gratuitous privilege escalation, so this module deliberately does not do it.
CDI is generic and harmless without a GPU — a separate NVIDIA/container-toolkit module can enable the device plumbing, and it "just works" because the CDI feature gate and rootless DNS already live here.
Usage¶
Import default.nix as a module, then per host:
{
modules.virtualisation.containers = {
enable = true;
# usePodman = true; # rootless Podman + docker compat shim
# storageDriver = "overlay2"; # only if the FS supports it (see below)
# podmanExtraPackages = [ pkgs.zfs ]; # userspace tools for your storage driver
# dns = [ "192.0.2.1" ]; # LAN resolver for rootless containers
};
}
Options¶
| Option | Default | Notes |
|---|---|---|
enable |
false |
Turn the engine on. |
usePodman |
false |
Rootless Podman + docker CLI compat instead of Docker. |
enableCdi |
true |
(Docker branch) CDI on both root and rootless daemons. Needed for GPU passthrough. |
enableRootless |
true |
(Docker branch) Run the rootless companion daemon and export DOCKER_HOST. |
storageDriver |
null |
(Docker branch) null = auto-detect. Set only to match the real backing FS. |
podmanExtraPackages |
[ ] |
(Podman branch) Storage-driver userspace tools for Podman (e.g. pkgs.zfs). |
dns |
[ "1.1.1.1" ] |
(Docker branch) Resolvers for rootless-daemon containers. |
autoPrune |
true |
(Docker branch) Periodic docker system prune. |
The branch annotations matter: the Podman branch consumes only usePodman and
podmanExtraPackages. Podman is rootless by construction and does its own CDI
and DNS handling, so enableCdi, enableRootless, dns, storageDriver and
autoPrune are read only when usePodman = false. The decoupling above is
about not re-deriving that policy per host, not about the two engines sharing
one implementation of it.
Caveats¶
storageDrivermust match the filesystem. A driver the backing store doesn't support (btrfson a non-btrfs host,zfswithout the pool) will fail to start the daemon. Leaving itnulllets Docker auto-detect, which is the safe default; set it explicitly only when you know the host FS.- Podman storage graph drivers need userspace tools. On ZFS-backed hosts,
put
pkgs.zfsinpodmanExtraPackagesso Podman can drive that graph driver; same idea for other non-default drivers. - The Podman branch is rootless with the docker-compat shim, so
docker ...commands work but hit Podman. If you rely on Docker-daemon-specific behavior, keepusePodman = false.
Source¶
modules/docker-podman-cdi-rootless/default.nix
# docker-podman-cdi-rootless
#
# One toggleable NixOS module that flips between Docker and Podman (with
# docker-compat) behind a single flag, while decoupling the "container
# engine choice" from the "CDI / rootless hardening" knobs.
#
# The reusable insight: whichever engine you pick, GPU passthrough via CDI
# and rootless operation are host-independent policy. Wire them once here
# instead of copy-pasting daemon settings into every host.
#
# Trap this encodes: `features.cdi` must be set on BOTH the root daemon and
# the rootless daemon settings. The rootless daemon is a *separate* dockerd
# with its own settings block, so a `features.cdi` on the root daemon alone
# leaves rootless GPU workloads broken.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.modules.virtualisation.containers;
in
{
options.modules.virtualisation.containers = {
enable = lib.mkOption {
description = "Enable a Docker-compatible container engine.";
type = lib.types.bool;
default = false;
};
usePodman = lib.mkOption {
description = ''
Use rootless Podman with the docker CLI compat shim instead of Docker.
When false, the Docker daemon is used with a rootless companion daemon.
'';
type = lib.types.bool;
default = false;
};
enableCdi = lib.mkOption {
description = ''
Enable the Container Device Interface (CDI). Required for GPU
passthrough (e.g. the NVIDIA container toolkit). Harmless without a
GPU. Applied to BOTH the root and rootless Docker daemons.
'';
type = lib.types.bool;
default = true;
};
enableRootless = lib.mkOption {
description = ''
Run an additional rootless Docker daemon and export DOCKER_HOST for
unprivileged users. Note: this does NOT add anyone to the
root-equivalent `docker` group — that would be gratuitous privilege.
The rootless socket is the whole point.
'';
type = lib.types.bool;
default = true;
};
storageDriver = lib.mkOption {
description = ''
Docker storage driver. Leave null to let Docker auto-detect (the safe
default). Set to "btrfs", "zfs", "overlay2", etc. only when the host's
backing filesystem actually supports it — a mismatched driver here
will fail to start the daemon.
'';
type = lib.types.nullOr lib.types.str;
default = null;
example = "overlay2";
};
podmanExtraPackages = lib.mkOption {
description = ''
Extra packages made available to Podman. Add the userspace tools for
your backing filesystem here (e.g. [ pkgs.zfs ] on ZFS-backed hosts)
so Podman can drive that storage graph driver.
'';
type = lib.types.listOf lib.types.package;
default = [ ];
example = lib.literalExpression "[ pkgs.zfs ]";
};
dns = lib.mkOption {
description = ''
DNS resolvers for the rootless daemon's containers. The rootless
daemon does not inherit the host's resolver the way the root daemon
does, so set this explicitly. Override with your LAN resolver on hosts
that must resolve internal names.
'';
type = lib.types.listOf lib.types.str;
default = [ "1.1.1.1" ];
example = lib.literalExpression ''[ "192.0.2.1" ]'';
};
autoPrune = lib.mkOption {
description = "Periodically `docker system prune` to reclaim disk.";
type = lib.types.bool;
default = true;
};
};
config = lib.mkIf cfg.enable {
# --- Podman branch: rootless by design, docker CLI compat shim on ---
virtualisation.podman = lib.mkIf cfg.usePodman {
enable = true;
dockerCompat = true;
extraPackages = cfg.podmanExtraPackages;
};
# --- Docker branch ---
virtualisation.docker = lib.mkIf (!cfg.usePodman) {
enable = true;
enableOnBoot = true;
autoPrune.enable = cfg.autoPrune;
storageDriver = cfg.storageDriver;
# Root daemon settings.
daemon.settings = lib.mkIf cfg.enableCdi {
features.cdi = true;
};
# Rootless companion daemon — a SEPARATE dockerd with its OWN settings.
rootless = lib.mkIf cfg.enableRootless {
enable = true;
setSocketVariable = true;
daemon.settings = {
# CDI must be repeated here; the root daemon's setting does not
# carry over to the rootless daemon. This is the load-bearing trap.
features.cdi = lib.mkIf cfg.enableCdi true;
dns = cfg.dns;
};
};
};
};
}