LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Podman vs Docker: Rootless Containers, Pods, and the Case for Switching

podmandockercontainerssecurityrootlessdevopslinuxhomelab
Contents

Docker democratized containers. It gave developers a simple, consistent way to package and run software that has fundamentally changed how we build and deploy applications. If you’ve been running containers for any length of time, Docker is almost certainly where you started.

But Docker carries architectural baggage that matters more the longer you operate in production: a persistent root daemon, a socket that is effectively a root shell, rootless mode bolted on as an afterthought, and no native concept of container grouping. These aren’t dealbreakers for many use cases, but they’re real constraints — particularly if you care about security, systemd integration, or preparing workloads for Kubernetes.

Podman takes a different approach. Developed by Red Hat and now a core part of RHEL, CentOS Stream, and Fedora, it reimagines the container runtime without a daemon, makes rootless containers a first-class default, introduces Kubernetes-compatible pods directly on the command line, and integrates with systemd in ways Docker simply cannot. And it does all of this while remaining compatible enough with Docker that alias docker=podman genuinely works for most workflows.

This guide is for people who know Docker well. We’ll cover the architectural differences in depth, work through rootless containers and pods with real examples, walk through Buildah and Skopeo, and give you a concrete migration path.


Part 1: What is Podman?

Podman (Pod Manager) is an OCI-compliant container engine developed by Red Hat. It is not a standalone tool — it belongs to a family of tools designed to cover the full container lifecycle without requiring a privileged daemon:

  • Podman — run, manage, and inspect containers and pods
  • Buildah — build OCI-compatible container images
  • Skopeo — inspect, copy, and sync images across registries

All three tools speak the same OCI image format as Docker, can pull from and push to the same registries (Docker Hub, GHCR, Quay.io, private registries), and produce images that run on any OCI-compliant runtime including Docker itself.

Installing Podman

On RHEL 8/9, CentOS Stream, and Fedora, Podman is installed by default or available directly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Fedora / RHEL / CentOS Stream
sudo dnf install -y podman podman-compose buildah skopeo

# Debian / Ubuntu (via official repos)
sudo apt-get update
sudo apt-get install -y podman buildah skopeo

# Ubuntu 22.04+ — get a newer version via the Kubic repo
. /etc/os-release
echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/ /" \
  | sudo tee /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list
curl -L "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/Release.key" \
  | sudo apt-key add -
sudo apt-get update && sudo apt-get install -y podman

# macOS (via Homebrew — runs containers in a Linux VM)
brew install podman
podman machine init
podman machine start

The Drop-In Replacement Claim

Podman is designed to be a drop-in CLI replacement for Docker. Most docker commands run unchanged:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# These work identically
podman pull nginx:alpine
podman run -d --name web -p 8080:80 nginx:alpine
podman ps
podman logs web
podman exec -it web sh
podman stop web
podman rm web
podman images
podman rmi nginx:alpine

For a truly seamless transition:

1
2
3
# Add to ~/.bashrc or ~/.zshrc
alias docker=podman
alias docker-compose=podman-compose

This alias works for the majority of Docker workflows. The edge cases where it breaks are well-documented, and we’ll cover the important ones in the migration section.


Part 2: The Core Difference — Daemon vs. Daemonless

Docker’s Architecture

When you install Docker, you’re installing a layered system of processes:

dockerd (Docker daemon — runs as root)
  └── containerd (container lifecycle management)
        └── containerd-shim
              └── runc (OCI runtime — actually runs containers)

The Docker daemon (dockerd) is a persistent root process. Every Docker operation — pulling images, starting containers, reading logs — goes through it via the Docker socket at /var/run/docker.sock.

That socket is the problem. Because the daemon owns it and runs as root, any process that can write to /var/run/docker.sock can instruct Docker to do anything root can do. The canonical example:

1
2
# Any user with access to the Docker socket can escalate to root:
docker run -it --rm -v /:/host alpine chroot /host

That’s a complete host filesystem escape. This is why adding a user to the docker group is functionally equivalent to granting them passwordless sudo. It’s widely understood, frequently ignored, and a real risk in multi-user environments.

Additionally, the daemon is a single point of failure. If dockerd crashes or gets OOM-killed, all containers lose their lifecycle management — they may continue running but you lose the ability to stop, inspect, or restart them through Docker until the daemon recovers.

Podman’s Fork-Exec Model

Podman has no daemon. When you run a container with Podman, the Podman binary forks and execs the container directly:

podman run nginx
  └── conmon (container monitor — tiny, per-container)
        └── runc (OCI runtime)
              └── nginx (your process)

conmon (container monitor) is a small C program that serves as the container’s parent process — it holds open the container’s stdio streams and allows Podman to reconnect to a running container after the initial podman run process exits. It’s per-container, not a shared daemon.

