SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Podman vs Docker on a VPS: what really differs

Podman runs containers with no daemon and rootless by default. What that changes on a rented server: compose files, quadlets, ports and volume ownership.

What actually differs between Podman and Docker

Podman and Docker run the same OCI (open container initiative) images on a VPS, so the choice is not about which software you can run. The difference is the process model. Docker runs a root daemon that owns every container, and the docker command is a small client that asks that daemon to do the work. Podman has no daemon: podman run starts the container as a child process of whatever called it, under your own unprivileged user.

Everything else follows from that one fact. Auto-start becomes systemd's job instead of the daemon's. Volume ownership passes through a user namespace, so the owner you see with ls -l on the host is not the owner the container sees. Ports below 1024 refuse to bind until you change a kernel setting. The docker CLI (command line interface) keeps working through a wrapper, right up to the point where something wants the Docker socket.

No daemon: what actually runs when you start a container

On a Docker host, pstree -a shows dockerd as root, containerd beside it, and one containerd-shim-runc-v2 per running container. Your application is a child of that shim, and the shim is a child of PID 1. Nothing connects the container to the shell that started it. Stop the daemon and you lose the control plane for every container on the box, and with the default live-restore setting off, systemctl restart docker restarts your containers as well.

Podman has no equivalent process. Start a container and you get one conmon (container monitor) process holding the container's main process, owned by the user who ran the command.

podman run -d --name web -p 8080:80 docker.io/library/caddy:2
ps -o user,pid,ppid,args -C conmon
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080

ps should list conmon running as your login user and not as root, and curl should print 200. Because no central service owns the container, sudo apt upgrade podman does not stop anything that is already running, and one container's monitor crashing cannot take the others with it.

The missing daemon costs you something too. Nothing starts your containers after a reboot. Docker's --restart=always is a promise the daemon keeps at boot, and Podman replaces it with systemd, which is what the quadlet section below is for.

The socket is the other half of the story. /var/run/docker.sock is a root-owned API (application programming interface) endpoint, and any process that can write to it can start a privileged container that mounts the host filesystem. Adding a user to the docker group grants that user root by a slower route, which is worth reading beside giving each service account only the access it needs. Podman exposes no socket unless you ask for one, and the socket you get belongs to a single user at /run/user/<uid>/podman/podman.sock.

Install Podman on Ubuntu 24.04 and confirm rootless is real

sudo apt update
sudo apt install -y podman uidmap
podman --version
podman info | grep -i rootless

The uidmap package provides newuidmap and newgidmap. These are the setuid helpers that let an ordinary user claim a range of subordinate IDs, and without them rootless containers do not start. podman info should print rootless: true.

Ubuntu 24.04 ships Podman 4.9 and Debian 13 ships Podman 5.x, checked in August 2026. The gap matters, because quadlet files need 4.4 or newer and .pod quadlet files need 5.0. Run podman --version before you copy an example out of upstream documentation.

Every rootless user needs a subordinate ID range:

grep "$USER" /etc/subuid /etc/subgid

A user created by adduser on Ubuntu gets a range automatically. A user created by useradd -M or by a configuration tool often does not, and the failure says so:

Error: cannot find UID/GID for user deploy: no subuid ranges found for user "deploy" in /etc/subuid - check rootless mode in man pages.

Assign a range, then reset that user's storage so the new mapping is used:

sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 deploy
podman system migrate

One more first-run surprise: Podman does not assume Docker Hub. A short image name is resolved against unqualified-search-registries in /etc/containers/registries.conf, and in a script with no terminal attached the pull fails with short-name resolution enforced but cannot prompt without a TTY. Write the full name every time. Use docker.io/library/nginx:1.27 rather than nginx.

What rootless containers actually buy you on a rented server

A rootless container runs inside a user namespace, a kernel feature that gives a process its own private map of user IDs. Inside the namespace the container's superuser is UID (user ID) 0. Outside it, on your VPS, that same process is your ordinary login user. Root in the container is not root on the host.

That is the honest size of the win. An image that insists on running as root, a web application with a remote code execution bug, an escape that depends on being UID 0 outside: all of those end up holding your unprivileged user's permissions instead of the machine's. What rootless does not do is protect you from kernel bugs, and it does not protect your own files, because the escaped process is running as you and can read whatever you can read.

