podman
Overview: Standalone container workloads on ul run under rootless Podman (podman 6.x) managed as native systemd user services via Podman Quadlets under the dedicated unprivileged account docker-data (UID 1100, GID 1100). This eliminates the privileged root daemon attack surface while preserving the per-service ULA IPv6 reverse-proxy architecture on eth77.
1. Architecture & Security Comparison: Rootless Podman vs. Docker
Under traditional Docker, dockerd runs as root (UID 0) inside the host init user namespace (user:[4026531837]). Every container spawned by dockerd without user-namespace remapping shares the host kernel's root UID 0 whenever the container process runs as root, and any vulnerability in the daemon or runtime starts with full host privileges.
Under rootless Podman, there is no central root daemon. Instead, systemd manages a per-user service manager (user@1100.service) for docker-data, which spawns lightweight conmon and crun processes entirely inside an unprivileged Linux user namespace (user:[4026534031]).
1.1 User Namespace UID/GID Translation Model
Rootless Podman maps container UIDs and GIDs into unprivileged host IDs using /etc/subuid and /etc/subgid (docker-data:624288:65536). Under the default rootless mapping (UserNS=host within the rootless user namespace), any container UID $u \in [0, 65536]$ translates to a host kernel UID according to:
$$
\text{UID}\sb{_{\text{host}}(u) = \begin{cases} 1100 & \text{if } u = 0 \quad (\text{container } \texttt{root} \longrightarrow \text{host } \texttt{docker-data}) \cr 624287 + u & \text{if } 1 \le u \le 65536 \quad (\text{subordinate range } 624288\text{--}689823) \end{cases}
$$
When a service explicitly sets UserNS=keep-id with User=1100:1100 (such as drawpile, whose binary refuses to start if internal getuid() == 0), the identity mapping preserves UID 1100 across the namespace boundary:
$$
\text{UID}\sb{_{\text{container}keep-id}}(1100) \stackrel{\texttt{keep-id}userns}}{\longleftrightarrow} 1100 \text{UID} (\sb{\text{host}}(1100)texttt{docker-data})
$$
1.2 Side-by-Side Comparison
| Dimension | Rootful Docker (dockerd) |
Rootless Podman (docker-data Quadlets) |
|---|---|---|
| Daemon Model | Persistent root daemon (dockerd + containerd) running as UID 0 |
Daemonless fork/exec (systemd $\longrightarrow$ conmon $\longrightarrow$ crun) running as UID 1100 |
Host Kernel UID of Container root |
Host UID 0 (root in init user:[4026531837]) |
Host UID 1100 (docker-data in isolated user:[4026534031]) |
Host Kernel UID of Container Worker (u > 0) |
Same numeric UID on host (e.g., 101 or 33) |
Subordinate UID $624287 + u$ (e.g., nginx $101 \longrightarrow 624388$, www-data $33 \longrightarrow 624320$) |
| Service Definition & Lifecycle | /data/docker/<svc>/docker-compose.yml managed by dockerd restart policies |
/data/docker/<svc>/<svc>.container Quadlet compiled into native systemd --user unit <svc>.service |
ULA IPv6 Port Forwarding (eth77) |
docker-proxy running as UID 0 + root iptables/nftables NAT |
rootlessport + netavark (Network=bridge) binding unprivileged sockets on [fd77:...]:PORT as UID 1100 |
| Firewall Mutation | Mutates host iptables/nftables chains directly as root |
Zero host firewall mutation; binds unprivileged user sockets directly to pre-assigned eth77 ULA addresses |
ROCm GPU Access (/dev/kfd, /dev/dri) |
devices: + group_add: ["91", "991"] as root |
AddDevice= + GroupAdd=keep-groups inheriting docker-data's video (91) and render (991) groups |
| Image & Overlay Storage | /var/lib/docker/overlay2 (root:root) |
/home/docker-data/.local/share/containers/storage (overlay driver owned by docker-data) |
2. Host Configuration on ul
2.1 Unprivileged Account, Subordinate IDs & Systemd Lingering
- Service Account (
docker-data):- Primary UID/GID:
1100:1100(docker-data) - Home Directory:
/home/docker-data - Supplementary Groups:
video(91) andrender(991) for AMD ROCm GPU device access (/dev/driand/dev/kfd).
- Primary UID/GID:
- Subordinate UID/GID Ranges (
/etc/subuid&/etc/subgid):docker-data:624288:65536 - Systemd User Lingering:
Enabled via
sudo loginctl enable-linger docker-data(creating/var/lib/systemd/linger/docker-data). This ensuresuser@1100.servicestarts automatically at system boot and keeps all rootless Podman Quadlet services running without requiring an active login session.
2.2 Global Configuration Files & Storage Layout
/etc/containers/registries.conf: Configures unqualified image names to resolve against Docker Hub by default:unqualified-search-registries = ["docker.io"]/etc/containers/containers.conf:[network] default_network_backend = "pasta" pasta_options = ["-a", "10.0.2.100", "-a", "fd00::100"]- Storage Paths:
- Persistent image/layer store (
GraphRoot):/home/docker-data/.local/share/containers/storage(native kerneloverlayfilesystem on NVMe/). - Ephemeral runtime state (
RunRoot):/run/user/1100/containers. - Quadlet unit directory:
/home/docker-data/.config/containers/systemd/(contains symlinks pointing to/data/docker/<service>/<service>.container).
- Persistent image/layer store (
2.3 Networking Architecture on ul: Why Network=bridge Is Used
Every container service on ul binds its published port to a dedicated ULA IPv6 address on eth77 (fd77:7777:5010:0:1337:f1ac:d00d:<hex>/64), and host Nginx (UID 33 http) reverse-proxies requests to http://[fd77:...:<hex>]:<port>.
All rootless Quadlet definitions on ul specify Network=bridge rather than default pasta networking for two architectural reasons:
- Preventing Local ULA IPv6 Hairpin Collisions:
By default,
pastaclones the host'seth77interface and copies all 36/64ULA IPv6 addresses from the host into the container namespace. When host Nginx connects from the same machine (src = fd77:...:<hex>,dst = fd77:...:<hex>), apasta-networked container sees the source IP as a local address on its own interface unless isolated. - Seamless IPv6-to-IPv4 Protocol Translation (
rootlessport): Many container images (tldraw,sopro,demucs,drawpile,navidrome,vaultwarden) bind their internal server to IPv40.0.0.0:<port>only (not IPv6[::]:<port>). WithNetwork=bridge, Podman spawns/usr/lib/podman/rootlessporton the host side to accept IPv6 connections on[fd77:...:<hex>]:<host_port>and proxy them cleanly over IPv4 into the container'snetavarkbridge interface (10.88.0.x:<container_port>):
$$
\text{Nginx } (\texttt{http}) \stackrel{\text{IPv6 TCP}}{\longrightarrow} [\texttt{fd77:...:hex}]:P\sb{P_{\text{host}} \ (\texttt{rootlessport}) \stackrel{\text{IPv4 TCP}}{\longrightarrow} \texttt{10.88.0.x}:P\sb{P(\text{container}})
$$
3. Quadlet Service Architecture & Migrated Inventory
3.1 How Quadlets Work
Instead of docker-compose.yml, each service directory /data/docker/<service>/ contains a declarative <service>.container file symlinked into /home/docker-data/.config/containers/systemd/<service>.container.
Whenever sudo systemctl --user -M docker-data@ daemon-reload runs (or on boot), /usr/lib/systemd/user-generators/podman-system-generator compiles each .container file into a transient systemd unit at /run/user/1100/systemd/generator/<service>.service.
3.2 Migrated Rootless Podman Service Inventory
| Service | Systemd Unit (docker-data@) |
Image | ULA IPv6 Binding (eth77) |
Host $\leftrightarrow$ Container Port | Host Process UID | UserNS Mode | Volumes / Devices |
|---|---|---|---|---|---|---|---|
audiomass |
audiomass.service |
docker.io/azrikahar/audiomass:latest |
fd77:7777:5010:0:1337:f1ac:d00d:a0d1 |
9004 -> 80 |
1100 (624388 workers) |
Default rootless | Stateless |
bentopdf |
bentopdf.service |
ghcr.io/alam00000/bentopdf:latest |
fd77:7777:5010:0:1337:f1ac:d00d:bdf |
8080 -> 8080 |
624388 (container 101) |
Default rootless | Stateless (.env) |
excalidraw |
excalidraw.service |
docker.io/excalidraw/excalidraw:latest |
fd77:7777:5010:0:1337:f1ac:d00d:e8ca |
9003 -> 80 |
1100 (624388 workers) |
Default rootless | Stateless |
tldraw |
tldraw.service |
docker.io/ratneo/tldraw:latest |
fd77:7777:5010:0:1337:f1ac:d00d:71d7 |
9002 -> 80 |
1100 (624388 workers) |
Default rootless | Stateless |
wick |
wick.service |
docker.io/library/nginx:alpine |
fd77:7777:5010:0:1337:f1ac:d00d:41c8 |
9005 -> 80 |
1100 (624388 workers) |
Default rootless | /data/docker/wick/html:/usr/share/nginx/html:ro |
webrcade |
webrcade.service |
docker.io/webrcade/webrcade:latest |
fd77:7777:5010:0:1337:f1ac:d00d:a1ca |
8080 -> 80 |
1100 (624320 workers) |
Default rootless | /data/docker/webrcade/content, /data/games:ro |
drawpile |
drawpile.service |
docker.io/drawpile/drawpile-srv:2 |
fd77:7777:5010:0:1337:f1ac:d00d:d7a0 |
27750-27752 -> 27750-27752 |
1100 (container 1100) |
UserNS=keep-id |
/data/docker/drawpile/sessions:/data/sessions |
navidrome |
navidrome.service |
docker.io/deluan/navidrome:latest |
fd77:7777:5010:0:1337:f1ac:d00d:5a70 |
4533 -> 4533 |
1100 (container 0) |
Default rootless | /data/docker/navidrome/data:/data, /data/music/flac:/music:ro |
vaultwarden |
vaultwarden.service |
docker.io/vaultwarden/server:latest |
fd77:7777:5010:0:1337:f1ac:d00d:a117 |
8222 -> 80 |
1100 (container 0) |
Default rootless | /data/docker/vaultwarden/vw-data:/data (.env) |
sopro |
sopro.service |
localhost/sopro:latest |
fd77:7777:5010:0:1337:f1ac:d00d:509 |
7860 -> 7860 |
1100 (container 0) |
Default rootless | /data/docker/sopro/cache:/root/.cache/huggingface |
demucs |
demucs.service |
localhost/demucs:latest |
fd77:7777:5010:0:1337:f1ac:d00d:de3 |
8000 -> 8000 |
1100 (container 0) |
Default + keep-groups |
/data/docker/demucs/{app.py,static,cache,media}, /dev/dri, /dev/kfd |
3.3 Reference Quadlet Patterns
Pattern A: Standard Stateful Service (e.g., /data/docker/vaultwarden/vaultwarden.container)
[Unit]
Description=Vaultwarden Rootless Podman Container
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=vaultwarden
Image=docker.io/vaultwarden/server:latest
Network=bridge
User=0:0
PublishPort=[fd77:7777:5010:0:1337:f1ac:d00d:a117]:8222:80
EnvironmentFile=/data/docker/vaultwarden/.env
Environment=TZ=America/New_York
Volume=/data/docker/vaultwarden/vw-data:/data
[Service]
Restart=always
[Install]
WantedBy=default.target
Because container 0:0 maps directly to host docker-data:docker-data (1100:1100), files written inside /data are owned on the host ZFS dataset by docker-data:docker-data (1100:1100).
Pattern B: Non-Root Enforced Binary with UserNS=keep-id (e.g., /data/docker/drawpile/drawpile.container)
[Unit]
Description=Drawpile Rootless Podman Container
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=drawpile
Image=docker.io/drawpile/drawpile-srv:2
Network=bridge
UserNS=keep-id
User=1100:1100
Exec=--port 27750 --websocket-port 27751 --web-admin-port 27752 --web-admin-access all --web-admin-auth admin:drawpile123 --sessions /data/sessions --database /data/sessions/drawpile.db
Volume=/data/docker/drawpile/sessions:/data/sessions
PublishPort=[fd77:7777:5010:0:1337:f1ac:d00d:d7a0]:27750:27750
PublishPort=[fd77:7777:5010:0:1337:f1ac:d00d:d7a0]:27751:27751
PublishPort=[fd77:7777:5010:0:1337:f1ac:d00d:d7a0]:27752:27752
[Service]
Restart=always
[Install]
WantedBy=default.target
drawpile-srv aborts on startup if internal getuid() == 0. Setting UserNS=keep-id and User=1100:1100 makes the process see UID 1100 inside the container while still mapping to UID 1100 (docker-data) on the host filesystem.
Pattern C: AMD ROCm GPU Passthrough with GroupAdd=keep-groups (e.g., /data/docker/demucs/demucs.container)
[Unit]
Description=Demucs ROCm Rootless Podman Container
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=demucs
Image=localhost/demucs:latest
Network=bridge
AddDevice=/dev/dri:/dev/dri
AddDevice=/dev/kfd:/dev/kfd
GroupAdd=keep-groups
PublishPort=[fd77:7777:5010:0:1337:f1ac:d00d:de3]:8000:8000
EnvironmentFile=/data/docker/demucs/.env
Environment=TZ=America/New_York
Environment=TORCH_HOME=/root/.cache/torch
Environment=HF_HOME=/root/.cache/huggingface
Environment=MIOPEN_USER_DB_PATH=/root/.cache/miopen
Environment=MIOPEN_CUSTOM_CACHE_DIR=/root/.cache/miopen
Volume=/data/docker/demucs/app.py:/app/app.py:ro
Volume=/data/docker/demucs/static:/app/static:ro
Volume=/data/docker/demucs/cache/torch:/root/.cache/torch
Volume=/data/docker/demucs/cache/huggingface:/root/.cache/huggingface
Volume=/data/docker/demucs/cache/miopen:/root/.cache/miopen
Volume=/data/docker/demucs/media:/app/media
[Service]
Restart=always
[Install]
WantedBy=default.target
GroupAdd=keep-groups instructs crun not to call setgroups(0, NULL) when entering the user namespace, preserving docker-data's host membership in video (91) and render (991) so /dev/kfd and /dev/dri/renderD128 are accessible to PyTorch ROCm.
4. Day-to-Day Operations: Status, Logs & Lifecycle
Important Execution Context: Rootless Podman containers belong to docker-data (UID 1100), not root or luke. Running bare sudo podman ps inspects root's empty store, not docker-data's store. Always use sudo systemctl --user -M docker-data@ ... for service management, or set HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 from a directory readable by docker-data (such as cd /home/docker-data) when invoking podman directly.
4.1 Checking Service & Container Status
# 1. Check systemd unit status for a specific Quadlet service
$ sudo systemctl --user -M docker-data@ status vaultwarden.service
# 2. List all active Quadlet container units under docker-data
$ sudo systemctl --user -M docker-data@ list-units --type=service --state=running
# 3. List all running rootless Podman containers, ports, and uptime
$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman ps -a)
# 4. View live CPU, memory, and I/O resource usage across all rootless containers
$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman stats --no-stream)
# 5. Inspect processes running inside a container (showing container UID vs host UID)
$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman top vaultwarden user uid huser huid pid comm)
4.2 Viewing Logs
Because Quadlet services integrate directly with systemd (--sdnotify=conmon), container output is available through both journalctl and podman logs:
# Follow live systemd journal logs for a service (includes systemd start/stop events + container stdout/stderr)
$ sudo journalctl --user -M docker-data@ -u navidrome.service -f
# View the last 100 lines of systemd journal logs
$ sudo journalctl --user -M docker-data@ -u demucs.service -n 100 --no-pager
# View container stdout/stderr directly via podman logs
$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman logs --tail 100 -f sopro)
4.3 Starting, Stopping, Restarting & Editing Quadlets
# Restart a service
$ sudo systemctl --user -M docker-data@ restart vaultwarden.service
# Stop or start a service
$ sudo systemctl --user -M docker-data@ stop drawpile.service
$ sudo systemctl --user -M docker-data@ start drawpile.service
# Apply edits made to /data/docker/<service>/<service>.container
$ sudo systemctl --user -M docker-data@ daemon-reload
$ sudo systemctl --user -M docker-data@ restart <service>.service
4.4 Updating Images & Rebuilding Local Dockerfiles
- Updating a Registry-Backed Image (
vaultwarden,navidrome,bentopdf, etc.):$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman pull docker.io/vaultwarden/server:latest) $ sudo systemctl --user -M docker-data@ restart vaultwarden.service - Rebuilding a Local
DockerfileImage (sopro,demucs): UseTMPDIR=/dev/shmso large multi-gigabyte layer builds (demucsROCm is ~16.4 GB) stage in RAM rather than disk:$ (cd /data/docker/demucs && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 TMPDIR=/dev/shm podman build -t localhost/demucs:latest /data/docker/demucs) $ sudo systemctl --user -M docker-data@ restart demucs.service - Pruning Unused/Dangling Images:
$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman image prune -f)
4.5 Onboarding a New Standalone Service
- Assign a dedicated ULA IPv6 address on
eth77(fd77:7777:5010:0:1337:f1ac:d00d:<hex>/64) in/etc/systemd/network/and bind it live withsudo ip -6 addr add ... dev eth77. - Create
/data/docker/<service>/<service>.containerwithNetwork=bridgeandPublishPort=[fd77:7777:5010:0:1337:f1ac:d00d:<hex>]:<host_port>:<container_port>. - Set ownership and permissions:
$ sudo chown -R docker-data:docker-data /data/docker/<service> $ sudo chmod 755 /data/docker/<service> $ sudo -u docker-data ln -sf /data/docker/<service>/<service>.container /home/docker-data/.config/containers/systemd/<service>.container - Reload and start:
$ sudo systemctl --user -M docker-data@ daemon-reload $ sudo systemctl --user -M docker-data@ start <service>.service - Create
/home/luke/git/backups/ul/scripts/backup/<service>and/home/luke/git/backups/ul/scripts/restore/<service>archiving<service>.containerand any data volumes, and verify withshellcheck.
5. Troubleshooting & Diagnostics
5.1 Symptom: Local curl or Nginx Times Out / Resets on [fd77:...]:PORT
- Cause:
- If
Network=pasta(the default whenNetwork=is omitted) is used without isolated-aaddresses,pastacopies all 36 host ULA IPv6 addresses frometh77into the container namespace, causing replies to local connections from the host's own ULA IPv6 address to loop back inside the container. - Even with
Network=pasta:-a,10.0.2.100,-a,fd00::100,pastaforwards connections arriving on an IPv6 host socket as IPv6 packets inside the container. If the container application only listens on IPv40.0.0.0:<port>(such astldraw,sopro,demucs, ordrawpile), the container kernel sends TCPRST(curl: (56) Recv failure: Connection reset by peer).
- If
- Fix:
Ensure the
.containerfile specifiesNetwork=bridge, then runsudo systemctl --user -M docker-data@ daemon-reload && sudo systemctl --user -M docker-data@ restart <service>.service. WithNetwork=bridge,rootlessportterminates the IPv6 connection on the host and connects over IPv4 (10.88.0.x) inside the container.
5.2 Symptom: Container Fails to Start with Permission denied on Volumes or Refuses UID 0
- Case 1 — Application refuses to run as
root(getuid() == 0) inside the container (e.g.,drawpile-srv): Under default rootless Podman,User=0:0maps toUID 1100on the host, so the process is unprivileged on the host—but inside the containergetuid()returns0. If a binary explicitly checksgetuid() == 0and exits, add bothUserNS=keep-idandUser=1100:1100to[Container]. - Case 2 — Multi-process container (
nginx,apache2) starts asroot(0 -> 1100) and drops worker privileges to UID101(624388) or UID33(624320): Because worker processes run as host UID624388or624320(not1100), they access bind-mounted directories via theotherpermission bits. Ensure the parent directory/data/docker/<service>haschmod 755(drwxr-xr-x) and read-only content directories (html,content) have world-readable traversal (chmod -R a+rX). - Case 3 — Inspecting host-mapped UIDs inside a container:
Run
podman unshareasdocker-datato view filesystem ownership from inside the container's user namespace:$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman unshare ls -lan /data/docker/vaultwarden/vw-data)
5.3 Symptom: Error: chdir("/home/luke"): Permission denied When Running sudo -u docker-data podman
- Cause:
When you run
sudo -u docker-data ...while your current working directory is/home/luke(0700 luke:luke),podmanor its OCI runtime attempts tostat()/chdir()the inherited working directory and fails withPermission denied. - Fix:
Always change directory to
/home/docker-data(or/tmp) in a subshell and passHOMEandXDG_RUNTIME_DIR:$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman ps)
5.4 Symptom: Quadlet .container File Edits Not Applied or Service Not Found
- Diagnostic Command:
Run the Quadlet systemd generator in
--dry-runmode asdocker-datato inspect any syntax or parsing errors in/home/docker-data/.config/containers/systemd/*.container:$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 /usr/lib/systemd/user-generators/podman-system-generator --user --dry-run) - Checklist:
- Confirm
/home/docker-data/.config/containers/systemd/<service>.containeris a valid symlink to/data/docker/<service>/<service>.containerand that/data/docker/<service>is readable bydocker-data. - Run
sudo systemctl --user -M docker-data@ daemon-reload. - If the service previously hit
start-limit-hit, clear the failed state before starting:$ sudo systemctl --user -M docker-data@ reset-failed <service>.service $ sudo systemctl --user -M docker-data@ restart <service>.service
- Confirm
5.5 Symptom: ROCm GPU (torch.cuda.is_available() == False) Inside Rootless Container
- Checklist:
- Verify
docker-datais a member ofvideo(91) andrender(991) on the host (id docker-data). - Verify the
.containerfile includes all three directives under[Container]:AddDevice=/dev/dri:/dev/dri AddDevice=/dev/kfd:/dev/kfd GroupAdd=keep-groups - Test GPU detection live inside
demucs:$ (cd /home/docker-data && sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman exec demucs python3 -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))") True AMD Radeon AI PRO R9700
- Verify
5.6 Full Audit Script: Verify Host UIDs, User Namespaces & HTTP Health
Run the following one-liner on ul to audit every rootless Podman service's host kernel UID, user namespace isolation, and ULA IPv6 HTTP response:
$ (cd /home/docker-data && init_ns=$(sudo readlink /proc/1/ns/user) && echo "Init UserNS: $init_ns" && \
for c in audiomass bentopdf excalidraw tldraw wick webrcade drawpile navidrome vaultwarden sopro demucs; do \
pid=$(sudo -u docker-data HOME=/home/docker-data XDG_RUNTIME_DIR=/run/user/1100 podman inspect "$c" --format "{{.State.Pid}}"); \
uid=$(awk "/^Uid:/ {print \$2}" "/proc/$pid/status"); \
ns=$(sudo readlink "/proc/$pid/ns/user"); \
printf "%-12s PID=%-8s HostUID=%-8s UserNS=%s\n" "$c" "$pid" "$uid" "$ns"; \
done)