The practical consequences:

  • No persistent root process required. Non-root users run containers as their own processes.
  • No socket required for basic operations. Podman talks directly to the kernel via the OCI runtime.
  • Smaller attack surface. There’s nothing listening that didn’t exist before you ran podman.
  • No single point of failure. A Podman crash doesn’t affect running containers. They’re just processes.
  • Process visibility. Running containers appear in ps aux as child processes of the user who started them.
1
2
3
4
5
# After running: podman run -d nginx:alpine
ps aux | grep -E "conmon|nginx"
# jmoon  12345  ...  conmon --api-version 1 ...
# jmoon  12346  ...  nginx: master process nginx -g daemon off;
# jmoon  12347  ...  nginx: worker process

The trade-off: because there’s no daemon coordinating state across sessions, some features require more setup (like the Podman socket for applications that need the Docker API).


Part 3: Rootless Containers

What Rootless Means

A rootless container is one where both the container manager (Podman) and the containers themselves run as an unprivileged user with no elevated host privileges. “Root inside the container” doesn’t mean root on the host — the container’s UID 0 maps to your unprivileged user ID on the host via Linux user namespaces.

Docker’s Rootless Mode vs. Podman Rootless

Docker does have a rootless mode, but it’s an add-on with friction:

1
2
3
# Docker rootless requires manual setup
dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock

Docker’s rootless mode is not the default, is not always available on managed systems, and has a separate socket path that breaks tools which hardcode /var/run/docker.sock. It’s a valid option but clearly an afterthought.

Podman’s rootless mode is the default. Run podman run as any user and it works — no setup, no export, no separate mode to enable:

1
2
3
4
5
6
7
8
9
# As an unprivileged user, no sudo:
podman run -it --rm alpine echo "I am running rootlessly"
# I am running rootlessly

# Check what UID the container is running as on the host:
podman run -d --name web nginx:alpine
podman top web hpid huser
# HPID  HUSER
# 23456 jmoon       ← host user, not root

User Namespaces and UID Mapping

The mechanism enabling this is Linux user namespaces. When Podman starts a rootless container, it maps a range of subordinate UIDs and GIDs from the host into the container’s namespace.

Two files control the available ranges:

1
2
3
4
5
6
7
# /etc/subuid — subordinate UIDs for each user
cat /etc/subuid
# jmoon:100000:65536

# /etc/subgid — subordinate GIDs
cat /etc/subgid
# jmoon:100000:65536

This means user jmoon has UIDs 100000–165535 available for use inside containers. When a process inside the container runs as UID 0 (root), the kernel maps that to UID 100000 on the host. When it runs as UID 1000, it maps to UID 101000. From the host’s perspective, these are all unprivileged UIDs with no special privileges.

If your user isn’t in /etc/subuid and /etc/subgid, add entries:

1
2
3
4
5
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 jmoon

# Or edit directly:
echo "jmoon:100000:65536" | sudo tee -a /etc/subuid
echo "jmoon:100000:65536" | sudo tee -a /etc/subgid

You can inspect the actual mapping for a running container:

1
2
3
podman unshare cat /proc/self/uid_map
#          0       1000          1
#          1     100000      65535

Rootless Storage

Rootless Podman stores everything in your home directory, not in /var/lib/docker:

~/.local/share/containers/storage/   # images and container layers
~/.config/containers/                 # configuration files
$XDG_RUNTIME_DIR/containers/         # runtime state (sockets, etc.)

This is great for multi-user systems — each user has completely isolated container storage with no interference between users.

Limitations of Rootless Containers

Rootless is not magic. There are real constraints:

Port binding below 1024. Unprivileged users can’t bind to ports under 1024 by default:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# This fails rootlessly:
podman run -p 80:80 nginx

# Workarounds:
# Option 1: Use a high port and put a reverse proxy in front
podman run -p 8080:80 nginx

# Option 2: Lower the unprivileged port limit (system-wide)
echo "net.ipv4.ip_unprivileged_port_start=80" | sudo tee /etc/sysctl.d/99-podman.conf
sudo sysctl --system

# Option 3: CAP_NET_BIND_SERVICE (rootful Podman only)

No macvlan/ipvlan networking. These network modes require root because they interact with network interfaces directly. Rootless networking uses slirp4netns or pasta (user-space networking), which adds a small overhead.

No cgroup v1 resource limits. Rootless containers require cgroup v2 (unified hierarchy) for CPU and memory limits. Most modern Linux systems (kernel 5.10+, systemd 248+) default to cgroup v2, so this is rarely an issue today.

Bind-mounting root-owned host directories. If you need to mount /var/log or similar root-owned paths, you’ll need sudo or need to use a rootful Podman container.

