Podman is the container engine most often mentioned as "the Docker alternative", and the pitch is real: it runs the same images, speaks almost the same command line, and reads the same Compose files, but with no background daemon and with rootless mode as the default. The question is what that actually changes in practice, and how much friction you hit if you switch.
So this is not a spec-sheet comparison. It runs Docker and rootless Podman side by side on one real Linux box and records only the differences that actually show up in the output, then covers what migrating a project really takes.
Tip
Get the code: the shared Compose file, a rootless-Podman setup script, and the Quadlet unit are in the 09-docker-vs-podman folder of the companion repo.
Info
Everything below was run on one Ubuntu 24.04 (x86_64) machine with Docker Engine 29.1.3 and Podman 4.9.3. The output is the genuine result, trimmed only for width.
One daemon, or none
This is the architectural split everything else follows from. Docker is a client that talks to a long-running background service that runs as root. It is always there:
pgrep -a dockerd
775 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
Podman has no daemon. The podman command forks the container itself and exits. Ask for its process the same way and there is nothing running:
pgrep -a podman
(no output: nothing is running)
That single difference is why Podman can be rootless so naturally, and why there is no daemon to crash, to run as root, or to be a single point of failure for every container on the host.
Rootless by default (the real reason to care)
Ask each engine how it is running. Podman is rootless out of the box; Docker, in its default setup, is not:
podman info --format 'rootless={{.Host.Security.Rootless}} root={{.Store.GraphRoot}}'
rootless=true root=/home/ubuntu/.local/share/containers/storage
Rootless works through user namespaces: your single host UID is mapped to root inside the container, and a range of subordinate UIDs covers everything else. You can see the map:
podman unshare cat /proc/self/uid_map
0 1000 1
1 100000 65536
Read the first line as "UID 0 inside the container is really UID 1000 (me) on the host". The rest come from the range Ubuntu reserves for you in /etc/subuid. Here is why that matters. Have each engine write a file to a bind-mounted host directory, then look at who owns the result:
docker run --rm -v ~/data:/out alpine sh -c 'echo hi > /out/docker.txt'
podman run --rm -v ~/data:/out docker.io/library/alpine sh -c 'echo hi > /out/podman.txt'
ls -l ~/data
-rw-r--r-- 1 root root 3 Sep 11 19:27 docker.txt
-rw-r--r-- 1 ubuntu ubuntu 3 Sep 11 19:27 podman.txt
The Docker container ran as real root, so it left a root-owned file on your host that you now need sudo to delete. The Podman container thought it was root, but on the host it was just you.
Success
This is the headline win. Under rootless Podman, a process that breaks out of a container lands as your unprivileged user, not as host root. Being in the
dockergroup, by contrast, is effectively root on the machine. For anything exposed or multi-tenant, that is a real security upgrade.
The first gotcha: privileged ports
Rootless is not free. The most common wall you hit is binding a port below 1024, which the kernel reserves for root. Try to publish port 80 rootless and Podman stops you, with a genuinely helpful error:
podman run -d --name web -p 80:80 docker.io/library/nginx:alpine
Error: rootlessport cannot expose privileged port 80, you can add
'net.ipv4.ip_unprivileged_port_start=80' to /etc/sysctl.conf (currently 1024),
or choose a larger port number (>= 1024): listen tcp 0.0.0.0:80: bind: permission denied
Pick a port at or above 1024 and it works fine:
podman run -d --name web -p 8080:80 docker.io/library/nginx:alpine
curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:8080
HTTP 200
Docker does not hit this, because its daemon is already root and binds the privileged port for you:
docker run -d --name web -p 80:80 nginx:alpine
curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:80
HTTP 200
Warning
In production this is a non-issue: you put a reverse proxy or the platform's load balancer on 80 and 443 and point it at your container's high port. But it surprises people on their first rootless run. If you truly need a low port bound directly, lower
net.ipv4.ip_unprivileged_port_startwith sysctl, as the error suggests.
Mostly a drop-in
The day-to-day commands are the same. Podman implements the Docker verbs, so a lot of muscle memory just transfers, and you can make it literal with an alias:
alias docker=podman
docker version --format '{{.Client.Version}}'
4.9.3
Compose comes along too. Point podman-compose at the exact same file Docker Compose would use:
services:
web:
image: docker.io/library/nginx:alpine
ports:
- "8080:80"
podman-compose up -d
podman ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES IMAGE PORTS
podman-demo_web_1 docker.io/library/nginx:alpine 0.0.0.0:8080->80/tcp
It brought the service up and created a podman-demo_default network, named exactly as Compose would. One real adjustment worth making: fully qualify image names.
Info
Docker silently assumes
docker.iofor a bare name likenginx. Podman's resolution depends on the distro's registry config, so the portable habit is to write the full name,docker.io/library/nginx:alpine. Do that and your commands and Compose files behave the same everywhere.
systemd done right: Quadlet
This is where Podman is genuinely nicer than Docker. If you want a container managed by systemd, the old podman generate systemd approach now tells you itself that it is on the way out:
podman generate systemd --new --name web
DEPRECATED command:
It is recommended to use Quadlets for running containers and pods under systemd.
The modern way is a Quadlet: a small declarative file that systemd turns into a real service. Save this as nginx.container in ~/.config/containers/systemd/ (the service name is derived from the file name, so this file becomes nginx.service):
[Unit]
Description=Rootless nginx via a Podman Quadlet
[Container]
Image=docker.io/library/nginx:alpine
PublishPort=8083:80
[Install]
WantedBy=default.target
Then reload and start it as a normal user service, no root involved:
systemctl --user daemon-reload
systemctl --user start nginx.service
systemctl --user is-active nginx.service
active
curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:8083
HTTP 200
That is a rootless nginx, supervised by systemd, with no daemon and no Dockerfile-to-unit glue script. To make it start at boot rather than only during your login session, enable lingering for your user once with sudo loginctl enable-linger $USER. Docker leaves you to wire all of this up yourself.
Side by side
Only the differences that actually showed up in the run above:
| Docker | Podman | |
|---|---|---|
| Architecture | Client plus a root daemon (dockerd) |
Daemonless; the CLI runs the container directly |
| Default user model | Root daemon (rootless is opt-in) | Rootless by default |
| File a container writes to a bind mount | Owned by root
|
Owned by you |
| Bind a privileged port rootless | Works (daemon is root) | Blocked below 1024 unless you tune sysctl |
| Command line | docker ... |
Same verbs; alias docker=podman
|
| Compose | Docker Compose |
podman-compose on the same file |
| systemd | Bring your own units | First-class Quadlet files |
Should you switch?
Be honest about your setup rather than following a trend:
- Lean toward Podman if you run containers on a Linux server and care about not handing out root, if you want systemd-native services, or if a daemonless model appeals. On a VPS, rootless plus Quadlet is a genuinely strong story.
- Stay on Docker if your toolchain assumes the Docker daemon or its socket. Some CI runners, IDE integrations, and libraries (for example the Testcontainers family) talk to the Docker socket directly, and while Podman can expose a compatible socket, that is extra setup to verify.
Migrating a typical project is usually a short list: fully qualify your image names, swap docker compose for podman-compose (or run both), move any privileged ports to high ports behind a proxy, and replace hand-written systemd wiring with Quadlet units. The images themselves do not change at all.
New to this stack? Start with running your first containers, then the Compose guide, then the Docker security basics post, which is where the rootless argument above really pays off.
Top comments (0)