Docker can run rootless too. dockerd-rootless-setuptool.sh install sets up a per-user daemon and it works well. The difference is which way the default points. With Podman you get rootless without asking, so your first failure is a container that cannot bind port 80, instead of a service that quietly ran as root for two years.

Why are my volume files owned by UID 100999?

Because of that same user namespace. Container UID 0 maps to your host UID. Container UID 1 maps to the first ID in your subuid range, and it counts up from there. With a range starting at 100000, container UID 1000 lands on the host as 100999.

mkdir -p "$PWD/data"
podman run --rm --user 1000 -v "$PWD/data:/data" docker.io/library/alpine:3 sh -c 'id -u; touch /data/f'
ls -ln "$PWD/data"

The container prints 1000. The host listing prints owner 100999, because 100000 plus 1000 minus 1 is 100999. Nothing is broken, and a plain chown will not fix it, because your unprivileged user cannot change file ownership at all outside the namespace.

Four ways out:

  • podman unshare chown 1000:1000 "$PWD/data" runs the chown inside the same user namespace, where the numbers mean what they mean to the container.
  • -v "$PWD/data:/data:U" asks Podman to correct the ownership of the source directory for you. Use it on a fresh directory, not on data you care about.
  • --userns=keep-id maps your host UID to the same UID inside the container, so new files come out owned by you.
  • A named volume such as -v appdata:/data avoids the question, because Podman creates it inside your own storage with the ownership already correct.

If you have fought this in Docker, it is the same problem one layer up. The PUID and PGID variables that many images expose set the UID the process inside the container uses, and under rootless Podman that UID is then mapped a second time. PUID=1000 inside a rootless container still writes host files owned by 100999. Pick the numbers with that second mapping in mind, or move the data into a named volume and stop thinking about it.

Two more notes on mounts. The :z and :Z flags you see in Fedora and RHEL examples are SELinux relabel options, and Ubuntu uses AppArmor, so they do nothing there. Rootless Podman also cannot mount a host directory that your user cannot read, which is the point rather than a fault.

Why does rootless Podman refuse to publish port 80?

Because binding a port below 1024 needs a privilege your user does not have. The error names the fix:

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

Two answers work. Lower the threshold for the whole host:

echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-podman.conf
sudo sysctl --system
sysctl net.ipv4.ip_unprivileged_port_start

The last command should echo 80 back. Be clear about what that setting does: every user on the machine can now bind 80 and 443, not only the one running containers. On a single-admin VPS that is an acceptable trade. On a box carrying other people's accounts it is not. The other answer is to publish on 8080 and put a reverse proxy in front, which is where you want certificates issued and renewed by certbot on nginx anyway.

Rootless publishing also changes what your application sees. Podman 4.x uses slirp4netns with the rootlesskit port handler by default, and forwarded connections arrive with a rewritten source address, so the access log records every visitor as 10.0.2.100. Podman 5.0 changed the default to pasta, which keeps the real client address. On 4.x, --network slirp4netns:port_handler=slirp4netns restores the true source address at some cost in throughput.

There is one good surprise here. A rootless published port is an ordinary listening socket owned by a normal process, so your firewall's input rules apply to it. Docker publishes ports by writing NAT (network address translation) rules plus its own forwarding accepts, which is exactly why a published Docker port ignores the ufw rule you thought was blocking it. Rootful Podman uses similar plumbing and inherits the same trap. Rootless does not.

Do my Docker Compose files still work under Podman?

Mostly, by two different routes. The first is podman-compose, a separate implementation that reads the same file and drives the Podman CLI:

sudo apt install -y podman-compose
podman-compose up -d
podman ps

The second is real Docker Compose talking to Podman's Docker-compatible API over a per-user socket:

systemctl --user enable --now podman.socket
export DOCKER_HOST="unix:///run/user/$(id -u)/podman/podman.sock"
docker compose up -d
podman ps

docker compose ps and podman ps should list the same containers, because there is only one set of them. Name resolution works as well: Podman's default network backend, netavark, runs aardvark-dns, so containers on a user-defined network find each other by name.