Enabling User Services with Lingering

By default, a user’s systemd session ends when they log out, killing all their processes — including containers. To run rootless Podman containers that survive logout and start at boot:

1
2
3
4
5
6
7
8
9
# Enable lingering for your user
loginctl enable-linger $USER

# Verify
loginctl show-user $USER | grep Linger
# Linger=yes

# Now user services start at boot and persist after logout
systemctl --user enable --now mycontainer.service

Security Implications

The key security property: a container escape in a rootless container does not give the attacker root on the host.

In a rootful Docker setup, if a container escape exploit is triggered, the attacker gets a UID 0 shell on the host. With rootless Podman, they get a shell running as UID 100000 (or wherever the UID mapping starts) — an unprivileged user with no special access.

This isn’t a complete security story on its own — proper seccomp profiles, SELinux/AppArmor labels, and limited capabilities still matter — but it meaningfully reduces the impact of a container escape.


Part 4: CLI Compatibility and Differences

Commands That Work Identically

The vast majority of daily Docker commands work without modification:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
podman pull ubuntu:24.04
podman run -d --name db \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  -p 5432:5432 \
  postgres:16-alpine

podman ps
podman ps -a
podman inspect db
podman logs -f db
podman exec -it db psql -U postgres
podman stop db
podman start db
podman restart db
podman rm db
podman rmi postgres:16-alpine

# Volume management
podman volume create mydata
podman volume ls
podman volume inspect mydata
podman volume rm mydata

# Network management
podman network create mynet
podman network ls
podman network inspect mynet
podman network rm mynet

# System
podman system prune -af
podman system df
podman info

Docker Compose → podman compose

Podman 4.0 introduced a built-in podman compose subcommand (via podman-compose under the hood). Podman 4.4+ includes it as a proper built-in. For older Podman versions, install podman-compose via pip:

1
2
3
4
pip3 install podman-compose
# or
sudo dnf install podman-compose
sudo apt-get install podman-compose

Usage is nearly identical to Docker Compose:

1
2
3
4
5
6
7
8
# Equivalent to docker compose up -d
podman compose up -d

# Equivalent to docker compose down
podman compose down

# Equivalent to docker compose logs -f
podman compose logs -f

Compatibility is good but not perfect. Very complex Compose files using advanced networking or Docker-specific extensions may need adjustment. For most homelab stacks, it works as expected.

The Docker Socket and Podman’s API

Some applications (Traefik, Watchtower, Portainer, Diun) use the Docker socket API to discover and manage containers. Podman provides a compatible socket:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Enable the Podman socket for your user (rootless)
systemctl --user enable --now podman.socket

# The socket location (rootless)
echo $XDG_RUNTIME_DIR/podman/podman.sock
# /run/user/1000/podman/podman.sock

# Tell Docker-aware tools where to find it:
export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock

# Or enable the system-wide socket (rootful)
sudo systemctl enable --now podman.socket
# Socket: /run/podman/podman.sock

With DOCKER_HOST set, tools like Docker Compose and many CI utilities will use the Podman socket transparently.

Behavior Differences Worth Knowing

Default registries. Docker defaults to Docker Hub; Podman searches a list of registries configured in /etc/containers/registries.conf. If you get “short-name resolution” prompts, that’s why:

1
2
3
# /etc/containers/registries.conf
[registries.search]
registries = ['docker.io', 'quay.io', 'registry.fedoraproject.org']

To disable the prompt (just use Docker Hub by default):

1
short-name-mode = "disabled"

--latest flag. podman ps --latest shows the most recently created container. Docker doesn’t have this flag.

podman play kube vs no Docker equivalent. This is unique to Podman.

DOCKER_HOST environment variable. Podman respects this variable, so setting it to the Podman socket makes everything work seamlessly.


Part 5: Pods — The Kubernetes-Inspired Feature

Pods are the feature that most clearly shows where Podman is heading and why it matters for anyone working with or toward Kubernetes.

What a Pod Is

A pod is a group of one or more containers that share:

  • Network namespace — all containers in a pod share the same IP address and can communicate via localhost
  • IPC namespace — shared inter-process communication
  • Optionally PID namespace — shared process table

This is exactly how a Kubernetes pod works. It’s the same concept, running directly on your workstation or server without a Kubernetes cluster.

The pod model is powerful because it enables the sidecar pattern: run a main application container alongside helper containers (logging agents, proxies, init containers, secret-fetchers) that share the same network context.

Pod Lifecycle Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Create a pod with a name
podman pod create --name webapp

# List pods
podman pod ls

# Add containers to the pod
podman run -d --pod webapp --name app nginx:alpine
podman run -d --pod webapp --name logger fluent/fluent-bit:latest

