DEV Community

Cover image for Docker vs Podman: A Hands-On Comparison and Migration
Shubham Sharma
Shubham Sharma

Posted on Originally published at techdevmantra.com

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

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
Enter fullscreen mode Exit fullscreen mode
775 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
(no output: nothing is running)
Enter fullscreen mode Exit fullscreen mode

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}}'
Enter fullscreen mode Exit fullscreen mode
rootless=true  root=/home/ubuntu/.local/share/containers/storage
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
         0       1000          1
         1     100000      65536
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
-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
Enter fullscreen mode Exit fullscreen mode

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 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:

podman run -d --name web -p 80:80 docker.io/library/nginx:alpine
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
HTTP 200
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
HTTP 200
Enter fullscreen mode Exit fullscreen mode

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_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:

alias docker=podman
docker version --format '{{.Client.Version}}'
Enter fullscreen mode Exit fullscreen mode
4.9.3
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
podman-compose up -d
podman ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
Enter fullscreen mode Exit fullscreen mode
NAMES              IMAGE                           PORTS
podman-demo_web_1  docker.io/library/nginx:alpine  0.0.0.0:8080->80/tcp
Enter fullscreen mode Exit fullscreen mode

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.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:

podman generate systemd --new --name web
Enter fullscreen mode Exit fullscreen mode
DEPRECATED command:
It is recommended to use Quadlets for running containers and pods under systemd.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
active
Enter fullscreen mode Exit fullscreen mode
curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:8083
Enter fullscreen mode Exit fullscreen mode
HTTP 200
Enter fullscreen mode Exit fullscreen mode

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)