What Actually Trips You Up in the Migration
In one line
Moving from docker to podman is not a swap of commands but a swap of the operating model. From a daemon to fork-exec, from rootful to rootless, and from daemon.json to the containers.conf family.
Why this was needed
With alias docker=podman, most commands work as they are. That makes the switch look easy. But when you actually migrate, you are bound to get caught on a few things. If you know those few in advance it takes 30 minutes; if you do not, half a day.
How it works
Differences in structure
Docker: docker CLI ──REST──▶ dockerd ──▶ containerd ──▶ runc ──▶ 컨테이너
Podman: podman CLI ──fork/exec──▶ conmon ──▶ crun ──▶ 컨테이너
podman has no daemon. podman run creates the container directly, like an ordinary process. A small watcher process, conmon, is attached to each container and manages stdio and the exit code, and the actual creation is done by the OCI runtime crun (written in C, lighter than runc).
The implications of this structure are large.
- There is no single point of failure. When dockerd dies or is upgraded, all container management stops, but podman has no central process to begin with.
- There is no attack surface in the form of a root daemon socket.
- It combines naturally with systemd. A container is an ordinary child process, so systemd units can manage it directly.
The five things you are bound to hit
1. restart: always behaves differently. With no daemon, it does not mean "start automatically at boot." The podman way is Quadlet.
# ~/.config/containers/systemd/web.container
[Unit]
Description=Web service
[Container]
Image=docker.io/library/nginx:1.27
PublishPort=8080:80
Volume=/srv/www:/usr/share/nginx/html:Z
[Service]
Restart=always
[Install]
WantedBy=default.target
If you place a .container file, systemd reads it and generates a service unit. podman generate systemd has become outdated.
2. SELinux volume labels. On RHEL/Fedora, if a volume mount gives Permission denied, nine times out of ten it is SELinux. Attach the label -v ./data:/data:Z (private) or :z (shared). A compose file brought over from Ubuntu is where this trap hits most often.
3. Unqualified image names. podman pull nginx behaves differently from docker. Put unqualified-search-registries = ["docker.io"] in registries.conf, or, in CI, use the full path.
4. Storage location. Rootless images live in ~/.local/share/containers/storage. If you dig through /var/lib with the habits of docker system df, you will not find them.
5. Ports below 1024. Rootless cannot bind to privileged ports.
What about compose?
There are two ways.
- Method 1 (recommended) — Turn on the Docker API-compatible socket that podman provides and use standard
docker composeas it is. You do not need to edit compose.yaml, and compatibility is the highest.systemctl --user enable --now podman.socket export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock docker compose up -d - Method 2 —
podman-compose(a Python reimplementation). It calls podman commands directly without a socket, but there are subtle differences in the corners of the Compose spec.
If you are aiming at production, podman kube play (which runs Kubernetes YAML directly) is also an option instead of compose. It lets you unify the manifests for local and cluster.
What it looks like in the field
A gradual switch is possible. The two engines have separate storage, so they can coexist on one machine. You can move one service at a time.
The podman-docker package. It is a shim package that connects the docker command to podman. It is useful for script compatibility, but you must announce to teammates that "docker on this server is actually podman." Otherwise, when a command taken from the docker documentation behaves subtly differently, nobody can find the cause.
What you will do in the next lab
You load an image and run a container, check volumes and user mapping, reproduce the privileged port failure, and write a Quadlet .container file. At the end, you summarize the differences from docker in a table, with evidence.