# Containers in the same pod communicate via localhost
# The logger can reach nginx at http://localhost:80

# Inspect the pod
podman pod inspect webapp

# All pod operations affect all containers at once
podman pod stop webapp
podman pod start webapp
podman pod restart webapp
podman pod rm webapp --force  # -f to also remove running containers

# Pod-level stats
podman pod stats webapp

# Show containers in a pod
podman ps --pod

The Infra Container

Every Podman pod automatically creates an infra container (also called a pause container). This small container (based on k8s.gcr.io/pause or the Podman equivalent) holds the network and IPC namespaces open for the lifetime of the pod. Other containers join the pod’s namespaces by attaching to the infra container.

1
2
3
podman pod inspect webapp | grep -A5 InfraContainer
# The infra container is visible in podman ps -a
podman ps -a --pod | grep webapp

This design means the pod’s IP address remains stable even if individual containers are stopped and restarted — a key property that mirrors Kubernetes behavior.

Practical Example: Web App + Sidecar Logger

Let’s build a realistic pod: an nginx web server with a Fluent Bit sidecar that reads nginx’s access log and ships it to a logging backend.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Create the pod, exposing port 8080 on the host
# Port mapping is set at the pod level, not the container level
podman pod create \
  --name webapp \
  --publish 8080:80

# Run the main nginx container in the pod
podman run -d \
  --pod webapp \
  --name app \
  -v nginx-logs:/var/log/nginx \
  nginx:alpine

# Run the Fluent Bit sidecar — it can read the shared log volume
# and reach nginx metrics via localhost
podman run -d \
  --pod webapp \
  --name logger \
  -v nginx-logs:/var/log/nginx:ro \
  -v ./fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf:ro \
  fluent/fluent-bit:latest

# Both containers share localhost — the logger can hit nginx's stub_status
# at http://localhost/nginx_status without any network configuration

A minimal fluent-bit.conf for this setup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[SERVICE]
    Flush        5
    Daemon       Off
    Log_Level    info

[INPUT]
    Name         tail
    Path         /var/log/nginx/access.log
    Tag          nginx.access

[OUTPUT]
    Name         stdout
    Match        *

Generating Kubernetes YAML from a Pod

This is the migration path from Podman to Kubernetes. Once you’ve built and validated a pod locally, Podman can generate the Kubernetes manifest:

1
podman generate kube webapp > webapp-pod.yaml

The output is a valid Kubernetes Pod manifest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Generated by Podman — webapp-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: webapp
spec:
  containers:
  - name: app
    image: nginx:alpine
    ports:
    - containerPort: 80
      hostPort: 8080
      protocol: TCP
    volumeMounts:
    - mountPath: /var/log/nginx
      name: nginx-logs
  - name: logger
    image: fluent/fluent-bit:latest
    volumeMounts:
    - mountPath: /var/log/nginx
      name: nginx-logs
      readOnly: true
  volumes:
  - name: nginx-logs
    emptyDir: {}

Apply it to a Kubernetes cluster (or back to Podman):

1
2
3
4
5
6
7
8
# Apply to Kubernetes
kubectl apply -f webapp-pod.yaml

# Apply back to Podman (great for testing)
podman kube play webapp-pod.yaml

# Tear down what podman kube play created
podman kube down webapp-pod.yaml

podman kube play also supports Deployments and ConfigMaps, making it a useful local Kubernetes emulator.


Part 6: Systemd Integration

Docker’s relationship with systemd is awkward. The recommended way to manage Docker containers as services involves writing systemd unit files that run docker start commands, dealing with race conditions, and bolting on restart logic that Docker itself also handles. It works, but it’s inelegant.

Podman was designed with systemd in mind from the start.

Legacy Method: podman generate systemd

The original approach generates a unit file from a running container:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Run your container first
podman run -d --name myapp --restart on-failure:3 myapp:latest

# Generate a systemd unit file
podman generate systemd --new --name myapp --files

# This creates container-myapp.service in the current directory
# --new means the unit will use podman run (not podman start) on each start
# which ensures a fresh container and works better with auto-updates

# Install it
mkdir -p ~/.config/systemd/user/
mv container-myapp.service ~/.config/systemd/user/

# Enable and start
systemctl --user daemon-reload
systemctl --user enable --now container-myapp.service
systemctl --user status container-myapp.service

This method works but is deprecated in favor of Quadlets in Podman 4.4+.

Quadlets: The Modern Approach

Quadlets (introduced in Podman 4.4, integrated from the quadlet project) define containers as systemd unit files using a declarative INI-style format. They’re simpler to write, easier to read, and generate proper systemd service units automatically.

Drop files into ~/.config/containers/systemd/ (rootless) or /etc/containers/systemd/ (rootful), then reload systemd.

