Podman and Docker patterns

Updated

July 30, 2026

Podman and Docker patterns

Goal: Run OCI containers on NixOS via declarative modules (prefer Podman), understand rootless vs rootful trade-offs, and compare honestly to NixOS nspawn—without turning the host into an imperative docker run snowflake.

Why this chapter exists

The previous chapter covered NixOS-native containers. The industry default many colleagues know is Docker/Podman + images. On NixOS you should still:

  1. Prefer a nixpkgs service module when it exists
  2. When you need an image, declare it in the flake/host modules
  3. Track ports, volumes, and update cadence

Theory 1 — Podman vs Docker on NixOS

Engine Notes
Podman Daemonless roots; good rootless story; often preferred on NixOS labs
Docker Familiar Compose ecosystem; virtualisation.docker module

House suggestion: Podman unless you must match a Docker-only workflow.

# modules/services/podman.nix
{
  virtualisation.podman = {
    enable = true;
    dockerCompat = true; # optional: docker CLI alias — document if used
  };
}

Docker alternative sketch:

{
  virtualisation.docker.enable = true;
  # users.users.alice.extraGroups = [ "docker" ]; # rootful socket risk
}

docker group membership is effectively near-root. Prefer rootless or rootful with care.


Theory 2 — Rootless vs rootful

Mode Pros Cons
Rootless Better default security Port <1024 hard; storage quirks
Rootful Simpler networking/ports Higher blast radius

Rootless Podman for user workloads; rootful only when needed—or put proxy on host and map high ports.


Theory 3 — Declarative containers (OCI)

Common patterns include virtualisation.oci-containers.containers (backend docker/podman).

# modules/services/oci-hello.nix
{
  virtualisation.oci-containers = {
    backend = "podman";
    containers = {
      hello-lab = {
        image = "docker.io/library/nginx:alpine";
        ports = [ "127.0.0.1:8081:80" ];
        # volumes = [ "/var/lib/hello-lab:/usr/share/nginx/html:ro" ];
        autoStart = true;
      };
    };
  };
}
Field Discipline
image Pin tags carefully; digests better for prod
ports Prefer 127.0.0.1: binds
volumes Host paths must be managed (tmpfiles/persist)
environment No secrets in plaintext—use secret files / sops-generated env

Exact option schema: confirm on search.nixos.org for 26.05.


Theory 4 — Images and supply chain (awareness)

Practice Why
Pin by digest Tags move
Prefer known registries Reduce surprise
Update deliberately Same as flake lock discipline
Scan when available Optional tooling

Nix can build images from packages (dockerTools)—packaging mindset later. Here: consume one official image declaratively.

# Prefer tags you understand; better: digest pins when available
image = "docker.io/library/nginx:1.27";
# image = "nginx@sha256:…";

Floating :latest is the OCI equivalent of an unlocked flake input.


Theory 5 — Compare to nspawn

Dimension nspawn containers.* OCI Podman
Guest config NixOS modules Image + env/cmd
Updates nixpkgs guest Image pulls
Good for Nix-first isolation Vendor images
Store integration Strong Looser

Write your own two-sentence preference for the service slice.


Theory 6 — Firewall and proxy integration

clients → host :443 Caddy/Nginx → 127.0.0.1:8081 → OCI nginx

Do not publish OCI ports on 0.0.0.0 unless docs/ports.md says so and auth exists.

# proxy snippet idea
# reverse_proxy 127.0.0.1:8081

Theory 7 — Stateful volumes

If the container needs state:

  1. Create host directory with tmpfiles or activation
  2. Bind mount
  3. Include in backup story
  4. Never put secrets into world-readable volume without mode care

Theory 8 — Rootless paths and user namespaces

Rootless Podman stores containers under the user data dir. On NixOS:

Concern Note
subuid/subgid User must be mapped
Lingering User services may need lingering for boot-start
Ports <1024 Often need rootful or redirect
podman info | head
id alice  # check subuids if rootless fails

Theory 9 — Imperative snowflakes vs flake

podman run --rm -p 127.0.0.1:8082:80 docker.io/library/nginx:alpine
# stop/remove; do NOT leave unmanaged containers as the real setup

Imperative is fine for five-minute experiments. The rebuildable truth lives in virtualisation.oci-containers.


Worked example — Full small stack slice

{ ... }:
{
  imports = [ ./podman.nix ];

  virtualisation.oci-containers.backend = "podman";
  virtualisation.oci-containers.containers.webish = {
    image = "docker.io/library/nginx:1.27";
    ports = [ "127.0.0.1:8081:80" ];
    autoStart = true;
  };
}
curl -sS http://127.0.0.1:8081/ | head
podman ps

Exercises

Exercise 1 — Enable Podman (or Docker)

# add module, rebuild
podman info 2>/dev/null || docker info 2>/dev/null

Document rootless vs rootful choice.

Exercise 2 — Declarative OCI container

Define one container with localhost port publish. Rebuild. Verify:

podman ps
curl -sS http://127.0.0.1:8081/ | head

Exercise 3 — Imperative contrast (then undo)

Short imperative run; stop/remove; journal why the flake definition wins.

Exercise 4 — Wire to proxy (optional but valuable)

Add a reverse_proxy path to the OCI port. Update ports.md.

Exercise 5 — Secret anti-pattern check

Confirm no environment = { PASSWORD = "..." } in nix. If the image needs a password, plan sops-backed env file mount.

Exercise 6 — Comparison table filled

Complete nspawn vs OCI table in docs/isolation.md with your results.

Exercise 7 — Volume inventory

podman volume ls 2>/dev/null || docker volume ls 2>/dev/null || true

Map each volume to a backup/persist decision.

Exercise 8 — Update story one-pager

Write five lines: how you will refresh images, who tests, whether flake lock/image digest changes ship together.

Exercise 9 — autoStart reboot proof

Reboot or restart the oci unit; confirm container returns.

Exercise 10 — Port conflict check

ss -tlnp | grep 8081

Avoid clashing with TLS chapter backend on 8080.

Exercise 11 — docker group risk note

If you used Docker + group, write why it is near-root.

Exercise 12 — Image pin upgrade

Change tag deliberately; rebuild; observe pull; document.

Exercise 13 — Disable cleanly

Remove container from config; rebuild; podman ps clean.

Exercise 14 — Isolation.md final

State house preference: native / nspawn / OCI for each service in the slice.


Common gotchas

Symptom / mistake What to do
Port already in use Conflict with TLS backend—choose free port
Image pull fails Network/registry; mirror; pin
Rootless port 80 bind fails Use high ports + host proxy
docker group casual Avoid; understand privilege
State lost on recreate Bind volume on host
Forgot autoStart Container missing after reboot
Secrets in environment= sops file mounts
:latest forever Pin tag/digest
Imperative left running Remove snowflake

Checkpoint

  • Podman or Docker enabled via NixOS module
  • One OCI container declarative in flake
  • Localhost publish; ports documented
  • Imperative snowflake cleaned up
  • Isolation docs updated vs nspawn
  • No plaintext secrets in container env
  • Image update story written
  • autoStart verified or documented

Journal (optional)

Write three personal gotchas before continuing.