The edges are real. Anything that mounts /var/run/docker.sock has to be pointed at the Podman socket or dropped. network_mode: host behaves differently under a user namespace. depends_on with condition: service_healthy is supported unevenly across podman-compose versions. restart: always does not survive a reboot on its own, which the next section fixes. Compose remains a good way to describe a multi-container stack in a single file, and under Podman it is a translation layer. For a stack you intend to keep for years, convert it to quadlets and maintain one abstraction instead of two.

Pods: the idea Docker has no answer for

A pod is a group of containers that share one network namespace. Podman starts a small infra container to hold that namespace open, and the members then reach each other on 127.0.0.1 with no user-defined network and no service discovery involved.

podman pod create --name app -p 8080:80
podman run -d --pod app --name app-cache docker.io/library/redis:7
podman run -d --pod app --name app-web docker.io/library/nginx:1.27
podman pod ps
podman ps --pod

podman pod ps should show the pod Running with three containers, counting the infra container. The web container now reaches Redis at 127.0.0.1:6379 rather than at app-cache:6379. Two rules follow from the shared namespace: publish ports on the pod and never on a member, and no two members may listen on the same port.

This is the Kubernetes model, and Podman leans into it. podman kube generate app > app.yaml writes a Kubernetes manifest from what is running (older packages spell it podman generate kube), and podman kube play app.yaml recreates it on another host. Quadlet has a .kube unit type that runs such a file as a systemd service. It is a genuinely different way to group services, and it is the strongest reason to choose Podman if Kubernetes is anywhere in your future.

Auto-start without a daemon: quadlet units

Quadlet is a systemd generator. It turns a short file describing a container into a real systemd service at boot. Files go in ~/.config/containers/systemd/ for a rootless user, or /etc/containers/systemd/ for root.

~/.config/containers/systemd/caddy.container:

[Unit]
Description=Caddy web server
After=network-online.target

[Container]
Image=docker.io/library/caddy:2
ContainerName=caddy
PublishPort=8080:80
Volume=caddy-data.volume:/data
Environment=TZ=UTC
AutoUpdate=registry

[Service]
Restart=always
MemoryMax=512M

[Install]
WantedBy=default.target

~/.config/containers/systemd/caddy-data.volume can be almost empty, since the section header is what creates the volume:

[Volume]
systemctl --user daemon-reload
systemctl --user start caddy
systemctl --user status caddy
journalctl --user -u caddy -n 50

The service name comes from the filename: caddy.container becomes caddy.service. Do not run systemctl --user enable caddy. Generated units cannot be enabled, and systemd answers Failed to enable unit: Unit /run/user/1000/systemd/generator/caddy.service is transient or generated. The [Install] section is what starts the container at boot, and daemon-reload is what regenerates the unit after you edit the file.

Now the setting that catches almost everyone:

sudo loginctl enable-linger deploy
loginctl show-user deploy --property=Linger

Expect Linger=yes. Without linger, systemd tears down the whole user session when your last SSH connection closes, so every rootless container stops with it and none of them come back at boot. Containers that vanish when you log out are always this.

Because the container is the main process of an ordinary service unit, systemd's own controls apply directly. MemoryMax= and CPUQuota= in the [Service] section behave exactly as they do for any other service you cap with systemd. This needs cgroup v2 (control group version 2), which Ubuntu has used by default since 22.04. Confirm with podman info | grep -i cgroup.

Updates have a matching mechanism. AutoUpdate=registry plus systemctl --user enable --now podman-auto-update.timer checks the registry for a newer image on the same tag, restarts the unit, and rolls back to the previous image if the new container fails to start. Run podman auto-update --dry-run first to see what it would change. The older podman generate systemd command still exists and is deprecated, so write quadlets for anything new.

Where the docker alias holds, and where it does not

sudo apt install -y podman-docker
sudo touch /etc/containers/nodocker
docker ps

podman-docker installs a /usr/bin/docker wrapper that calls Podman. Without the nodocker file, every call first prints Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg. The wrapper covers the commands you type all day: run, ps, logs, exec, build, pull, push, inspect, cp, volume, network.

