In today’s post we’ll get going at getting SafeLine excellent WAF (Web Application Firewall) to agree at running on Rootless Docker setup.
Prerequisites#
- Docker installed in rootless mode (
dockerd-rootless-setuptool.sh install) - SafeLine CE
compose.yamland.envpresent -
sudoaccess for sysctl (one-time)
Setting up Docker in Rootless mode is a bit beyond the goal of that article, you’ll find all you need here.
Once this has been done, let’s get down at making SafeLine run on such a setup. In order to build your SafeLine setup, you’d need to do this by hands. That means that you’d need to download the docker-compose file and create your own .env file.
That is what I did logged in as the docker running user:
mkdir -p /home/user/data/safeline/
cd /home/user/data/safeline/
wget "https://waf.chaitin.com/release/latest/compose.yaml"
touch ".env"
cat > /home/user/data/safeline/.env << 'EOF'
SAFELINE_DIR=/home/user/data/safeline
IMAGE_TAG=latest
MGT_PORT=9443
POSTGRES_PASSWORD="<apassword>"
SUBNET_PREFIX=172.22.222
IMAGE_PREFIX=chaitin
ARCH_SUFFIX=
RELEASE=
REGION=-g
MGT_PROXY=0
EOF
Now comes a few identified issues, issues we will address further on:
Problem 1 — Ports 80/443 not binding on the host:#
In rootless Docker, network_mode: host does not mean the real host network. Containers land in the rootlesskit network namespace instead. As a result, nginx inside safeline-tengine binds to 80/443 correctly inside the container, but those ports are never exposed to the real host interface. Additionally, rootless Docker cannot bind privileged ports (< 1024) without a sysctl change.
Problem 2 — Real client IPs not visible to SafeLine (SNAT):#
Rootlesskit’s default port driver SNATs all incoming traffic before it reaches the container, so SafeLine/nginx sees the rootlesskit gateway IP instead of the real client IP. This breaks IP-based WAF features: block lists, rate limiting, geo-blocking and IP reputation rules all become ineffective. The fix is to switch the port driver to slirp4netns, which handles port forwarding at a lower level and preserves the original source IP.
Now let’s fix these issues:
Step 1 — Switch rootlesskit port driver to slirp4netns#
This is the most important step — it both enables privileged port binding and preserves real client IPs. With slirp4netns as the port driver, CAP_NET_BIND_SERVICE via setcap is no longer needed or effective; the sysctl approach (Step 2) is the only path for privileged ports.
Create the Docker daemon override file (under your docker user owner):
bashCopymkdir -p ~/.config/systemd/user/docker.service.d
cat > ~/.config/systemd/user/docker.service.d/override.conf << EOF
[Service]
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns"
Environment="DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=slirp4netns"
EOF
Reload and restart the Docker user daemon:
bashCopy
systemctl --user daemon-reload
systemctl --user stop docker
pkill rootlesskit # ensure full teardown
systemctl --user start docker
systemctl --user status docker
Verify the driver is active:
bashCopy
cat ~/.config/systemd/user/docker.service.d/override.conf
systemctl --user show docker | grep Environment
Step 2 — Lower the unprivileged port start on the host#
Required for binding ports 80/443 in rootless mode. With slirp4netns as the port driver, this is the only supported method — setcap cap_net_bind_service on rootlesskit does not work with the slirp4netns port driver.
# Temporary (verify first)
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
# Persistent (survives reboot)
echo "net.ipv4.ip_unprivileged_port_start=80" | sudo tee /etc/sysctl.d/99-unprivileged-ports.conf
sudo sysctl --system
Verify:
bashCopy
sudo sysctl net.ipv4.ip_unprivileged_port_start
# Expected: net.ipv4.ip_unprivileged_port_start = 80
Step 3 — Fix the tengine service in compose.yaml#
Why this is needed#
SafeLine’s default compose.yaml uses network_mode: host for tengine with no explicit port mappings. In rootless Docker this means nginx binds inside the rootlesskit netns only — invisible to the real host.
The fix#
Edit compose.yaml. Find the tengine service and remove network_mode: host, replacing it with explicit port mappings and a network assignment:
tengine:
container_name: safeline-tengine
restart: always
image: ${IMAGE_PREFIX}/safeline-tengine${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
ports:
- "80:80"
- "443:443"
networks:
safeline-ce:
ipv4_address: ${SUBNET_PREFIX}.x # pick a free IP — see note below
volumes:
# ... unchanged ...
environment:
# ... unchanged ...
ulimits:
nofile: 131072
# network_mode: host ← REMOVE this line
Finding a free IP: Check
.envforSUBNET_PREFIX, then review other containers'ipv4_addressentries incompose.yamlto pick an unused last octet.I went for this:
networks:
safeline-ce:
ipv4_address: ${SUBNET_PREFIX}.6
Remove any sysctls block from tengine (if present)#
If your compose file has this under tengine, remove it — it is not permitted with explicit port mappings:
yamlCopy# REMOVE if present:
sysctls:
- net.ipv4.ip_unprivileged_port_start=0
Step 4 — Bring SafeLine up#
bashCopycd /path/to/safeline
docker compose up -d
docker compose ps
Expected state — all containers Up:
safeline-tengine Up
safeline-mgt Up
safeline-detector Up
safeline-pg Up
safeline-chaos Up
safeline-fvm Up
safeline-luigi Up
Step 5 — Verify port binding on the host#
bashCopyss -tlnp | grep -E ':80|:443|:9443'
Expected — slirp4netns owning all three ports:
LISTEN 0 1 0.0.0.0:80 0.0.0.0:* users:(("slirp4netns",...))
LISTEN 0 1 0.0.0.0:443 0.0.0.0:* users:(("slirp4netns",...))
LISTEN 0 1 0.0.0.0:9443 0.0.0.0:* users:(("slirp4netns",...))
You can also verify nginx is listening inside the container via /proc:
bashCopy
docker exec safeline-tengine cat /proc/1/net/tcp | awk '{print $2}' | grep -E "^00000000:(0050|01BB)"
# 0x0050 = port 80, 0x01BB = port 443
Step 6 — Configure upstream applications#
Connecting tengine to an external app network (optional)#
If your upstream apps live in a separate Docker compose stack, attach tengine to their network:
yamlCopy# In SafeLine compose.yaml
services:
tengine:
networks:
safeline-ce:
ipv4_address: ${SUBNET_PREFIX}.x
your-app-network: # join the upstream network
aliases:
- safeline-tengine
# Bottom of compose.yaml
networks:
safeline-ce:
external: false
your-app-network:
external: true
name: actual_docker_network_name # from: docker network ls
Find the network name:
bashCopy
docker network ls
docker inspect <upstream-container> | grep -A 5 Networks
Adding a site in SafeLine UI#
- Browse to
https://<host>:9443 - Add your upstream app (IP:port or container name — see note below)
- SafeLine generates nginx vhost configs in
/etc/nginx/sites-enabled/IF_backend_* - nginx reloads automatically
Upstream addressing#
| Method | Status | Notes |
|---|---|---|
172.1x.x.x:PORT (static IP) |
✅ | Reliable if IPs are statically assigned in compose, I went for this |
container_name:PORT |
❌ | SafeLine UI accepts it although nginx validation fails |
Step 7 — Securing the SafeLine Admin Console on TCP:9443#
⚠️ Obviously, securing any external access toward port TCP:9443 is highly recommended, I did that through UFW rules on the host itself, thus allowing inbound connectivity to TCP:9443 for tolerated IP stacks only.
That’s it, you can now enjoy your Rootless SafeLine setup !
Hope this helps,
obuno
Originally published at Synack
https://blog.synack.li/posts/safeline-on-rootless-docker
If there are any copyright concerns, please contact me for removal.
Top comments (0)