Docker Hardened Images (DHI) give you production-ready images with a small attack surface: no shell, no package manager, and — this is the part that confuses people the most — they run as a nonroot user (UID 65532) by default. This is great for security. But if you switch your old image to dhi.io/traefik:3-alpine without any changes, you will almost certainly hit a few errors.
This article covers three problems I found while moving a Traefik reverse proxy to DHI on top of Podman (rootful), along with the root cause and the fix for each one.
The starting setup
A simple stack: Traefik as a reverse proxy plus a Cloudflare Tunnel, running on Podman with the socket mounted in place of the Docker socket:
services:
traefik:
image: dhi.io/traefik:3-alpine
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.http.address=:8080"
- "--entrypoints.https.address=:8443"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http"
- "--certificatesresolvers.letsencrypt.acme.email=your-email@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:8080"
- "443:8443"
volumes:
- traefik_letsencrypt:/letsencrypt
- "/run/podman/podman.sock:/var/run/docker.sock:ro,z"
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
volumes:
traefik_letsencrypt:
This looks normal. But once you run docker compose up, three problems appear one after another.
Problem 1: listen tcp :8080: bind: address already in use
The first instinct is usually: "a nonroot user can't bind to a privileged port." But look closely at the error message — it says address already in use, not permission denied. These are two different errors with two different causes.
Root cause: --api.insecure=true without an explicit entrypoint makes Traefik automatically create an internal entrypoint called traefik for the dashboard. This entrypoint also defaults to :8080. If your config already sets another entrypoint to :8080 too, both listeners try to bind the same port inside the same process.
Fix: give the dashboard its own port, separate from the web entrypoint:
command:
- "--api.dashboard=true"
- "--api.insecure=true"
- "--entrypoints.http.address=:8080"
- "--entrypoints.https.address=:8443"
- "--entrypoints.traefik.address=:8081" # dashboard, its own port
A quick note on privileged ports: on Docker Engine 20.10 and later, a nonroot user can actually still bind to ports below 1024. But on Kubernetes or older engines, you still need to use ports above 1024 inside the container (as shown above), then map them to 80/443 with ports:.
Problem 2: permission denied when connecting to the Podman socket
Once the port issue is fixed, the next error shows up:
Failed to retrieve information of the docker client and server host
error="permission denied while trying to connect to the docker API
at unix:///var/run/docker.sock" providerName=docker
Root cause: UID 65532 (nonroot) doesn't match the owner or group of podman.sock on the host, which is usually:
root root 660
Since the DHI image has no shell, you can't run chmod or chown from inside the container. A few quick fixes exist — using an ACL, or changing the socket's group to GID 65532 — but they both have two downsides:
Host permissions can reset every time the socket service restarts (you'd need a systemd override to make it permanent).
More importantly: giving direct socket access means giving Traefik full root-level access to the entire container engine on the host. If Traefik (which faces the public internet) gets compromised, the attacker gets full control over every container.
Problem 3: The real fix — a Docker/Podman Socket Proxy
The correct solution for this combination — nonroot, hardened image, and a publicly-facing reverse proxy — is to not give Traefik socket access at all. Instead, put a dedicated proxy between Traefik and the socket, one that only allows the read-only endpoints actually needed for service discovery.
services:
socket-proxy:
image: lscr.io/linuxserver/socket-proxy:latest
restart: unless-stopped
environment:
- CONTAINERS=1 # list containers (required for discovery)
- NETWORKS=1 # resolve container networks (required for routing)
- EVENTS=1 # live updates when containers start/stop
# everything else stays at the default 0 (deny)
volumes:
- "/run/podman/podman.sock:/var/run/docker.sock:ro,z"
networks:
- socket-proxy-net
traefik:
image: dhi.io/traefik:3-alpine
command:
- "--api.dashboard=true"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.endpoint=tcp://socket-proxy:2375"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.http.address=:8080"
- "--entrypoints.https.address=:8443"
- "--entrypoints.traefik.address=:8081"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http"
- "--certificatesresolvers.letsencrypt.acme.email=your-email@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:8080"
- "443:8443"
volumes:
- traefik_letsencrypt:/letsencrypt
networks:
- socket-proxy-net
- default
depends_on:
- socket-proxy
cloudflared:
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
networks:
- default
networks:
socket-proxy-net:
internal: true
default:
volumes:
traefik_letsencrypt:
The socket-proxy-net network is marked internal: true so it stays fully isolated. Nothing outside this network can reach it, even if the socket-proxy port is accidentally exposed somewhere else.
Why this approach is better
| Aspect |
Direct socket mount |
Socket proxy |
| Requires host permission changes |
Yes (chmod/setfacl, breaks on restart) |
No |
| Works with a nonroot image that has no shell |
Needs a workaround |
Works natively, no issues |
| API access if the container is compromised |
Full read-write access to the whole engine |
Read-only, limited to containers/networks/events |
| Label-based autodiscovery |
Works |
Still works, no changes needed |
That last point matters most: Traefik's label-based autodiscovery (traefik.enable=true, and so on) doesn't change at all. Traefik still uses the same providers.docker setup — only the endpoint now points to the proxy instead of the raw socket.
Summary
If you're moving Traefik to a hardened or nonroot image (or any production image that runs as nonroot) on top of Docker or Podman:
Don't assume a port error is always about privileges. Check the error message closely — already in use is not the same as permission denied, and each one has a different cause and a different fix.
Give the dashboard/API its own entrypoint, separate from the web entrypoint, when using api.insecure=true. This avoids implicit port conflicts.
Never mount the container engine socket directly into a publicly-facing reverse proxy. Use a socket proxy with the narrowest read-only permissions possible. This isn't just a workaround for the nonroot permission issue — it's good security practice regardless of which user your container runs as.
Note: the examples above use rootful Podman with the socket at /run/podman/podman.sock. If you're using regular Docker, change the socket path to /var/run/docker.sock.