1{
2 config,
3 options,
4 lib,
5 pkgs,
6 ...
7}:
8
9with lib;
10let
11 cfg = config.virtualisation.oci-containers;
12 proxy_env = config.networking.proxy.envVars;
13
14 defaultBackend = options.virtualisation.oci-containers.backend.default;
15
16 containerOptions =
17 { name, ... }:
18 {
19
20 config = {
21 podman = mkIf (cfg.backend == "podman") { };
22 };
23
24 options = {
25
26 image = mkOption {
27 type = with types; str;
28 description = "OCI image to run.";
29 example = "library/hello-world";
30 };
31
32 imageFile = mkOption {
33 type = with types; nullOr package;
34 default = null;
35 description = ''
36 Path to an image file to load before running the image. This can
37 be used to bypass pulling the image from the registry.
38
39 The `image` attribute must match the name and
40 tag of the image contained in this file, as they will be used to
41 run the container with that image. If they do not match, the
42 image will be pulled from the registry as usual.
43 '';
44 example = literalExpression "pkgs.dockerTools.buildImage {...};";
45 };
46
47 imageStream = mkOption {
48 type = with types; nullOr package;
49 default = null;
50 description = ''
51 Path to a script that streams the desired image on standard output.
52
53 This option is mainly intended for use with
54 `pkgs.dockerTools.streamLayeredImage` so that the intermediate
55 image archive does not need to be stored in the Nix store. For
56 larger images this optimization can significantly reduce Nix store
57 churn compared to using the `imageFile` option, because you don't
58 have to store a new copy of the image archive in the Nix store
59 every time you change the image. Instead, if you stream the image
60 then you only need to build and store the layers that differ from
61 the previous image.
62 '';
63 example = literalExpression "pkgs.dockerTools.streamLayeredImage {...};";
64 };
65
66 serviceName = mkOption {
67 type = types.str;
68 default = "${cfg.backend}-${name}";
69 defaultText = "<backend>-<name>";
70 description = "Systemd service name that manages the container";
71 };
72
73 login = {
74
75 username = mkOption {
76 type = with types; nullOr str;
77 default = null;
78 description = "Username for login.";
79 };
80
81 passwordFile = mkOption {
82 type = with types; nullOr str;
83 default = null;
84 description = "Path to file containing password.";
85 example = "/etc/nixos/dockerhub-password.txt";
86 };
87
88 registry = mkOption {
89 type = with types; nullOr str;
90 default = null;
91 description = "Registry where to login to.";
92 example = "https://docker.pkg.github.com";
93 };
94
95 };
96
97 cmd = mkOption {
98 type = with types; listOf str;
99 default = [ ];
100 description = "Commandline arguments to pass to the image's entrypoint.";
101 example = [ "--port=9000" ];
102 };
103
104 labels = mkOption {
105 type = with types; attrsOf str;
106 default = { };
107 description = "Labels to attach to the container at runtime.";
108 example = {
109 "traefik.https.routers.example.rule" = "Host(`example.container`)";
110 };
111 };
112
113 entrypoint = mkOption {
114 type = with types; nullOr str;
115 description = "Override the default entrypoint of the image.";
116 default = null;
117 example = "/bin/my-app";
118 };
119
120 environment = mkOption {
121 type = with types; attrsOf str;
122 default = { };
123 description = "Environment variables to set for this container.";
124 example = {
125 DATABASE_HOST = "db.example.com";
126 DATABASE_PORT = "3306";
127 };
128 };
129
130 environmentFiles = mkOption {
131 type = with types; listOf path;
132 default = [ ];
133 description = "Environment files for this container.";
134 example = [
135 /path/to/.env
136 /path/to/.env.secret
137 ];
138 };
139
140 log-driver = mkOption {
141 type = types.str;
142 default = "journald";
143 description = ''
144 Logging driver for the container. The default of
145 `"journald"` means that the container's logs will be
146 handled as part of the systemd unit.
147
148 For more details and a full list of logging drivers, refer to respective backends documentation.
149
150 For Docker:
151 [Docker engine documentation](https://docs.docker.com/engine/logging/configure/)
152
153 For Podman:
154 Refer to the {manpage}`docker-run(1)` man page.
155 '';
156 };
157
158 ports = mkOption {
159 type = with types; listOf str;
160 default = [ ];
161 description = ''
162 Network ports to publish from the container to the outer host.
163
164 Valid formats:
165 - `<ip>:<hostPort>:<containerPort>`
166 - `<ip>::<containerPort>`
167 - `<hostPort>:<containerPort>`
168 - `<containerPort>`
169
170 Both `hostPort` and `containerPort` can be specified as a range of
171 ports. When specifying ranges for both, the number of container
172 ports in the range must match the number of host ports in the
173 range. Example: `1234-1236:1234-1236/tcp`
174
175 When specifying a range for `hostPort` only, the `containerPort`
176 must *not* be a range. In this case, the container port is published
177 somewhere within the specified `hostPort` range.
178 Example: `1234-1236:1234/tcp`
179
180 Publishing a port bypasses the NixOS firewall. If the port is not
181 supposed to be shared on the network, make sure to publish the
182 port to localhost.
183 Example: `127.0.0.1:1234:1234`
184
185 Refer to the
186 [Docker engine documentation](https://docs.docker.com/engine/network/#published-ports) for full details.
187 '';
188 example = [
189 "127.0.0.1:8080:9000"
190 ];
191 };
192
193 user = mkOption {
194 type = with types; nullOr str;
195 default = null;
196 description = ''
197 Override the username or UID (and optionally groupname or GID) used
198 in the container.
199 '';
200 example = "nobody:nogroup";
201 };
202
203 volumes = mkOption {
204 type = with types; listOf str;
205 default = [ ];
206 description = ''
207 List of volumes to attach to this container.
208
209 Note that this is a list of `"src:dst"` strings to
210 allow for `src` to refer to `/nix/store` paths, which
211 would be difficult with an attribute set. There are
212 also a variety of mount options available as a third
213 field; please refer to the
214 [docker engine documentation](https://docs.docker.com/engine/storage/volumes/) for details.
215 '';
216 example = [
217 "volume_name:/path/inside/container"
218 "/path/on/host:/path/inside/container"
219 ];
220 };
221
222 workdir = mkOption {
223 type = with types; nullOr str;
224 default = null;
225 description = "Override the default working directory for the container.";
226 example = "/var/lib/hello_world";
227 };
228
229 dependsOn = mkOption {
230 type = with types; listOf str;
231 default = [ ];
232 description = ''
233 Define which other containers this one depends on. They will be added to both After and Requires for the unit.
234
235 Use the same name as the attribute under `virtualisation.oci-containers.containers`.
236 '';
237 example = literalExpression ''
238 virtualisation.oci-containers.containers = {
239 node1 = {};
240 node2.dependsOn = [ "node1" ];
241 };
242 '';
243 };
244
245 hostname = mkOption {
246 type = with types; nullOr str;
247 default = null;
248 description = "The hostname of the container.";
249 example = "hello-world";
250 };
251
252 preRunExtraOptions = mkOption {
253 type = with types; listOf str;
254 default = [ ];
255 description = "Extra options for {command}`${defaultBackend}` that go before the `run` argument.";
256 example = [
257 "--runtime"
258 "runsc"
259 ];
260 };
261
262 extraOptions = mkOption {
263 type = with types; listOf str;
264 default = [ ];
265 description = "Extra options for {command}`${defaultBackend} run`.";
266 example = [ "--network=host" ];
267 };
268
269 autoStart = mkOption {
270 type = with types; bool;
271 default = true;
272 description = ''
273 When enabled, the container is automatically started on boot.
274 If this option is set to false, the container has to be started on-demand via its service.
275 '';
276 };
277
278 podman = mkOption {
279 type = types.nullOr (
280 types.submodule {
281 options = {
282 sdnotify = mkOption {
283 default = "conmon";
284 type = types.enum [
285 "conmon"
286 "healthy"
287 "container"
288 ];
289 description = ''
290 Determines how `podman` should notify systemd that the unit is ready. There are
291 [three options](https://docs.podman.io/en/latest/markdown/podman-run.1.html#sdnotify-container-conmon-healthy-ignore):
292
293 * `conmon`: marks the unit as ready when the container has started.
294 * `healthy`: marks the unit as ready when the [container's healthcheck](https://docs.podman.io/en/stable/markdown/podman-healthcheck-run.1.html) passes.
295 * `container`: `NOTIFY_SOCKET` is passed into the container and the process inside the container needs to indicate on its own that it's ready.
296 '';
297 };
298 user = mkOption {
299 default = "root";
300 type = types.str;
301 description = ''
302 The user under which the container should run.
303 '';
304 };
305 };
306 }
307 );
308 default = null;
309 description = ''
310 Podman-specific settings in OCI containers. These must be null when using
311 the `docker` backend.
312 '';
313 };
314
315 pull = mkOption {
316 type =
317 with types;
318 enum [
319 "always"
320 "missing"
321 "never"
322 "newer"
323 ];
324 default = "missing";
325 description = ''
326 Image pull policy for the container. Must be one of: always, missing, never, newer
327 '';
328 };
329
330 capabilities = mkOption {
331 type = with types; lazyAttrsOf (nullOr bool);
332 default = { };
333 description = ''
334 Capabilities to configure for the container.
335 When set to true, capability is added to the container.
336 When set to false, capability is dropped from the container.
337 When null, default runtime settings apply.
338 '';
339 example = {
340 SYS_ADMIN = true;
341 SYS_WRITE = false;
342 };
343 };
344
345 devices = mkOption {
346 type = with types; listOf str;
347 default = [ ];
348 description = ''
349 List of devices to attach to this container.
350 '';
351 example = [
352 "/dev/dri:/dev/dri"
353 ];
354 };
355
356 privileged = mkOption {
357 type = with types; bool;
358 default = false;
359 description = ''
360 Give extended privileges to the container
361 '';
362 };
363
364 autoRemoveOnStop = mkOption {
365 type = types.bool;
366 default = true;
367 description = ''
368 Automatically remove the container when it is stopped or killed
369 '';
370 };
371
372 networks = mkOption {
373 type = with types; listOf str;
374 default = [ ];
375 description = ''
376 Networks to attach the container to
377 '';
378 };
379 };
380 };
381
382 isValidLogin =
383 login: login.username != null && login.passwordFile != null && login.registry != null;
384
385 mkService =
386 name: container:
387 let
388 dependsOn = lib.attrsets.mapAttrsToList (k: v: "${v.serviceName}.service") (
389 lib.attrsets.getAttrs container.dependsOn cfg.containers
390 );
391 escapedName = escapeShellArg name;
392 preStartScript = pkgs.writeShellApplication {
393 name = "pre-start";
394 runtimeInputs = [ ];
395 text = ''
396 ${cfg.backend} rm -f ${name} || true
397 ${optionalString (isValidLogin container.login) ''
398 # try logging in, if it fails, check if image exists locally
399 ${cfg.backend} login \
400 ${container.login.registry} \
401 --username ${escapeShellArg container.login.username} \
402 --password-stdin < ${container.login.passwordFile} \
403 || ${cfg.backend} image inspect ${container.image} >/dev/null \
404 || { echo "image doesn't exist locally and login failed" >&2 ; exit 1; }
405 ''}
406 ${optionalString (container.imageFile != null) ''
407 ${cfg.backend} load -i ${container.imageFile}
408 ''}
409 ${optionalString (container.imageStream != null) ''
410 ${container.imageStream} | ${cfg.backend} load
411 ''}
412 ${optionalString (cfg.backend == "podman") ''
413 rm -f /run/${escapedName}/ctr-id
414 ''}
415 '';
416 };
417
418 effectiveUser = container.podman.user or "root";
419 inherit (config.users.users.${effectiveUser}) uid;
420 dependOnLingerService =
421 cfg.backend == "podman" && effectiveUser != "root" && config.users.users.${effectiveUser}.linger;
422 in
423 {
424 wantedBy = [ ] ++ optional (container.autoStart) "multi-user.target";
425 wants =
426 lib.optional (container.imageFile == null && container.imageStream == null) "network-online.target"
427 ++ lib.optionals dependOnLingerService [ "linger-users.service" ];
428 after =
429 lib.optionals (cfg.backend == "docker") [
430 "docker.service"
431 "docker.socket"
432 ]
433 # if imageFile or imageStream is not set, the service needs the network to download the image from the registry
434 ++ lib.optionals (container.imageFile == null && container.imageStream == null) [
435 "network-online.target"
436 ]
437 ++ dependsOn
438 ++ lib.optionals dependOnLingerService [ "linger-users.service" ]
439 ++ lib.optionals (effectiveUser != "root" && container.podman.sdnotify == "healthy") [
440 "user@${toString uid}.service"
441 ];
442 requires =
443 dependsOn
444 ++ lib.optionals (effectiveUser != "root" && container.podman.sdnotify == "healthy") [
445 "user@${toString uid}.service"
446 ];
447 environment = lib.mkMerge [
448 proxy_env
449 (mkIf (cfg.backend == "podman" && container.podman.user != "root") {
450 HOME = config.users.users.${container.podman.user}.home;
451 })
452 ];
453
454 path =
455 if cfg.backend == "docker" then
456 [ config.virtualisation.docker.package ]
457 else if cfg.backend == "podman" then
458 [ config.virtualisation.podman.package ]
459 else
460 throw "Unhandled backend: ${cfg.backend}";
461
462 script = concatStringsSep " \\\n " (
463 [
464 "exec ${cfg.backend} "
465 ]
466 ++ map escapeShellArg container.preRunExtraOptions
467 ++ [
468 "run"
469 "--name=${escapedName}"
470 "--log-driver=${container.log-driver}"
471 ]
472 ++ optional (container.entrypoint != null) "--entrypoint=${escapeShellArg container.entrypoint}"
473 ++ optional (container.hostname != null) "--hostname=${escapeShellArg container.hostname}"
474 ++ lib.optionals (cfg.backend == "podman") [
475 "--cidfile=/run/${escapedName}/ctr-id"
476 "--cgroups=enabled"
477 "--sdnotify=${container.podman.sdnotify}"
478 "-d"
479 "--replace"
480 ]
481 ++ (mapAttrsToList (k: v: "-e ${escapeShellArg k}=${escapeShellArg v}") container.environment)
482 ++ map (f: "--env-file ${escapeShellArg f}") container.environmentFiles
483 ++ map (p: "-p ${escapeShellArg p}") container.ports
484 ++ optional (container.user != null) "-u ${escapeShellArg container.user}"
485 ++ map (v: "-v ${escapeShellArg v}") container.volumes
486 ++ (mapAttrsToList (k: v: "-l ${escapeShellArg k}=${escapeShellArg v}") container.labels)
487 ++ optional (container.workdir != null) "-w ${escapeShellArg container.workdir}"
488 ++ optional (container.privileged) "--privileged"
489 ++ optional (container.autoRemoveOnStop) "--rm"
490 ++ mapAttrsToList (k: _: "--cap-add=${escapeShellArg k}") (
491 filterAttrs (_: v: v == true) container.capabilities
492 )
493 ++ mapAttrsToList (k: _: "--cap-drop=${escapeShellArg k}") (
494 filterAttrs (_: v: v == false) container.capabilities
495 )
496 ++ map (d: "--device=${escapeShellArg d}") container.devices
497 ++ map (n: "--network=${escapeShellArg n}") (lib.lists.unique container.networks)
498 ++ [ "--pull ${escapeShellArg container.pull}" ]
499 ++ map escapeShellArg container.extraOptions
500 ++ [ container.image ]
501 ++ map escapeShellArg container.cmd
502 );
503
504 preStop =
505 if cfg.backend == "podman" then
506 "podman stop --ignore --cidfile=/run/${escapedName}/ctr-id"
507 else
508 "${cfg.backend} stop ${name} || true";
509
510 postStop =
511 if cfg.backend == "podman" then
512 "podman rm -f --ignore --cidfile=/run/${escapedName}/ctr-id"
513 else
514 "${cfg.backend} rm -f ${name} || true";
515
516 unitConfig = mkIf (effectiveUser != "root") {
517 RequiresMountsFor = "/run/user/${toString uid}/containers";
518 };
519
520 serviceConfig = {
521 ### There is no generalized way of supporting `reload` for docker
522 ### containers. Some containers may respond well to SIGHUP sent to their
523 ### init process, but it is not guaranteed; some apps have other reload
524 ### mechanisms, some don't have a reload signal at all, and some docker
525 ### images just have broken signal handling. The best compromise in this
526 ### case is probably to leave ExecReload undefined, so `systemctl reload`
527 ### will at least result in an error instead of potentially undefined
528 ### behaviour.
529 ###
530 ### Advanced users can still override this part of the unit to implement
531 ### a custom reload handler, since the result of all this is a normal
532 ### systemd service from the perspective of the NixOS module system.
533 ###
534 # ExecReload = ...;
535 ###
536 ExecStartPre = [ "${preStartScript}/bin/pre-start" ];
537 TimeoutStartSec = 0;
538 TimeoutStopSec = 120;
539 Restart = "always";
540 }
541 // optionalAttrs (cfg.backend == "podman") {
542 Environment = "PODMAN_SYSTEMD_UNIT=%n";
543 Type = "notify";
544 NotifyAccess = "all";
545 Delegate = mkIf (container.podman.sdnotify == "healthy") true;
546 User = effectiveUser;
547 RuntimeDirectory = escapedName;
548 };
549 };
550
551in
552{
553 imports = [
554 (lib.mkChangedOptionModule [ "docker-containers" ] [ "virtualisation" "oci-containers" ] (oldcfg: {
555 backend = "docker";
556 containers = lib.mapAttrs (
557 n: v:
558 builtins.removeAttrs (
559 v
560 // {
561 extraOptions = v.extraDockerOptions or [ ];
562 }
563 ) [ "extraDockerOptions" ]
564 ) oldcfg.docker-containers;
565 }))
566 ];
567
568 options.virtualisation.oci-containers = {
569
570 backend = mkOption {
571 type = types.enum [
572 "podman"
573 "docker"
574 ];
575 default = if versionAtLeast config.system.stateVersion "22.05" then "podman" else "docker";
576 description = "The underlying Docker implementation to use.";
577 };
578
579 containers = mkOption {
580 default = { };
581 type = types.attrsOf (types.submodule containerOptions);
582 description = "OCI (Docker) containers to run as systemd services.";
583 };
584
585 };
586
587 config = lib.mkIf (cfg.containers != { }) (
588 lib.mkMerge [
589 {
590 systemd.services = mapAttrs' (n: v: nameValuePair v.serviceName (mkService n v)) cfg.containers;
591
592 assertions =
593 let
594 toAssertions =
595 name:
596 {
597 imageFile,
598 imageStream,
599 podman,
600 ...
601 }:
602 [
603 {
604 assertion = imageFile == null || imageStream == null;
605
606 message = "virtualisation.oci-containers.containers.${name}: You can only define one of imageFile and imageStream";
607 }
608 {
609 assertion = cfg.backend == "docker" -> podman == null;
610 message = "virtualisation.oci-containers.containers.${name}: Cannot set `podman` option if backend is `docker`.";
611 }
612 {
613 assertion =
614 cfg.backend == "podman" && podman.sdnotify == "healthy" && podman.user != "root"
615 -> config.users.users.${podman.user}.uid != null;
616 message = ''
617 Rootless container ${name} (with podman and sdnotify=healthy)
618 requires that its running user ${podman.user} has a statically specified uid.
619 '';
620 }
621 ];
622 in
623 concatMap (name: toAssertions name cfg.containers.${name}) (lib.attrNames cfg.containers);
624
625 warnings = mkIf (cfg.backend == "podman") (
626 lib.foldlAttrs (
627 warnings: name:
628 { podman, ... }:
629 let
630 inherit (config.users.users.${podman.user}) linger;
631 in
632 warnings
633 ++ lib.optional (podman.user != "root" && linger && podman.sdnotify == "conmon") ''
634 Podman container ${name} is configured as rootless (user ${podman.user})
635 with `--sdnotify=conmon`, but lingering for this user is turned on.
636 ''
637 ++ lib.optional (podman.user != "root" && !linger && podman.sdnotify == "healthy") ''
638 Podman container ${name} is configured as rootless (user ${podman.user})
639 with `--sdnotify=healthy`, but lingering for this user is turned off.
640 ''
641 ) [ ] cfg.containers
642 );
643 }
644 (lib.mkIf (cfg.backend == "podman") {
645 virtualisation.podman.enable = true;
646 })
647 (lib.mkIf (cfg.backend == "docker") {
648 virtualisation.docker.enable = true;
649 })
650 ]
651 );
652}