Podman for People Who Distrust the Docker Daemon

Daemonless, rootless, and answerable to systemd

Contents

I ran Docker happily for years, and then one afternoon I actually read what my docker group membership meant and felt slightly sick. I had added my everyday user to that group ages ago because typing sudo in front of every command is tedious, the way everyone does, the way every tutorial tells you to. What nobody mentions in the same breath is that this is functionally the same as handing that user a password-free root shell. Podman is the container engine I moved to when that stopped sitting right with me, and I have not wanted to go back.

Let me explain the why before any of the how, because the why is the entire reason I bothered to relearn muscle memory I had spent a decade building.

The daemon is the problem, and it’s a bigger one than it looks

Advertisement

dockerd is a long-running daemon that runs as root. Every container you launch is a child of that one process. It listens on a Unix socket, /var/run/docker.sock, and anything that can talk to that socket can ask the daemon to do root things on its behalf. That is the whole design, and it works, but it has consequences that are easy to wave away until you sit and think about them.

Membership of the docker group grants access to that socket. With access to that socket I can run a container that bind-mounts the host’s / into itself and then chroots into it. At that point I am editing /etc/shadow on the host as root, from an account that was supposed to be unprivileged. There is no escalation trick here, no CVE, nothing clever — this is the documented, intended behaviour of the tool. The docker group is root with extra steps, and the extra steps take about four seconds.

Then there is the blast radius. One process is the parent of every container on the box. If dockerd wedges, gets OOM-killed, or you restart it during an upgrade, everything it supervises is affected at once. I have watched a daemon restart take down a stack of unrelated services that had no business sharing a fate, purely because they shared a parent. For a homelab where I am running a dozen small things that should be independent, that single point of failure always nagged at me.

None of this makes Docker bad software. It makes Docker a piece of software with an architecture I would rather not build my defence-in-depth on top of. The root daemon is load-bearing, and I wanted it out from under the floor.

How Podman does it differently

Podman throws the daemon away. There is no long-running privileged process listening for instructions. When I type podman run, the Podman binary itself does the work of setting up the container, then fork-execs a small supervisor called conmon (container monitor) that stays attached to that one container to handle its I/O and record its exit status. Each container gets its own conmon. The containers are children of my shell session, or of systemd if I wire them up properly, and they answer to me.

The immediate consequence is that there is nothing privileged sitting idle by default. No socket that is root-equivalent, no group whose members are secretly administrators. If a Podman process dies, it takes its own container with it and leaves the others entirely alone. The failure domain shrank to exactly one container, which is where I wanted it.

The second thing Podman does is run rootless as the default. My ordinary user owns its containers. Inside the container, processes still think they are running as root — UID 0, the way container images expect — but that UID 0 is mapped through a Linux user namespace to my unprivileged host UID. Root in the container is a lie the kernel tells the container, and on the host it is just me. If something breaks out, it breaks out into an account that can barely do anything.

That mapping is driven by two files, /etc/subuid and /etc/subgid, which hand each user a range of subordinate IDs, typically 65,536 of them. So my host UID might be 1000, and the container’s UID 0 maps to 1000, while UID 1 inside maps to the first entry of my subordinate range, say 100000, and so on up the block. If those files have no entry for your user, rootless Podman cannot build the mapping and refuses to start containers, which is the first thing I check when a fresh box misbehaves. I go deeper on user namespaces and why this matters for least privilege in Rootless containers: least privilege for the paranoid; here it is enough to know the mapping exists and where it comes from.

The part that made the switch painless: the CLI is a near drop-in for Docker’s. The verbs and flags are the same, it pulls OCI images from the same registries, and it reads the same Dockerfiles. Most days I run alias docker=podman and carry on as though nothing changed. My old Compose files still work through podman compose, and the Compose habits I wrote up in Docker Compose patterns that age well carried across intact — the same restart policies, named volumes, and healthcheck conventions behave the way I expect.

Podman also leans into the Kubernetes model in a way Docker never did. It groups containers into a pod — the actual Kubernetes concept — where the members share a network namespace and can reach each other on localhost. Better still, podman kube generate emits Kubernetes YAML from a running pod, and podman kube play builds a pod back from that YAML. When a homelab service eventually earns a place on a real cluster, that round-trip is a genuine bridge rather than a rewrite.

Quadlet: handing your containers to systemd properly

Advertisement

For a long time the answer to “how do I keep this container running across reboots” was podman generate systemd, which spat out a unit file you copied into place. That approach is deprecated now, and good riddance — the generated units were verbose and awkward to maintain. The modern way is Quadlet.

Quadlet lets me describe a container in a small declarative file that looks like a systemd unit, drop it in the right directory, and let a generator translate it into a real .service at boot. For rootless containers the directory is ~/.config/containers/systemd/; for system-wide ones it is /etc/containers/systemd/. Here is the unit I use for a small self-hosted web app, saved as ~/.config/containers/systemd/linkwarden.container:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
[Unit]
Description=Linkwarden bookmark archive
After=network-online.target
Wants=network-online.target

[Container]
Image=ghcr.io/linkwarden/linkwarden:latest
AutoUpdate=registry
PublishPort=8100:3000
Volume=linkwarden-data:/data:U
Environment=NEXTAUTH_URL=https://links.mylab.local
Environment=DATABASE_URL=postgres://linkwarden@db:5432/linkwarden
# keep it off the internet directly; a reverse proxy fronts it
Network=web.network

