Contents

Podman: Running Containers Without Docker (and Without Losing Your Mind)

A daemonless, rootless container engine that mostly speaks Docker

Contents

For about a decade, “containers” and “Docker” were synonyms in most people’s heads, mine included. You installed Docker, you ran docker run, a daemon somewhere did the work, and you didn’t think too hard about the fact that the daemon ran as root and you were talking to it through a socket that was, functionally, a key to the whole machine. Podman is what you get when someone looks at that arrangement and asks why it has to be like that. The answer, it turns out, is that it doesn’t.

I switched my personal boxes over a while back and have very few regrets. Most of the time it’s a drop-in. Some of the time it isn’t, and being honest about which times is the useful part of this post.

The thing that finally pushed me was a small realisation about attack surface. On a Docker host, membership of the docker group is effectively root — anyone in it can docker run -v /:/host and read or write the entire filesystem as root, no sudo prompt, no audit trail worth the name. I had that on a box facing the internet through a reverse proxy, and I did not love it. The daemon is a single always-running privileged process that half your tooling talks to over a socket. It works, it’s well-engineered, but it’s a big, tempting target sitting there whether or not you’re using it. Podman’s design deletes that target rather than guarding it, and once I’d internalised that difference I stopped wanting to go back.

No daemon, and why that matters

Advertisement

Docker runs a long-lived root daemon (dockerd); your CLI is just a client poking it. Podman has no daemon. When you run podman run, the container is a direct child of your shell’s process tree, managed by a small monitor called conmon. That sounds like an implementation detail until you realise the consequences: there’s no single privileged process that, if compromised, owns the host. There’s no socket that grants root to anyone in the docker group. And containers integrate cleanly with systemd, because they are just processes.

The CLI is deliberately Docker-compatible. Almost everything you know transfers:

1
2
3
4
5
6
7
podman run -d --name caddy -p 8080:80 docker.io/library/caddy:2
podman ps
podman logs caddy
podman exec -it caddy sh

# If muscle memory insists on "docker", alias it and move on:
alias docker=podman

Rootless by default, and the one gotcha

The headline feature is rootless containers. You run as your own unprivileged user, and Podman uses user namespaces to map your UID to “root” inside the container while you remain a nobody on the host. A container breakout lands the attacker as your unprivileged user, not as host root. For anything self-hosted and internet-adjacent, that’s a meaningful reduction in blast radius.

The gotcha that catches everyone: privileged ports. As a normal user you can’t bind below 1024, so -p 80:80 fails. You either publish to a high port and reverse-proxy, lower the net.ipv4.ip_unprivileged_port_start sysctl, or grant the capability. None of these is hard; all of them surprise you the first time.

1
2
3
# Let rootless containers bind low ports system-wide:
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/podman.conf
sudo sysctl --system

The second thing that trips people up is subuid/subgid ranges. Rootless Podman maps container UIDs onto a block of host UIDs allocated to your user in /etc/subuid and /etc/subgid. If those files are empty — common on a hand-built server rather than a fresh Fedora install — you get a container that runs as a single UID and any image expecting multiple users inside falls over with permission errors. The fix is one command:

1
2
3
# Allocate 65536 sub-UIDs/GIDs to your user, then rebuild the mappings:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$USER"
podman system migrate

podman system migrate is the one to remember; it re-reads the ranges and rebuilds existing containers’ user namespaces. Forget it and you will swear the subuid change did nothing.

Pods, and where the name comes from

Advertisement

Podman can group containers into a pod — the same concept Kubernetes uses, a set of containers sharing a network namespace and localhost. That’s not a coincidence; the kinship with Kubernetes is intentional. You can even export a running pod to a Kubernetes YAML manifest with podman generate kube, which is a genuinely lovely way to prototype on a laptop and then ship the same shape to a real cluster. If you self-host something money-adjacent this way — I run Firefly III for personal finance in exactly this shape — the rootless model is doing real work: a breakout in the web container lands the attacker as an unprivileged user, not as the account that owns your database volumes.

Compose and quadlets

The most common objection is “but my whole stack is a docker-compose.yml.” Fine. podman-compose exists, and there’s a Docker-API-compatible socket you can point the real docker compose at if you must. But the idiomatic Podman answer is quadlets: you describe a container in a small systemd unit file, drop it in the right place, and systemd manages it like any other service — start on boot, restart on failure, journald logging, all for free.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# ~/.config/containers/systemd/caddy.container
[Container]
Image=docker.io/library/caddy:2
PublishPort=8080:80
Volume=%h/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z

[Service]
Restart=always

[Install]
WantedBy=default.target

