Running Traefik with Rootless Docker and Systemd Sockets

Trying to set up rootless docker containers that need more permissions is generally a headache. Luckily, Linux is awesome! Dive in to see how systemd-sockets can save the day.

The Why

The idea of rootless Docker is to minimize the attack surface by removing the need for Docker to run with elevated privileges.
As a result, we encounter obstacles when trying to run things the traditional, privileged way — like binding to ports below 1024. Luckily, Linux is awesome.

 

In this post, we’ll set up Traefik as a reverse proxy in rootless Docker, while still being able to serve on ports 80 and 443 — cleanly and securely.

The problem with rootless Docker port assignment is that it restricts you from using ports higher than 1024. That’s a clear limitation when hosting web services.

 

We don’t want to mess with NAT, iptables, CAP_NET_BIND_SERVICE, or external tools like socat. These introduce additional complexity and potential security risks.

No iptables hacks, no socat shell escapes, no exposed host networking via --cap-add or --net=host.

It’s native. It’s simple.

We’ll use good old systemd socket units and services, and let systemd-socket-proxyd forwards the ports.

That way, our Docker containers stay nicely isolated. It also works seamlessly with security tools like SELinux and AppArmor — all fully native.

What is systemd-socket-proxyd?

systemd-socket-proxyd is a lightweight systemd helper binary that reads data from sockets opened by systemd and forwards it to another IP/port.

 

We’re using it for socket activation, to start services only when needed, and to safely forward traffic from privileged ports (80/443) to unprivileged services — like rootless Docker containers running Traefik.

 

It helps isolate responsibilities: systemd owns the ports, and Your apps own the logic.

How it works

  • .socket opens a port (e.g. 80)
  • .service launches systemd-socket-proxyd
  • systemd passes the socket file descriptor (FD) to proxyd
  • proxyd reads the connection and forwards it to 127.0.0.1:PORT
  • No need for iptables, socat, or root-bound services

Again, the benefits

  • Native to systemd
  • Written in C — extremely fast and lightweight
  • Doesn’t bind ports — it proxies from existing sockets
  • Zero config or dependencies

How This Setup Works Under the Hood

This Traefik setup uses systemd socket activation mechanism to forward traffic from ports 80 and 443 to Traefik running inside rootless Docker — with no hacks, elevated permissions, or firewall tricks.

The Pieces

ComponentRole
.socket unitsBind ports 80/443 using systemd (root-owned)
.service unitsStart systemd-socket-proxyd when needed
systemd-socket-proxydForwards TCP traffic to Traefik inside Docker
Traefik (rootless)Listens on 8080/8443 inside an unprivileged container

Request Lifecycle

  1. A client hits http://<your-host> or https://<your-host>

  2. systemd (via .socket) receives the TCP connection on port 80 or 443

  3. systemd activates the paired .service (if not running)

  4. The .service runs:

     /lib/systemd/systemd-socket-proxyd 127.0.0.1:8080
    
  5. systemd-socket-proxyd forwards the TCP stream to 127.0.0.1:8080 (or 8443)

  6. Traefik (inside rootless Docker) receives the request and responds

Setting up systemd magic

Let's get into the main part of this post.

We will create two sockets and two service files. Socket activation starts the service which in turn routes requests to our Traefik listening on 8080 and 8443.

Sockets

Let's create the sockets first in the /etc/systemd/system directory.

/etc/systemd/system/traefik-80.socket


[Unit]
Description=Listen on port 80 for Traefik forwarding

[Socket]
ListenStream=80
Accept=no
ReusePort=true
BindIPv6Only=both

[Install]
WantedBy=sockets.target

/etc/systemd/system/traefik-443.socket


[Unit]
Description=Listen on port 443 for Traefik forwarding

[Socket]
ListenStream=443
Accept=no
ReusePort=true
BindIPv6Only=both

[Install]
WantedBy=sockets.target

 

Let's go through what we wrote.

[Unit]
Description=Listen on port 80 for Traefik forwarding

Human-readable description of the socket's role.

[Socket]
ListenStream=80

Listens on TCP port 80 (IPv4 + IPv6 by default).

Accept=no

Uses a single service to manage all incoming connections

ReusePort=true

Enables safe port reuse across service restarts.

BindIPv6Only=both

Allows dual-stack binding (IPv4 + IPv6).

[Install]
WantedBy=sockets.target

Activates socket during system boot via sockets.target.

Services

Now it's time to create the services the socket calls.

Create two services in the /etc/systemd/system directory.

/etc/systemd/system/traefik-80.service


[Unit]
Description=Forward socket from port 80 to Traefik (8080)
Requires=traefik-80.socket
After=network.target

[Service]
ExecStart=/lib/systemd/systemd-socket-proxyd 127.0.0.1:8080

/etc/systemd/system/traefik-443.service


[Unit]
Description=Forward socket from port 443 to Traefik (8443)
Requires=traefik-443.socket
After=network.target

[Service]
ExecStart=/lib/systemd/systemd-socket-proxyd 127.0.0.1:8443

 

Let's walk through the services as well.

[Unit]
Description=Forward socket from port 80 to Traefik (8080)
Requires=traefik-80.socket

Human-readable description of the socket's role and Requires tells this service should only run if the matching socket is present.

After=network.target

Wait for the network stack to initialize before starting the service.

