Docker works. That's the problem. It works so well that nobody looks at the architecture underneath: a single daemon, running as root, that every container on the box depends on. When the daemon hiccups, everything goes down together. When a container breaks out, it breaks out into root.
Podman flips that around. No daemon at all — containers run as ordinary processes under your user account, rootless by default, and systemd manages their lifecycle like any other service. A breakout from a rootless container lands in an unprivileged user namespace, which is a much less exciting place to be an attacker.
The migration itself is less scary than it sounds. I've done it on VPSes running real workloads, and the pattern is always the same: inventory, parallel install, move data, cut over one service at a time, verify, then retire Docker. Here's the whole thing.
Prerequisites
- A server or workstation currently running Docker (commands below cover Debian/Ubuntu and Fedora/RHEL)
- SSH access with sudo
- About 30 minutes, plus one maintenance window if the services are public
- Your compose files and a list of every running container — you'll make that list in Step 0
Step 0: Inventory what you actually run
Every failed migration I've seen died the same way: "oh, I forgot about that container." Don't trust your memory; ask the daemon.
docker ps --format '{{.Names}} {{.Image}} {{.Ports}}' > ~/migration/inventory.txt
docker volume ls >> ~/migration/inventory.txtThen find every compose file on the box:
find / -name 'docker-compose*.yml' -o -name 'compose*.yaml' 2>/dev/null | grep -v procRead that inventory file. That is your definition of done — every line must have a Podman twin before you touch the Docker install.
Step 1: Install Podman alongside Docker
# Debian / Ubuntu
sudo apt install podman podman-compose
# Fedora / RHEL
sudo dnf install podman podman-composeNote what's missing: podman-docker, the package that replaces the docker CLI with a Podman shim. Install it later, at the very end — it conflicts with Docker's own CLI package, and you want both CLIs alive during the parallel phase so you can compare docker ps against podman ps in real time.
Step 2: Enable linger, or your containers die at logout
This is the number-one "Podman killed my containers" support question, and it isn't a bug — it's a feature you haven't enabled. Rootless containers are your user's processes, and systemd cleans up user processes at logout unless you tell it not to:
sudo loginctl enable-linger $USER
loginctl show-user $USER | grep LingerConfirm it prints Linger=yes. Without this, every container you start over SSH vanishes the moment you disconnect. Do this before anything else.
Step 3: Move your images
The easy part. Podman speaks the same registry protocol Docker does, so the fastest path is usually a fresh pull:
podman pull nginx:latestFor images you built locally or can't re-pull:
docker save myapp:latest | podman loadThen verify with podman images. If an image ran under Docker, it runs under Podman — the image format is the standard both of them share.
Step 4: Move your volumes — the fiddly one
This is where migrations actually live or die. Docker keeps named volumes in root-owned storage under /var/lib/docker; rootless Podman keeps them under ~/.local/share/containers/storage. The safe bridge between the two is a tar pipeline that doesn't care which runtime owns the data:
mkdir -p /tmp/mig
# export each volume from Docker
docker run --rm -v mydata:/data -v /tmp/mig:/backup \
busybox tar czf /backup/mydata.tar.gz -C /data .
# recreate it in Podman and import
podman volume create mydata
podman run --rm -v mydata:/data -v /tmp/mig:/backup \
busybox tar xzf /backup/mydata.tar.gz -C /dataRepeat for every volume in your inventory. Then spot-check the contents — don't skip this:
podman run --rm -v mydata:/data busybox ls -la /data | headIf you run databases, do this during your maintenance window with the app stopped, so the tarball captures a consistent state. Databases mid-write are how you learn about corruption at 3 AM.
Step 5: Translate your compose files
Most compose files work with podman-compose unchanged. Three gotchas account for nearly every failure:
1. Low ports. Rootless Podman can't bind ports below 1024 by default — your port-80 web server will refuse to start with a permission error. Fix it once, permanently:
echo "net.ipv4.ip_unprivileged_port_start=80" | \
sudo tee /etc/sysctl.d/99-rootless-ports.conf
sudo sysctl --system2. Volume names must match character-for-character. If your compose file references mydata but you created my-data in Step 4, Podman happily creates a brand-new empty volume and your app boots with no data. Diff against your inventory.
3. Tools expecting the Docker socket. Some tooling hardcodes /var/run/docker.sock. Podman ships its own socket; enable it and point the tool at it:
systemctl --user enable --now podman.socket
export DOCKER_HOST=unix:///run/user/$UID/podman/podman.sock(On Fedora/RHEL, one more: volume mounts may need the :Z suffix so SELinux relabels them — - ./mydata:/data:Z. Debian/Ubuntu users can ignore this.)
Step 6: Cut over one service at a time
Do not uninstall Docker first. The safe cutover is boring and deliberate — for each service:
docker stop myservice— take the Docker twin offlinepodman-compose -f docker-compose.yml up -d— start the Podman twin- Hit it:
curl localhost:8080, check the app's logs, log in and look at real data - Only then:
docker rm myservice
One service at a time means any failure is isolated, diagnosable, and instantly reversible — the old container is still right there until you remove it.
Step 7: Verify like you don't trust yourself
Work through this checklist before you declare victory:
podman psmatches every line of your Step 0 inventory — names, images, ports- Data is intact: open the app, read files, count database rows
- Reboot the box. This is the test everyone skips. With linger enabled and restart policies set (
restart: unless-stoppedin compose), everything should come back on its own. SSH back in and runpodman ps— if something's missing, better to find out now than during a real outage - Only after surviving one full week:
sudo systemctl disable --now docker
Keep the Docker packages installed until that week is over. Disk space is cheap; rollbacks are priceless.
Step 8 (optional): Go Quadlet-native and finish the job
Compose works, but Podman's native format is Quadlet — systemd unit files with a .container extension that turn your containers into first-class systemd citizens. Drop this in ~/.config/containers/systemd/myapp.container:
[Unit]
Description=My app
[Container]
Image=docker.io/library/nginx:latest
PublishPort=8080:80
[Service]
Restart=always
[Install]
WantedBy=default.targetsystemctl --user daemon-reload
systemctl --user enable --now myappNow your container gets journalctl --user -u myapp, dependency ordering, and the whole systemd toolbox — the upgrade Docker's architecture can never give you. And once your muscle memory has adjusted, install the CLI shim you skipped in Step 1:
# Debian/Ubuntu: sudo apt install podman-docker
# Fedora/RHEL: sudo dnf install podman-dockerType docker ps and watch it answer from Podman. Full circle.
When Podman is not the answer
Honesty section. Stay on Docker if you run Docker Swarm, depend on Docker Desktop's GUI workflows, use exotic compose extensions Podman doesn't implement, or share one root daemon across a CI fleet where the security model is someone else's problem. Podman is the better default for a VPS you administer yourself; it is not a religion.
Sources
- Podman documentation — https://docs.podman.io/
- Podman migration guides — https://docs.podman.io/
- podman-compose — https://github.com/containers/podman-compose