Supported unit types:

  • .container — a single container
  • .pod — a pod
  • .network — a container network
  • .volume — a named volume
  • .image — an image pull unit

Complete Quadlet Example: Web Service

Create ~/.config/containers/systemd/myapp.container:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
[Unit]
Description=My Application Container
After=network-online.target
Wants=network-online.target

[Container]
# Image to run
Image=docker.io/library/nginx:alpine

# Container name (optional — will be auto-named if omitted)
ContainerName=myapp

# Environment variables
Environment=NGINX_HOST=example.com
EnvironmentFile=/etc/myapp/myapp.env

# Port mapping
PublishPort=8080:80

# Volume mounts
Volume=/opt/myapp/html:/usr/share/nginx/html:ro,Z
Volume=myapp-logs:/var/log/nginx

# Labels — including the auto-update label
Label=io.containers.autoupdate=registry

# Health check
HealthCmd=curl -sf http://localhost/ || exit 1
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=10s

# Run as a specific user (rootless uses your UID by default)
# User=1000

# Security options
SecurityLabelDisable=false

[Service]
# systemd service settings
Restart=on-failure
RestartSec=5s
TimeoutStartSec=300

[Install]
WantedBy=default.target

Enable it:

1
2
3
4
5
6
7
8
9
systemctl --user daemon-reload

# Podman generates the service unit automatically from the .container file
# You can view the generated unit:
systemctl --user cat myapp.service

systemctl --user start myapp.service
systemctl --user enable myapp.service
systemctl --user status myapp.service

Quadlet for a Pod

~/.config/containers/systemd/webapp.pod:

1
2
3
4
5
6
7
8
9
[Unit]
Description=Webapp Pod

[Pod]
PodName=webapp
PublishPort=8080:80

[Install]
WantedBy=default.target

~/.config/containers/systemd/webapp-app.container:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[Unit]
Description=Webapp Application Container
After=webapp.service

[Container]
Image=nginx:alpine
ContainerName=webapp-app
Pod=webapp.pod
Volume=webapp-html:/usr/share/nginx/html:ro

[Service]
Restart=on-failure

[Install]
WantedBy=default.target

Quadlet for Volumes and Networks

~/.config/containers/systemd/myapp-data.volume:

1
2
3
[Volume]
VolumeName=myapp-data
Label=app=myapp

~/.config/containers/systemd/myapp-net.network:

1
2
3
4
[Network]
NetworkName=myapp-net
Subnet=10.89.1.0/24
Gateway=10.89.1.1

Reference them in your .container file:

1
2
3
[Container]
Volume=myapp-data.volume:/data
Network=myapp-net.network

The .volume and .network suffixes tell Quadlet to depend on those units automatically.

Auto-Update with podman auto-update

Combined with the io.containers.autoupdate=registry label, Podman can automatically check for new image versions and restart containers with the updated image:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Run a manual check and update
podman auto-update

# Output:
# UNIT                        CONTAINER              IMAGE               POLICY    UPDATED
# container-myapp.service     myapp (abc123de)       nginx:alpine        registry  true

# Set up a systemd timer for automatic daily updates
systemctl --user enable --now podman-auto-update.timer

# Check the timer
systemctl --user status podman-auto-update.timer

The podman-auto-update timer runs daily by default and is a clean alternative to Watchtower for Podman-based setups.


Part 7: Buildah — Building Images Without Docker

Buildah is the image builder in the Podman family. It builds OCI-compliant images and can be used as a direct replacement for docker build.

Dockerfile-Based Builds

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Drop-in replacement for docker build
buildah bud -t myapp:latest .

# With build args and target
buildah bud \
  --build-arg NODE_VERSION=20 \
  --target production \
  -t myapp:1.2.3 \
  -f Dockerfile.prod \
  .

# Push to a registry
buildah push myapp:latest docker://registry.example.com/myapp:latest

buildah bud (build using Dockerfile) accepts the same flags as docker build and works with existing Dockerfiles.

Buildah’s Scripting Approach

Buildah’s unique capability is building images through a shell script rather than a Dockerfile. This gives you the full power of bash for conditional logic, dynamic configuration, and complex build workflows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/bin/bash
set -euo pipefail

# Start from a base image — returns a working container name
CONTAINER=$(buildah from ubuntu:24.04)

# Run commands in the container
buildah run $CONTAINER -- apt-get update
buildah run $CONTAINER -- apt-get install -y --no-install-recommends \
  ca-certificates curl wget

# Copy files in
buildah copy $CONTAINER ./app /app
buildah copy $CONTAINER ./config /etc/myapp

