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
launchessystemd-socket-proxyd
- systemd passes the socket file descriptor (FD) to
proxyd
proxyd
reads the connection and forwards it to127.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
Component | Role |
---|---|
.socket units | Bind ports 80/443 using systemd (root-owned) |
.service units | Start systemd-socket-proxyd when needed |
systemd-socket-proxyd | Forwards TCP traffic to Traefik inside Docker |
Traefik (rootless) | Listens on 8080/8443 inside an unprivileged container |
Request Lifecycle
-
A client hits
http://<your-host>
orhttps://<your-host>
-
systemd (via
.socket
) receives the TCP connection on port 80 or 443 -
systemd activates the paired
.service
(if not running) -
The
.service
runs:/lib/systemd/systemd-socket-proxyd 127.0.0.1:8080
-
systemd-socket-proxyd
forwards the TCP stream to127.0.0.1:8080
(or 8443) -
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:
Keyword | Description |
---|---|
routers | Match incoming requests by host, path, method, etc. |
services | Define where to forward matched requests (e.g. a container) |
middlewares | Modify requests/responses (e.g. strip prefix, auth, headers) |
tls | Configure TLS (certs, options, resolvers) |
tls.options | Set TLS versions, ciphers, SNI strategies |
tls.certificates | Manually 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 thehello
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.