[Service]
ExecStart=/lib/systemd/systemd-socket-proxyd 127.0.0.1:8080

systemd passes port 80 as a file descriptor to systemd-socket-proxyd , which forwards it to localhost:8080.

Fire up the sockets and services

Now load the new configuration and enable sockets and services.


sudo systemctl daemon-reexec
sudo systemctl daemon-reload

sudo systemctl enable --now traefik-80.socket traefik-443.socket
sudo systemctl enable --now traefik-80.service traefik-443.service

Setting up Traefik

I assume You have rootless docker installed.

Create a new directory in Your desired location with the following structure:


.
├── docker-compose.yaml
├── dynamic
│   └── test.yaml
├── logs
└── traefik.yaml

docker-compose.yaml

Let's set up our docker-compose to start the Traefik container listening on ports 8080 and 8443.
We want to bind mount our traefik.yaml file to a location inside the the container where Traefik knows to look for it.
Then we need to bind mount our docker.sock so we can access containers and read labels of other containers to route requests.
We also need a directory called dynamic that holds our configuration files.
We want to see the logs, so we will mount a directory for those.

 

docker-compose.yaml


services:
  traefik:
    image: traefik:v3.3
    container_name: traefik
    ports:
      - 127.0.0.1:8080:8080
      - 127.0.0.1:8443:8443
    volumes:
      - ./traefik.yaml:/etc/traefik/traefik.yaml
      - /run/user/<user-id>/docker.sock:/var/run/docker.sock
      - ./dynamic:/dynamic
      - ./logs:/var/log/traefik

Next, we create traefik.yaml configuration that gets loaded in on container startup.

 

traefik.yaml


global:
  checkNewVersion: true

api:
  dashboard: false

log:
  filePath: "/var/log/traefik/log-file.log"
  format: json

accessLog:
  filePath: "/var/log/traefik/log-access.log"
  bufferingSize: 100

entryPoints:
  http:
    address: ":8080"
    forwardedHeaders:
      insecure: true
    http:
      redirections:
        entrypoint:
          to: https
          scheme: https
  https:
    address: ":8443"
    forwardedHeaders:
      insecure: true

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
  file:
    directory: "/dynamic"
    watch: true

Now it is time to bring up the container.


docker-compose up -d

Testing Traefik routing

Let's create a small test router to test out if everything is working as it should.
This will demonstrate how Traefik dynamically loads in new configurations with the need for restarts or reloads like Apache or Nginx do.


http:
  routers:
    debug:
      rule: "PathPrefix(`/`)"
      entryPoints:
        - http
        - https
      service: hello
      tls: {}  # <- Required for HTTPS

  services:
    hello:
      loadBalancer:
        servers:
          - url: "http://httpbin.org"

Traefik Dynamic Configuration Glossary

Let's go through our test configuration and see what it is made up of.
When writing files in the dynamic/ directory, you’re creating a dynamic configurations, which Traefik loads and applies at runtime. Here’s what the core building blocks are called:

KeywordDescription
routersMatch incoming requests by host, path, method, etc.
servicesDefine where to forward matched requests (e.g. a container)
middlewaresModify requests/responses (e.g. strip prefix, auth, headers)
tlsConfigure TLS (certs, options, resolvers)
tls.optionsSet TLS versions, ciphers, SNI strategies
tls.certificatesManually define cert/key pairs for domains

 

Note: These sections live inside http: or tls: blocks in your .yaml files.

Traefik loads all valid .yaml or .yml files from the providers.file.directory path, so you can split them cleanly by function — just like code modules.

 

routers – When to match a request

Routers define rules for incoming requests: domain, path, method, etc.


http:
  routers:
    debug:
      rule: "PathPrefix(`/`)"
      entryPoints:
        - http
        - https
      service: hello
      tls: {}  # <- Required for HTTPS

“If someone goes to / on HTTP, forward it to the hello service.”

 

services – Where to send the request

Services define backends — usually container ports or URLs.


http:
  ...
  services:
    hello:
      loadBalancer:
        servers:
          - url: "http://httpbin.org"

“Forward to another site.”

middlewares – Modify the request/response

Middlewares are like filters: strip paths, add headers, enforce auth.


http:
  middlewares:
    strip-admin:
      stripPrefix:
        prefixes: ["/admin"]


  routers:
    admin:
      rule: "PathPrefix(`/admin`)"
      middlewares: [strip-admin]
      service: dashboard

“Remove /admin from the request before passing it to the service.”

tls – Enable HTTPS

Tell Traefik to use TLS (with or without certs):


http:
  routers:
    secure:
      rule: "Host(`example.com`)"
      entryPoints: [https]
      service: app
      tls: {}  # enables HTTPS with default cert resolver

tls.options – Fine-tune TLS behavior

Define minimum TLS version, cipher suites, etc.


tls:
  options:
    modern:
      minVersion: VersionTLS13

tls.certificates – Manually define TLS certs

Use custom certificates for specific domains:


tls:
  certificates:
    - certFile: "/certs/example.crt"
      keyFile: "/certs/example.key"

Let's test both ports.


curl -I http://localhost


curl -I https://localhost --insecure

You should get back HTTP/2 200 with some headers for both commands.

 

In a future post, we will set up a demo Python application and show how Traefik can be used to add security middleware to enable public and private access to certain paths.