Docker vs Podman: A Hands-On Comparison and Migration

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
Info
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:
1pgrep -a dockerd1775 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sockPodman has no daemon. The podman command forks the container itself and exits. Ask for its process the same way and there is nothing running:
1pgrep -a podman1(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:
1podman info --format 'rootless={{.Host.Security.Rootless}} root={{.Store.GraphRoot}}'1rootless=true root=/home/ubuntu/.local/share/containers/storageRootless 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:
1podman unshare cat /proc/self/uid_map1 0 1000 12 1 100000 65536Read 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:
1docker run --rm -v ~/data:/out alpine sh -c 'echo hi > /out/docker.txt'2podman run --rm -v ~/data:/out docker.io/library/alpine sh -c 'echo hi > /out/podman.txt'3ls -l ~/data1-rw-r--r-- 1 root root 3 Sep 11 19:27 docker.txt2-rw-r--r-- 1 ubuntu ubuntu 3 Sep 11 19:27 podman.txtThe 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
docker group, 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:
1podman run -d --name web -p 80:80 docker.io/library/nginx:alpine1Error: rootlessport cannot expose privileged port 80, you can add2'net.ipv4.ip_unprivileged_port_start=80' to /etc/sysctl.conf (currently 1024),3or choose a larger port number (>= 1024): listen tcp 0.0.0.0:80: bind: permission deniedPick a port at or above 1024 and it works fine:
1podman run -d --name web -p 8080:80 docker.io/library/nginx:alpine2curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:80801HTTP 200Docker does not hit this, because its daemon is already root and binds the privileged port for you:
1docker run -d --name web -p 80:80 nginx:alpine2curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:801HTTP 200Warning
net.ipv4.ip_unprivileged_port_start with 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:
1alias docker=podman2docker version --format '{{.Client.Version}}'14.9.3Compose comes along too. Point podman-compose at the exact same file Docker Compose would use:
1services:2 web:3 image: docker.io/library/nginx:alpine4 ports:5 - "8080:80"1podman-compose up -d2podman ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'1NAMES IMAGE PORTS2podman-demo_web_1 docker.io/library/nginx:alpine 0.0.0.0:8080->80/tcpIt 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.io for a bare name like nginx. 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:
1podman generate systemd --new --name web1DEPRECATED command:2It 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):
1[Unit]2Description=Rootless nginx via a Podman Quadlet3 4[Container]5Image=docker.io/library/nginx:alpine6PublishPort=8083:807 8[Install]9WantedBy=default.targetThen reload and start it as a normal user service, no root involved:
1systemctl --user daemon-reload2systemctl --user start nginx.service3systemctl --user is-active nginx.service1active1curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:80831HTTP 200That 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.
Join the discussion on Docker vs Podman: A Hands-On Comparison and Migration
Likes, comments, and replies are available for authenticated readers with verified email addresses.