# Set environment variables and metadata
buildah config --env APP_ENV=production $CONTAINER
buildah config --env PORT=8080 $CONTAINER
buildah config --workingdir /app $CONTAINER
buildah config --cmd '["./server"]' $CONTAINER
buildah config --user 1001:1001 $CONTAINER
buildah config --label version=1.2.3 $CONTAINER
buildah config --label maintainer="ops@example.com" $CONTAINER

# Commit to an image
buildah commit $CONTAINER myapp:1.2.3

# Clean up the working container
buildah rm $CONTAINER

# Push
buildah push myapp:1.2.3 docker://registry.example.com/myapp:1.2.3

This scripting approach is particularly useful for conditional builds:

1
2
3
if [[ "$TARGET_ARCH" == "arm64" ]]; then
  buildah run $CONTAINER -- apt-get install -y gcc-aarch64-linux-gnu
fi

Rootless Image Builds

Building images rootlessly works out of the box with both Buildah and podman build:

1
2
3
4
5
6
7
8
# Build as a non-root user — no daemon, no socket required
podman build -t myapp:latest .

# Build a multi-stage image
podman build \
  --target production \
  -t myapp:prod \
  .

Why Buildah Matters in CI

In CI environments, the traditional approach requires either a Docker daemon or Docker-in-Docker (DinD), both of which have security and complexity implications. Buildah builds images rootlessly without a daemon:

1
2
3
4
5
6
7
# GitLab CI example — no Docker daemon needed
build-image:
  image: quay.io/buildah/stable:latest
  script:
    - buildah bud -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - buildah push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  # No privileged: true required for rootless builds

This is cleaner than DinD and works in environments where privileged containers are not allowed.


Part 8: Skopeo — Working With Registries

Skopeo is the specialist tool for registry operations. It works with remote images without ever pulling them locally into container storage.

Inspect Remote Images Without Pulling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Inspect image metadata — no download required
skopeo inspect docker://nginx:alpine
skopeo inspect docker://nginx:alpine | jq '.Architecture, .Os, .Layers | length'

# Inspect all available tags (if registry supports it)
skopeo list-tags docker://nginx

# Inspect from a private registry
skopeo inspect \
  --creds username:password \
  docker://registry.example.com/myapp:latest

Copy Images Between Registries

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Copy from Docker Hub to a private registry (no local storage used)
skopeo copy \
  docker://nginx:alpine \
  docker://registry.example.com/mirror/nginx:alpine

# Copy with authentication
skopeo copy \
  --dest-creds user:pass \
  docker://nginx:alpine \
  docker://registry.example.com/nginx:alpine

# Copy a multi-arch image preserving all architectures
skopeo copy \
  --all \
  docker://nginx:latest \
  docker://registry.example.com/nginx:latest

# Copy from a local OCI directory to a registry
skopeo copy \
  oci:/path/to/local/image \
  docker://registry.example.com/myapp:latest

Sync Entire Registries or Repositories

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Sync all tags of a repository to a local directory
skopeo sync \
  --src docker \
  --dest dir \
  registry.example.com/myapp \
  /mnt/mirror/

# Sync from directory to another registry
skopeo sync \
  --src dir \
  --dest docker \
  /mnt/mirror/ \
  air-gapped-registry.internal/

# Sync using a YAML manifest (precise control)
cat > sync-manifest.yaml << 'EOF'
registry.example.com:
  images:
    nginx: ["alpine", "1.25", "1.24"]
    postgres: ["16-alpine", "15-alpine"]
  images-by-tag-regex:
    myapp: ^v\d+\.\d+\.\d+$
EOF

skopeo sync \
  --src yaml \
  --dest docker \
  sync-manifest.yaml \
  air-gapped-registry.internal/

Delete Images From Registries

1
2
3
4
5
6
7
# Delete a specific tag from a registry
skopeo delete docker://registry.example.com/myapp:old-tag

# With credentials
skopeo delete \
  --creds username:password \
  docker://registry.example.com/myapp:v1.0.0

Practical CI Use Cases

Skopeo is particularly valuable in CI/CD pipelines for:

  1. Air-gapped registry pre-seeding — sync required images to an internal registry before builds start
  2. Registry migration — copy all images from one registry to another without intermediate storage
  3. Image promotion — copy an image from dev-registry to prod-registry when a pipeline stage passes (no re-build)
  4. Manifest inspection — verify image digests and metadata in pipeline gates without pulling
