SafeLine WAF running on Rootless Docker

posted 5 min read

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.yaml and .env present
  • sudo access 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=""
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):

bashCopy

mkdir -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.

bashCopy

# 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:

yamlCopy

  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 .env for SUBNET_PREFIX, then review other containers' ipv4_address entries in compose.yaml to 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#

bashCopy

cd /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#

bashCopy

ss -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  | grep -A 5 Networks

Adding a site in SafeLine UI#

  1. Browse to https://:9443
  2. Add your upstream app (IP:port or container name — see note below)
  3. SafeLine generates nginx vhost configs in /etc/nginx/sites-enabled/IF_backend_*
  4. 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/) https://blog.synack.li/posts/safeline-on-rootless-docker If there are any copyright concerns, please contact me for removal.

More Posts

A Developer’s Look at SafeLine: Running a Modern WAF Without the Cloud

Joe Swift - Jan 14

Manufacturing Company Secures Industrial Web Systems on Windows Server with SafeLine WAF

Joe Swift - Dec 23, 2025

How a Small IT Team Secured IIS on Windows Server with SafeLine WAF

Joe Swift - Dec 24, 2025

Three Months with SafeLine WAF: Why It Claims “No Rules Needed” for Injection Defense

MorphyBishop - Apr 13

AWS WAF vs. Cloudflare WAF vs. SafeLine WAF: A Practical Comparison for Real-World Deployments

MorphyBishop - Mar 25
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!