What does not carry over is a shorter list, and a sharper one. Swarm mode has no equivalent, so a Swarm stack has nowhere to land. Tools that speak to the Docker socket need the Podman socket exported, and some still notice the difference; Traefik's Docker provider works when pointed at /run/user/<uid>/podman/podman.sock, while Watchtower has no place at all, because podman auto-update does that job. Storage is separate, so Podman cannot see images you already pulled with Docker, and podman images on a busy Docker host starts out empty.

Migrating a running stack, step by step

  1. Create or choose the unprivileged user that will own the containers, and confirm it has a range in /etc/subuid.
  2. Re-pull anything that came from a registry, using fully qualified names. Podman has its own image store and will not read Docker's.
  3. Move locally built images across with docker save app:1.4 | podman load.
  4. Stop the Docker container, copy each volume's contents out of /var/lib/docker/volumes/<name>/_data, then fix ownership with podman unshare chown -R 1000:1000 <path>.
  5. Settle the port question: publish above 1024 behind a reverse proxy, or set net.ipv4.ip_unprivileged_port_start.
  6. Write one quadlet file per container, run systemctl --user daemon-reload, and start each service.
  7. Run sudo loginctl enable-linger <user>, reboot the VPS, log back in and check podman ps lists every service again.

The two engines share nothing: separate image storage and separate networks. So you can run both while you migrate, and the only thing they can fight over is a host port number. Move one service, watch it for a day, then move the next.

Podman vs Docker: which one belongs on your VPS?

Stay on Docker if your stack lives in compose files that other people also maintain, or if you depend on tooling that talks to the Docker socket. Compatibility with what everyone else writes is a real feature, and Docker has more of it. A team whose laptops all run Docker also gains something concrete from running the same engine in production.

Switch to Podman if the VPS runs a handful of services you control end to end, or if you want each application under its own unprivileged user with no docker group on the box at all. Distribution alignment counts too: RHEL and its rebuilds ship Podman as the supported engine, so on those systems Podman is the path with fewer surprises. If you already supervise everything else with systemd units, quadlets will feel like a missing piece arriving rather than a new tool to learn.

One middle option is worth naming. Rootful Podman behaves much like Docker, keeps the docker command through the wrapper, and still removes the always-running daemon. It also gives up the rootless half, which is the part that changes your security position, so treat it as a stop on the way.

If you are still building your first container host, the Docker setup and hardening path on a fresh VPS is the shorter road, and none of that knowledge goes to waste. Images and volumes are the same objects under both engines, so a move later changes how your services are supervised and very little else.

FAQ

Is Podman a drop-in replacement for Docker?

For the commands you type, close to it. Installing podman-docker gives you a /usr/bin/docker wrapper, and run, ps, build, logs and exec behave the same way. It is not a replacement for the daemon. Swarm has no equivalent, tools that connect to /var/run/docker.sock must be pointed at the per-user Podman socket instead, and images pulled by Docker stay invisible to Podman because the two keep separate storage.

Why do my rootless Podman containers stop when I log out of SSH?

Because systemd stops the user session, and every user service with it, when your last login closes. Run sudo loginctl enable-linger <user>, then check that loginctl show-user <user> --property=Linger prints Linger=yes. Linger keeps that user's systemd instance running with no active session, which is also what makes the containers start again after a reboot.

Why are files in my volume owned by UID 100999?

Rootless Podman maps container UID 0 to your host user, then maps container UID 1 and upwards onto your subuid range. With a range starting at 100000, container UID 1000 becomes 100999 on the host. Correct it from inside the namespace with podman unshare chown 1000:1000 /path/to/data, mount with the :U flag on the first run, or use --userns=keep-id so container UIDs match your own.

Can I keep using docker-compose.yml with Podman?

Yes, in two ways. podman-compose reads the file and drives the Podman CLI directly. Or enable the compatibility socket with systemctl --user enable --now podman.socket, set DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock, and run real docker compose against it. Expect friction on network_mode: host, on services that mount the Docker socket, and on restart: always, which needs a quadlet unit and linger to survive a reboot.

Does rootless really make containers more secure?

It removes one specific risk: a process that breaks out of a rootless container holds your unprivileged user's permissions rather than root's. That is worth having, and it is why the root-equivalent docker group has no counterpart under rootless Podman. It does not stop kernel vulnerabilities, and it does not protect files your own user can read, so keep the rest of the hardening you would do on any server.

#podman#docker#rootless#containers#systemd