1
2
3
4
5
6
7
# CI pipeline: promote image from staging to production registry
DIGEST=$(skopeo inspect docker://staging-registry/myapp:${CI_COMMIT_SHA} | jq -r '.Digest')

skopeo copy \
  docker://staging-registry/myapp@${DIGEST} \
  docker://prod-registry/myapp:latest \
  docker://prod-registry/myapp:${VERSION}

Part 9: Migrating From Docker to Podman

A production-grade migration typically goes through five stages. Most homelabs can move faster, but this structure works at any scale.

Step 1: Install Podman and Set the Alias

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Install
sudo apt-get install -y podman podman-compose  # Debian/Ubuntu
sudo dnf install -y podman podman-compose      # RHEL/Fedora

# Verify version (aim for 4.x+)
podman --version

# Set alias — add to ~/.bashrc
alias docker=podman
alias docker-compose=podman-compose

# Test it
docker run --rm hello-world

Step 2: Migrate Volumes

Docker stores named volumes in /var/lib/docker/volumes/. Podman rootless stores them in ~/.local/share/containers/storage/volumes/.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# List Docker volumes to migrate
docker volume ls

# For each volume, export and import:
# Export from Docker
docker run --rm \
  -v myvolume:/source:ro \
  -v $(pwd):/backup \
  alpine tar czf /backup/myvolume.tar.gz -C /source .

# Import into Podman
podman volume create myvolume
podman run --rm \
  -v myvolume:/dest \
  -v $(pwd):/backup:ro \
  alpine tar xzf /backup/myvolume.tar.gz -C /dest

For bind mounts, no migration is needed — just point to the same path.

Step 3: Migrate Docker Compose Workflows

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Test your existing docker-compose.yml with Podman
podman compose up -d

# Watch for issues:
# 1. Image short names (add registry prefix: docker.io/library/nginx)
# 2. depends_on with service conditions (partially supported)
# 3. Docker-specific labels (traefik works, but socket path changes)

# Check logs
podman compose logs -f

If podman compose has compatibility issues, try the pip version which may have better coverage:

1
2
pip3 install podman-compose
podman-compose up -d

For production services, migrate from Compose to Quadlets for proper systemd integration:

1
2
3
4
5
6
7
8
# Generate a starting point from a running container
podman generate systemd --new --name myapp > myapp.service

# Then convert to a .container Quadlet file (cleaner long-term)
mkdir -p ~/.config/containers/systemd/
# Write the .container file as shown in Part 6
systemctl --user daemon-reload
systemctl --user enable --now myapp.service

Step 5: Update CI/CD Pipelines

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Before (Docker)
build:
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker build -t myapp:$CI_COMMIT_SHA .
    - docker push myapp:$CI_COMMIT_SHA

# After (Podman/Buildah — no DinD, no privileged)
build:
  image: quay.io/podman/stable:latest
  script:
    - podman build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - podman push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

Common Migration Gotchas

Traefik’s Docker provider uses /var/run/docker.sock. Point it to the Podman socket instead:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Enable Podman socket
systemctl --user enable --now podman.socket

# In Traefik's docker-compose.yml, change the socket mount:
# Before:
volumes:
  - /var/run/docker.sock:/var/run/docker.sock:ro

# After (rootless Podman):
volumes:
  - /run/user/1000/podman/podman.sock:/var/run/docker.sock:ro

# Or set globally:
export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock

Watchtower — Watchtower needs the Docker socket. The same socket substitution works:

1
2
3
4
podman run -d \
  --name watchtower \
  -v /run/user/1000/podman/podman.sock:/var/run/docker.sock \
  containrrr/watchtower --interval 86400

However, for Podman-native auto-updates, podman auto-update with the io.containers.autoupdate label is a cleaner solution.

Portainer supports Podman via the socket:

1
2
3
4
5
6
podman run -d \
  --name portainer \
  -p 9000:9000 \
  -v /run/user/1000/podman/podman.sock:/var/run/docker.sock \
  -v portainer_data:/data \
  portainer/portainer-ce:latest

Rootless networking with ports below 1024. Applications that need port 80 or 443 require either the sysctl fix or a rootful Podman setup:

1
2
3
4
# System-wide fix for rootless low port binding
echo "net.ipv4.ip_unprivileged_port_start=80" \
  | sudo tee /etc/sysctl.d/99-unprivileged-port.conf
sudo sysctl -p /etc/sysctl.d/99-unprivileged-port.conf

Network differences. Rootless Podman uses slirp4netns or pasta for user-space networking rather than a kernel bridge. This means containers are NAT’d through the host, just like rootful Docker, but there’s no native bridge network. Most workloads are unaffected. If you need containers to have their own IPs on the host network, you’ll need rootful Podman.

Image short names. Where Docker defaults to Docker Hub, Podman may prompt you to choose a registry. Fix it in config:

1
2
3
4
5
# /etc/containers/registries.conf.d/shortnames.conf
[aliases]
"nginx" = "docker.io/library/nginx"
"postgres" = "docker.io/library/postgres"
"redis" = "docker.io/library/redis"

Part 10: Podman Desktop

Podman Desktop is a cross-platform GUI application analogous to Docker Desktop. It’s open source and free without licensing restrictions.

Available on: Linux (Flatpak, RPM, DEB), macOS, Windows

Key features:

  • Container and pod management with a visual interface
  • Image management and builds
  • Kubernetes integration — view and manage local clusters (Kind, Minikube, k3s)
  • Extensions for Compose, OpenShift, and more
  • Dashboard with container health, resource usage, and logs
  • Built-in terminal for container exec
  • Import and export Docker Compose stacks
1
2
3
4
5
6
7
# Install on Linux via Flatpak
flatpak install flathub io.podman_desktop.PodmanDesktop

# macOS
brew install --cask podman-desktop

# Or download from https://podman-desktop.io

On macOS and Windows, Podman Desktop manages a Linux VM (similar to Docker Desktop’s VM model) to run containers, since the container runtime requires a Linux kernel. On Linux, it talks directly to the Podman socket.

For homelabbers who prefer a GUI, Podman Desktop is a compelling alternative to Docker Desktop — especially since Docker Desktop requires a paid subscription for organizations over 250 employees or $10M in revenue.


Part 11: When to Stick with Docker

Podman is not the right choice for every situation. Honest trade-offs:

Docker Desktop on macOS/Windows is more polished. The developer experience — especially volume performance on macOS via VirtioFS, and the integrated Docker Extensions marketplace — is more mature than Podman Desktop. If most of your team works on Macs and Docker Desktop’s licensing isn’t a concern, the familiarity and polish may outweigh Podman’s security advantages.

Docker Compose V2 is more mature. The docker compose V2 plugin is more feature-complete and better tested than podman compose. Complex Compose files with depends_on: condition: service_healthy, custom networking, and Docker-specific extensions are more reliably handled by Docker Compose.

Larger ecosystem integration. Tools like Portainer, Yacht, and many CI systems have deeper, better-tested Docker socket integration. Podman’s socket compatibility is good, but edge cases exist. If you rely heavily on the ecosystem, Docker reduces friction.

Team familiarity and documentation. Docker has more tutorials, Stack Overflow answers, and vendor documentation than Podman. For teams new to containers, the learning resources for Docker are more abundant.

Container orchestrators beyond Kubernetes. If you’re using Nomad, Docker Swarm, or other orchestrators that use the Docker daemon API, Podman isn’t a fit.


Part 12: Side-by-Side Comparison

Feature Docker Podman
Architecture Daemon (dockerd) running as root Daemonless, fork-exec model
Rootless containers Opt-in, requires manual setup Default, first-class feature
Pod support No native concept Yes, Kubernetes-compatible pods
Systemd integration Awkward, workarounds needed Native via Quadlets, auto-update
Compose support Docker Compose V2 (mature) podman compose / podman-compose (good, less mature)
Windows/macOS Docker Desktop (polished, licensing restrictions) Podman Desktop (open source, less polished)
Kubernetes YAML No native generation podman generate kube / podman kube play
Security model Root daemon, socket = root access No persistent root process, smaller attack surface
Container escape impact Attacker gets host root Attacker gets unprivileged UID (100000+)
RHEL/enterprise support Third-party, requires subscription First-party Red Hat, in RHEL 8+ by default
Image format OCI-compatible OCI-compatible
Registry tools Limited (docker save/load) Full suite: Podman + Buildah + Skopeo
Building images docker build (requires daemon) podman build / buildah bud (rootless, daemonless)
CI/CD builds Docker-in-Docker or bind-mount socket Rootless build, no daemon required
Auto-updates Watchtower (third-party) podman auto-update (built-in)
License Apache 2.0 (engine), Docker Desktop paid for orgs Apache 2.0, all components free

Putting It All Together

Podman’s strengths align well with a specific set of concerns: security-conscious production environments, RHEL/Fedora-based infrastructure, Kubernetes preparedness, and systemd-native service management. If those concerns match your environment, Podman is not just an alternative to Docker — it’s a genuinely better fit.

The migration path is low-risk. Start with the alias, migrate your most important service, and run both in parallel. For homelab use, the combination of rootless containers + Quadlets + podman auto-update is a particularly clean stack: containers run as your own user, start at boot via systemd, and stay up to date automatically — no daemon, no root, no Watchtower.

The Buildah + Skopeo pairing rounds out the story for anyone building or distributing images. Rootless image builds without DinD are meaningful in CI environments with strict security policies, and Skopeo’s registry-to-registry copy capability is something Docker simply doesn’t have a clean answer for.

If you’re on Fedora or RHEL, Podman is already there. If you’re on Debian or Ubuntu, it’s an apt install away. The alias works. Start there, and you’ll find Podman earns its place incrementally.


Further reading: Podman documentationBuildah documentationSkopeo on GitHubPodman DesktopQuadlet documentation

Comments