Run systemctl --user daemon-reload and systemctl --user start caddy, and your container is now a first-class systemd service. No daemon babysitting a restart policy — the init system you already trust does it.

One more thing about user services: by default they die when you log out. If you want your quadlet-managed containers to survive a reboot and run without you being logged in, enable lingering for the account:

1
2
# Keep the user's systemd instance alive across logout/boot:
sudo loginctl enable-linger "$USER"

Skip this and you will deploy a stack, log out, and come back to find everything stopped — a genuinely baffling failure the first time it happens, because nothing errored; systemd just tore down your session. This is exactly the kind of quiet, self-owned reliability I lean on for running a blog on your own infrastructure: no root daemon, no external orchestrator, just the init system doing what it already does for everything else.

Where it bites, and how to fix it

It’s not pure sunshine. Rootless networking historically went through a userspace proxy (slirp4netns) that was slower than the kernel path Docker uses; the newer pasta backend closes most of that gap, but if you’ve got throughput-sensitive workloads, test before you assume parity. A handful of images and tools bake in assumptions about the Docker socket’s exact location or behaviour, and those need a nudge. And rootless storage lives under your home directory using overlay-on-fuse in some configurations, which can be slower than native overlayfs. For most self-hosters, none of this registers. For someone running a CI fleet, it might.

A few concrete failures you will actually hit, and what to do:

  • “short-name did not resolve to an alias”. Podman refuses to guess which registry nginx means, unlike Docker which silently assumes Docker Hub. Either write the fully-qualified name (docker.io/library/nginx) or add Docker Hub to the unqualified-search-registries list in /etc/containers/registries.conf. The explicitness is a feature — it’s how you avoid a typo pulling a malicious lookalike from the wrong registry — but it surprises everyone migrating from Docker.
  • Containers can’t reach each other by name. On the default rootless network, DNS-based service discovery needs the netavark backend with aardvark-dns, which is standard on current versions but absent on older ones. If podman run --network mynet gives you containers that can ping IPs but not names, check podman info | grep networkBackend and upgrade off the legacy CNI stack.
  • The socket some tool wants isn’t there. If a piece of software insists on talking to a Docker socket, expose Podman’s compatible one with systemctl --user enable --now podman.socket and point the tool at unix:///run/user/$UID/podman/podman.sock. It speaks enough of the Docker API to satisfy most clients, including the real docker compose.
  • A privileged workload genuinely needs root. Some things — binding to hardware, certain VPN or firewall containers — really do want root. Podman runs rootful too (sudo podman ...), and rootful quadlets live in /etc/containers/systemd/. Mixing the two is fine; just know which mode a given container is in, because rootless and rootful have entirely separate image stores and you’ll wonder where your image went.

The pattern across all of these: Podman is stricter and more explicit than Docker, and the failures are loud rather than silent. That is annoying on day one and reassuring by month three.

Migrating an existing stack without drama

If you already run things on Docker, don’t do a big-bang cutover. The approach that worked for me was to install Podman alongside Docker, alias docker=podman in an interactive shell, and run new deployments through Podman while leaving the old ones on Docker until I had a reason to touch them. Podman and Docker can coexist on the same host — they use different storage locations and don’t fight — so there’s no forced flag day.

Convert compose files opportunistically. When a service next needed a change anyway, I rewrote its docker-compose.yml into quadlet unit files, one .container per service plus a .network if they needed to talk to each other. It’s more files than a single compose file, but each one is a plain systemd unit you can reason about, and the payoff is that systemctl --user status and journalctl --user -u thing just work, with the same muscle memory you use for every other service on the box. For a stack you’re actively maintaining that’s a genuine upgrade; for a pile of legacy containers you never touch, podman-compose is a perfectly acceptable holding pattern.

The one migration gotcha worth flagging: named volumes. Docker volumes and Podman volumes live in different places, so a straight cutover won’t see your existing data. Either bind-mount a host path (which sidesteps the whole question) or explicitly copy the volume contents across before you point the new container at a fresh named volume. Losing a database because you assumed the volume would follow is an avoidable bad afternoon.

Is it worth it?

For a personal server, a homelab, or anywhere you’d rather not run a root daemon that’s a soft target — yes, unreservedly. The security model is better, the systemd integration is genuinely pleasant, and the CLI is close enough to Docker that the switch costs you an afternoon, not a weekend. If you’re deep into a Docker-Compose-and-Swarm shop with tooling that assumes the daemon, the migration is more involved and you should weigh it. But “containers without Docker” is no longer the awkward proposition it once sounded like. It’s just a quieter, more sensible way to run the same workloads.

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.