# Running the Traefik 3 Hardened Image (nonroot) on Podman: 3 Problems and Their Fixes

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:

```plaintext
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:

```plaintext
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:

```plaintext
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:

```plaintext
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:

1.  Host permissions can reset every time the socket service restarts (you'd need a systemd override to make it permanent).
    
2.  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.

```yaml
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:

1.  **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.
    
2.  **Give the dashboard/API its own entrypoint, separate from the web entrypoint**, when using `api.insecure=true`. This avoids implicit port conflicts.
    
3.  **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`.
