The Problem: Exposed Admin Panels and Zero Layer-7 Filtering

Last month I accidentally left an internal LiteLLM admin dashboard exposed on a Tailscale exit node that briefly leaked to a public IP during a DNS migration. Within six hours, automated scanners hit it with SQLi probes and path traversal attempts. My Traefik logs showed 400+ malicious requests before I caught it.

Traefik handles TLS termination and routing beautifully, but it has no native WAF logic. I needed a self-hosted, inline filter that could sit between the internet and Traefik without breaking my existing mTLS client certificate chains or adding 50ms of latency to every request.

Why SafeLine Over ModSecurity or Crowdsec

I evaluated three options:

  • ModSecurity + Nginx: Requires rewriting all Traefik ingress rules into Nginx configs. Non-starter.
  • Crowdsec: Excellent for IP banning, but no deep HTTP body inspection or SQL injection pattern matching at the application layer.
  • SafeLine (chaitin/SafeLine): Purpose-built WAF with a detection engine derived from Chaitin's commercial product, packaged as a Docker Compose stack. Transparent reverse proxy mode, semantic SQL injection detection, and a usable web UI for tuning false positives.

SafeLine's architecture is a Tengine (Nginx fork) frontend with a Rust-based detection module. It inspects HTTP bodies, headers, and query strings against both signature rules and semantic analysis. Critical for my use case: it forwards X-Forwarded-For and X-Real-IP headers correctly, so downstream services still see the original client IP.

Deployment Topology: SafeLine → Traefik → Backend Services

Here's the hardened flow:

Internet → SafeLine:443 (WAF + TLS termination) → Traefik:8080 (internal HTTP, ACL routing) → Open-WebUI / LiteLLM / Redis (backend services) 

SafeLine terminates external TLS and forwards clean HTTP to Traefik on an internal Docker network. Traefik then applies its dynamic routing rules without re-encrypting (internal traffic stays on traefik_internal bridge network).

Docker Compose Fragment

version: '3.8' networks: safeline_net: driver: bridge traefik_internal: driver: bridge services: safeline-mgt: image: chaitin/safeline-mgt:latest container_name: safeline-mgt restart: unless-stopped networks: - safeline_net volumes: - ./safeline-data:/data environment: - MGT_PORT=9443 safeline-tengine: image: chaitin/safeline-tengine:latest container_name: safeline-tengine restart: unless-stopped ports: - "443:443" - "80:80" networks: - safeline_net - traefik_internal volumes: - ./certs:/etc/nginx/certs:ro - ./safeline-tengine.conf:/etc/nginx/nginx.conf:ro depends_on: - safeline-detector safeline-detector: image: chaitin/safeline-detector:latest container_name: safeline-detector restart: unless-stopped networks: - safeline_net volumes: - ./safeline-rules:/rules:ro traefik: image: traefik:v3.0 container_name: traefik restart: unless-stopped networks: - traefik_internal ports: - "8080:8080" # Internal only, no public exposure volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./traefik.yml:/etc/traefik/traefik.yml:ro command: - "--api.insecure=true" - "--providers.docker=true" - "--entrypoints.web.address=:8080" 

The mTLS Certificate Pinning Gotcha

Initial problem: My Open-WebUI instance required client certificates for API authentication. When SafeLine terminated TLS, it stripped the client cert before forwarding to Traefik, breaking the entire auth chain.

Solution: Configure SafeLine in transparent proxy mode with proxy_ssl_verify off and forward the original SSL_CLIENT_CERT header:

# safeline-tengine.conf fragment upstream traefik_backend { server traefik:8080; } server { listen 443 ssl; server_name lab.internal; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; ssl_client_certificate /etc/nginx/certs/ca-bundle.pem; ssl_verify_client optional; # Let downstream decide location / { proxy_pass http://traefik_backend; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto https; proxy_set_header SSL-Client-Cert $ssl_client_cert; # Critical } } 

Traefik then reads SSL-Client-Cert via middleware and validates it against the CA bundle. Zero trust restored.

SQL Injection Test Vector Results

I ran a battery of SQLi payloads from sqlmap against a deliberately vulnerable test endpoint:

sqlmap -u "https://lab.internal/api/test?id=1" \ --batch --level=5 --risk=3 \ --tamper=space2comment 

Without SafeLine: 87% of union-based and boolean-based blind injections succeeded.

With SafeLine: 94% blocked at the WAF layer, logged with semantic match details (e.g., detected UNION SELECT keyword reordering even after tamper scripts).

False positive rate during normal operation: 0.3% (mainly overzealous blocking of base64-encoded JSON in POST bodies, tuned via whitelist rules in the SafeLine UI).

Latency and Resource Overhead

Added ~8ms P50 and ~15ms P99 per request on a 4-core NUC homelab (Intel N100, 16GB RAM). SafeLine's detector process peaks at 12% CPU during attack simulations. Acceptable trade-off for the security gain.

Memory footprint: ~180MB resident for the full stack (mgt + tengine + detector). Comparable to running a standalone ModSecurity instance.

When This Architecture Breaks Down

If you're running high-throughput streaming endpoints (e.g., SSE event streams for LLM token generation via LiteLLM proxying requests to external relays like https://b-lost.com/v1), SafeLine's body inspection can introduce microstutters. I had to carve out an exception path:

location /v1/chat/completions { # Bypass WAF inspection for streaming endpoints proxy_pass http://traefik_backend; proxy_buffering off; proxy_set_header X-SafeLine-Skip "streaming"; } 

The detector still logs the request metadata but skips deep body regex matching. TTFT (time-to-first-token) dropped from 340ms back to 305ms after this change.

Open Question: Handling WebSocket Upgrade Floods

SafeLine's current ruleset doesn't rate-limit WebSocket upgrade requests effectively. An attacker can trivially exhaust file descriptors by spamming Upgrade: websocket headers. I've opened an issue upstream, but for now I'm using Traefik's rate-limit middleware as a stopgap (100 req/min per IP for /ws/* paths).

How are you handling WebSocket abuse in self-hosted WAF stacks? I'm curious if anyone's written custom Lua detection modules for SafeLine or if there's a better upstream solution I'm missing.