← Back to GuidesGUIDESelf-Hosting & Homelab

Docker vs Podman: A Hands-On Comparison and Migration

about 20 hours ago
❤️ 0 likes
💬 0 comments
dockerself-hostingcontainerslinuxpodman
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:

Bash
1pgrep -a dockerd
Plain Text
1775 /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:

Bash
1pgrep -a podman
Plain Text
1(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:

Bash
1podman info --format 'rootless={{.Host.Security.Rootless}}  root={{.Store.GraphRoot}}'
Plain Text
1rootless=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:

Bash
1podman unshare cat /proc/self/uid_map
Plain Text
1         0       1000          12         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:

Bash
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 ~/data
Plain Text
1-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.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 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:

Bash
1podman run -d --name web -p 80:80 docker.io/library/nginx:alpine
Plain Text
1Error: 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 denied

Pick a port at or above 1024 and it works fine:

Bash
1podman run -d --name web -p 8080:80 docker.io/library/nginx:alpine2curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:8080
Plain Text
1HTTP 200

Docker does not hit this, because its daemon is already root and binds the privileged port for you:

Bash
1docker run -d --name web -p 80:80 nginx:alpine2curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:80
Plain Text
1HTTP 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_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:

Bash
1alias docker=podman2docker version --format '{{.Client.Version}}'
Plain Text
14.9.3

Compose comes along too. Point podman-compose at the exact same file Docker Compose would use:

YAML
1services:2  web:3    image: docker.io/library/nginx:alpine4    ports:5      - "8080:80"
Bash
1podman-compose up -d2podman ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
Plain Text
1NAMES              IMAGE                           PORTS2podman-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.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:

Bash
1podman generate systemd --new --name web
Plain Text
1DEPRECATED 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):

INI
1[Unit]2Description=Rootless nginx via a Podman Quadlet3 4[Container]5Image=docker.io/library/nginx:alpine6PublishPort=8083:807 8[Install]9WantedBy=default.target

Then reload and start it as a normal user service, no root involved:

Bash
1systemctl --user daemon-reload2systemctl --user start nginx.service3systemctl --user is-active nginx.service
Plain Text
1active
Bash
1curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:8083
Plain Text
1HTTP 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:

DockerPodman
ArchitectureClient plus a root daemon (dockerd)Daemonless; the CLI runs the container directly
Default user modelRoot daemon (rootless is opt-in)Rootless by default
File a container writes to a bind mountOwned by rootOwned by you
Bind a privileged port rootlessWorks (daemon is root)Blocked below 1024 unless you tune sysctl
Command linedocker ...Same verbs; alias docker=podman
ComposeDocker Composepodman-compose on the same file
systemdBring your own unitsFirst-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.

Comments (0)

Loading discussion...

Related guides