[Service]
Restart=on-failure
TimeoutStartSec=120

[Install]
WantedBy=default.target

After writing that I run systemctl --user daemon-reload so the generator picks it up, then systemctl --user start linkwarden. Podman has manufactured a proper service called linkwarden.service behind the scenes, and I manage it with all the ordinary systemd verbs — status, restart, journalctl --user -u linkwarden for logs. The [Install] section with WantedBy=default.target means it starts automatically for my user session. A first rootless run and enabling boot-time start looks like this:

1
2
podman run -d --name whoami -p 8200:80 traefik/whoami
loginctl enable-linger smarc

That AutoUpdate=registry label is worth calling out. It marks the container as eligible for podman auto-update, which checks the registry for a newer digest on the tag you pinned, pulls it, and restarts the service cleanly if the image moved. Podman ships a systemd timer, podman-auto-update.timer, that runs this on a schedule, so my homelab keeps itself current without me babysitting it. I keep it on for leaf services and off for anything where I want to read the changelog first.

One more piece worth naming is rootless networking, because it works differently from the root case. Recent Podman uses pasta by default to give each rootless container a userspace network stack, with slirp4netns as the older fallback. For containers that need to find each other by name on a shared network, Podman runs aardvark-dns, a tiny DNS server that resolves container names within that network. This is why the Network=web.network line above lets my proxy reach the app by hostname rather than by juggling IP addresses.

Troubleshooting the things that will actually bite you

The switch is smooth right up until it isn’t, and the rough edges are all consequences of running rootless. Here are the ones that cost me time so they cost you less.

You cannot bind ports below 1024. Rootless means unprivileged, and unprivileged processes are not allowed to bind low ports. So PublishPort=80:80 fails outright. My default answer is to publish a high port like 8100 and put a reverse proxy in front, which is how the Quadlet above is written. If you genuinely need a container on a low port, lower the kernel’s threshold with sysctl net.ipv4.ip_unprivileged_port_start=80 and make it persistent in /etc/sysctl.d/.

The service dies when you log out. By default a user’s systemd session and everything under it is torn down when the last session for that user ends. Log out of SSH and your carefully configured container evaporates. The fix is loginctl enable-linger smarc, which tells systemd to keep a user manager running for that account whether or not anyone is logged in. Forgetting this is the single most common reason people conclude rootless Podman “doesn’t stay up”.

Memory and CPU limits silently do nothing. Flags like --memory and --cpus need cgroups v2 with delegation to the user, so the user manager is actually permitted to set those controllers. Modern distributions ship cgroups v2 and delegate the right controllers, but on an older or hand-configured box you can set a limit, see no error, and get no enforcement. Check podman info for the cgroup version and confirm the memory and cpu controllers appear in your user’s cgroup.controllers.

File ownership on volumes looks wrong. Because of the UID shift, a file the container writes as its UID 0 lands on the host owned by your subordinate range, and a file you created on the host as UID 1000 shows up inside the container owned by some high, unfamiliar number. The clean fix is the :U mount option, as on the Volume line above, which tells Podman to chown the volume’s contents to match the container’s mapped user on startup. For an existing directory you can do the remap by hand with podman unshare chown -R 0:0 /path/to/data, which runs the chown inside the same user namespace the container will see.

Ping doesn’t work from inside a rootless container. ICMP from an unprivileged user namespace needs the kernel to allow it, controlled by net.ipv4.ping_group_range. If ping fails but everything else networks fine, that sysctl is why; widen the range in /etc/sysctl.d/ and it starts working. It trips people up during debugging because they assume the network itself is broken.

Your home partition fills up. Rootless storage lives under ~/.local/share/containers, not under /var/lib where the root daemon kept everything. Images, layers, and volumes all accumulate there, and on a box where /home is a modest partition, a few large images will fill it quietly. I keep an eye on it with podman system df and run podman system prune on a schedule.

Rootless refuses to start at all. If Podman complains about being unable to set up the user namespace, check that your user has entries in /etc/subuid and /etc/subgid. A user created before the subordinate-ID tooling was configured, or added by a script that skipped it, will have no range allocated. Running usermod --add-subuids 100000-165535 --add-subgids 100000-165535 smarc and then podman system migrate sorts it out.

The honest verdict

Podman is not free of friction. Rootless networking has more moving parts than the root daemon’s tidy bridge, the low-port restriction will annoy you at least once, and the UID-shifting on volumes is a genuine conceptual hurdle the first few times you meet it. If you run a single-purpose server where a root daemon is the entire threat model already, and you have no plans to change that, Docker will keep working fine and I would not evangelise at you.

For my homelab the trade came out firmly in Podman’s favour. I removed a root daemon and a root-equivalent group from my attack surface, I gave every container its own failure domain, and I got systemd management through Quadlet that is cleaner than anything I hand-rolled under Docker. The auto-update timer keeps my leaf services current, and podman kube play means the work I do now points at a real cluster later. The relearning cost me an afternoon; the peace of mind has lasted a lot longer than that. If the root daemon has ever given you the same uneasy feeling it gave me, spend the afternoon.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.