A text field on some random website. You type a single quote, add four words, and almost by magic the database spits out every user along with their password. You haven't logged into the server and you don't have credentials: you've just fired a SQL injection at a form.
In this article we'll set up a WAF (Web Application Firewall) for free in Docker, in front of whatever you have exposed to the internet —a VPS, your homelab, that container you spun up "just to test something"— and we'll watch that exact same attack stop working. I'll be using the free edition of SafeLine, and this post walks you through the whole process step by step.
This post comes with a video on my channel, if you'd rather see it in action (in Spanish): Monta tu propio WAF GRATIS y bloquea ataques reales (SafeLine en Docker)
Disclosure: this post is sponsored by SafeLine (CyberServal). Everything in it uses the free edition, and whenever something is paid I say so clearly.
⚠️ Your own lab only. Every attack in this guide targets DVWA, a deliberately vulnerable application running on your own machine. Launching them against systems you don't own, without written authorization, is a crime.
Why should you care?
If you've only got "a couple of things running on a VPS", you might think nobody will bother with you. Two numbers to change your mind:
- OWASP Top 10:2025: the Injection category (SQL, XSS, command injection...) is still in the top 5 (A05) and has more CVEs than any other: 62,445, with over 30,000 for XSS and over 14,000 for SQL injection.
- Verizon DBIR 2026: 31% of breaches start by exploiting a vulnerability. For the first time in 19 years, that beats credential theft.
Add to that what you already know: as soon as a service has a public IP, the automated scans begin. They're not attacking you; they're attacking everything that answers. Bots sweeping the internet, trying /wp-login.php on machines that don't even run WordPress.
What is a WAF (and how is it different from a firewall)?
A classic firewall works at layers 3 and 4 of the OSI model: IPs and ports. It decides whether a packet can reach port 443, but it has no idea what the request actually says.
A WAF works at layer 7, the application layer. It reads the HTTP: the URL, parameters, headers, cookies and POST body. It cares about what you're saying, not just where you're saying it from.
Analogy: the firewall is the parking-lot gate attendant who only checks your license plate. The WAF is the nightclub bouncer who also looks inside your bag.
To read all of that, the WAF sits in front of your application as a reverse proxy:
- The client (or the attacker) sends the request to the WAF, not to your app.
- The WAF analyzes it.
- If it's legitimate, the WAF forwards it to your application (the upstream).
- If it's an attack, it returns a block page and logs it. Your application never even knows the request existed.
How does a WAF decide what's an attack?
This is where WAFs really differ.
The classic model: rules and regular expressions
For over fifteen years, the de facto standard has been ModSecurity with the OWASP Core Rule Set (CRS): a huge catalog of regular expressions and signatures. If it sees union followed by select, it smells like SQL injection and blocks it. It's fast, transparent, and it protects half the internet.
But it has two problems that anyone who has run one knows well:
-
Evasion. Attackers don't break the WAF; they work around the pattern. For years, dropping an empty comment in the middle (
union/**/select) was enough to stop the regex from matching. To be fair, modern CRS v4 does catch that particular trick, but the race goes on: nested encodings, mixed case, alternative syntax... Every new variant needs a new rule. - False positives. The example from SafeLine's own docs is perfect: "the union select members from each department". That's ordinary English that a customer could type into your contact form, and a rule-based WAF blocks it as SQL injection. And there you are at 11 p.m., disabling rules by hand.
SafeLine's approach: understand the sentence, don't hunt for keywords
SafeLine doesn't look for keywords. For every parameter, it:
- Decodes it recursively, peeling off layers of obfuscation (double URL encoding, HTML entities...) until it reaches the real content.
- Asks whether that content is syntactically valid in some language: SQL, HTML, JavaScript...
- If it is, checks whether it has malicious intent or is harmless.
- Scores it and decides: block or allow.
The result? A UNION SELECT hidden under two layers of encoding turns back into the same old SQL once decoded, so it gets blocked. And the sentence about departments isn't valid SQL, so it goes through without bothering anyone.
Heads-up, this applies to any WAF: it doesn't fix your bug. If your code concatenates strings into a query, it's still vulnerable tomorrow. The real defense is parameterized queries, validation and output encoding. A WAF is one more layer of defense in depth, not a cure.
What we're going to build
SafeLine is made of 7 containers, but you only need two to understand it:
| Container | What it does |
|---|---|
safeline-tengine |
The reverse proxy (based on Tengine, an Nginx fork). It receives all the traffic and runs with network_mode: host. |
safeline-detector |
The detection engine. It analyzes each request and says yes or no. |
safeline-mgt |
The web management console (port 9443). |
safeline-pg, -fvm, -luigi, -chaos
|
Database, rule management, statistics and anti-bot. |
Requirements
They're pretty modest:
- Linux on x86_64 with the SSSE3 CPU instruction.
- Docker ≥ 20.10.14 and Docker Compose v2.
- At least 1 core, 1 GB of RAM and 5 GB of disk (2 cores and 2 GB recommended).
- Ports 80 and 443 free on the host (the proxy uses them directly).
⚠️ Raspberry Pi or an Apple Silicon Mac? The free edition is not supported on ARM; you need the Pro license for that. Better to know now than to lose an afternoon.
A note on licensing, because someone will ask: SafeLine's repository is GPL-3.0, but the detection engine ships as a proprietary binary. Keep that in mind if auditable code is a requirement for you.
Step 1: the victim (DVWA)
We need something to attack. DVWA (Damn Vulnerable Web Application) is an app built to be vulnerable on purpose. This compose.yaml publishes it only on 127.0.0.1:4280, so it isn't exposed and SafeLine can use it as the upstream:
services:
dvwa:
image: ghcr.io/digininja/dvwa:latest
restart: unless-stopped
environment:
- DB_SERVER=db
- DEFAULT_SECURITY_LEVEL=low
depends_on:
- db
ports:
- "127.0.0.1:4280:80" # never 0.0.0.0 on a host with a public IP
db:
image: docker.io/library/mariadb:10
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=dvwa
- MYSQL_DATABASE=dvwa
- MYSQL_USER=dvwa
- MYSQL_PASSWORD=p@ssw0rd
docker compose up -d
Open http://localhost:4280 (or use an SSH tunnel if it's on a remote server), log in with admin / password and click Create / Reset Database. Make sure DVWA Security is set to Low; otherwise the attacks won't get through.
Step 2: the attack, without a WAF
In the SQL Injection module, type this into the id field:
1' UNION SELECT user, password FROM users -- -
DVWA hands you every user along with their password hash. In the XSS (Reflected) module, try this in the name field:
<script>alert('XSS')</script>
And the alert pops up in your browser. Notice how little effort that took. Now let's fix it.
Step 3: install SafeLine with Docker Compose
There's an official one-line installer that does everything for you, but it downloads with curl -k (no TLS certificate verification) and runs as root. I prefer the manual route, which also lets you see what's going on:
mkdir -p /data/safeline && cd /data/safeline
wget https://waf.chaitin.com/release/latest/compose.yaml
Create an .env file next to it:
SAFELINE_DIR=/data/safeline
IMAGE_TAG=latest
# Console on loopback only (see "Don't expose the console" below)
MGT_PORT=127.0.0.1:9443
# Letters and digits ONLY: the installer validates it
POSTGRES_PASSWORD=ChangeMeLettersAndDigitsOnly123
SUBNET_PREFIX=172.22.222
IMAGE_PREFIX=chaitin
ARCH_SUFFIX=
RELEASE=
REGION=-g
MGT_PROXY=0
A few notes on the .env:
-
REGION=-ginstalls the international version. - An empty
RELEASE=uses the stable line. Don't use LTS, which has been frozen since 9.1.0. - Any odd symbol in
POSTGRES_PASSWORDbreaks the startup.
And bring it up:
docker compose up -d
docker ps # you should see 7 safeline-* containers
The first run takes a few minutes because it pulls quite a few images.
Step 4: log into the console (without exposing it)
The admin password isn't stored in any file. You generate it with this command, which shows it only once (and it's the same one that will save your day when you lose it three months from now):
docker exec safeline-mgt resetadmin
The console lives on port 9443 over HTTPS with a self-signed certificate, so your browser will make a face at you. That's normal. On first login it will ask you to set up a second factor (TOTP).
Don't expose the console
Port 9443 must not be open to the internet. And watch out for the classic trap: ufw does not protect ports published by Docker. Docker opens them with DNAT rules in nat/PREROUTING, before the INPUT chain where ufw operates, so ufw deny 9443 does nothing at all.
The reliable approach is the one we already put in the .env: publish the console only on 127.0.0.1 and reach it through an SSH tunnel:
ssh -L 9443:127.0.0.1:9443 user@your-server
# then in your browser: https://localhost:9443
A VPN or Tailscale works just as well. The point is that your WAF's own console shouldn't become an attack surface.
Step 5: protect your application
In the console: Applications → Add Application. There are three fields:
| Field | What to enter | In the lab |
|---|---|---|
| Domain | The domain or IP SafeLine will serve | The server IP or dvwa.yourdomain.com
|
| Port | The public port |
80 (or 443 with your certificate) |
| Upstream | Where your app actually lives | http://127.0.0.1:4280 |
A networking detail that can drive you mad: the proxy runs with
network_mode: host. If your application lives on its own Docker network without a published port, SafeLine won't find it by service name. The easiest fix is to publish it on127.0.0.1(like DVWA does) and point there.
Step 6: repeat the attack, now with the WAF
Open DVWA through SafeLine (port 80, not 4280) and repeat exactly the same attacks. This time, instead of the user table, you get the block page. In the console, under Attack Logs, you'll see every attempt with the payload, attack type and source IP.
Also try an obfuscated version with double URL encoding, the kind a simple signature engine doesn't always decode all the way down:
1%2527%2520UNION%2520SELECT%25201,2--%2520-
Blocked as well. Not because there's a rule for that specific payload, but because SafeLine decodes it and reads it again for what it really is. That's the difference between hunting for words and understanding the sentence.
If you don't want to use DVWA, SafeLine's own docs suggest these quick tests against any protected site (that you own):
http://YOUR_SITE/?id=1+and+1=2+union+select+1
http://YOUR_SITE/?id=<img+src=x+onerror=alert()>
http://YOUR_SITE/?id=../../../../etc/passwd
The key point: we haven't changed a single line of DVWA's code. The app is just as vulnerable as before; the only difference is that something now sits in front of it, watching the traffic.
Before you put it in front of something serious
A badly configured WAF is a great way to take down your own site. Some recommendations:
- Don't roll it out on a Friday afternoon. Test it first with a copy or a staging domain.
- Let real traffic through for a couple of days and review the Attack Logs. If your own users show up doing normal things, those are false positives and you need to tune.
- It's a single point of failure. All traffic goes through it: if the WAF goes down, your site stops responding.
- It needs to see traffic in the clear. For HTTPS, certificates are now managed in SafeLine, which terminates TLS.
- It doesn't cover business logic. No WAF will stop broken access control (A01 in the OWASP Top 10).
What's free and what's paid?
Everything in this guide is the Community (Personal) edition: free forever, for up to 10 applications. And it isn't a toy version:
- The same semantic engine as the paid editions.
- Protection against web attacks (SQLi, XSS, RCE...).
- Rate limiting against HTTP floods.
- Anti-bot with CAPTCHA.
- A login screen in front of apps that have no authentication of their own, which is wonderful for a homelab.
What you get by paying, and I think these are real differences rather than cosmetic ones:
- Performance: Community runs single-threaded; the vendor's documentation puts it at around 800 requests per second. You won't notice in a homelab; with serious traffic, you will.
- Real production features: config sync across multiple nodes, upstream load balancing, and multiple admin users.
- ARM: Pro license only.
Measure it yourself
Instead of trusting vendor benchmarks, you can run BlazeHTTP, their open source testing tool, against your own protected site:
docker run --rm --net=host chaitin/blazehttp:latest \
/app/blazehttp -t http://YOUR_PROTECTED_SITE
Whatever it reports is a measurement of your environment, which is exactly what you care about.
Takeaways
- Putting a WAF in front of what you expose is no longer just for companies with a budget: one
docker compose, fifteen minutes and zero euros. - A layer 7 WAF reads the content of requests. Rule-based WAFs look for patterns; SafeLine decodes and analyzes syntax, which cuts down on both evasions and false positives.
- It doesn't fix your code and won't save you from everything. But it removes the background noise from bots and buys you time when the next vulnerability drops in that app you haven't updated in six months. You know the one. We both know you have one.
- Protect the management console: loopback + SSH tunnel, because
ufwwon't cover you with Docker.
What do you have exposed right now with nothing in front of it?
Further reading
- SafeLine on GitHub
- Official docs: deployment
- Official docs: adding an application
- How SafeLine's syntax analysis works
- Edition comparison (Plan)
- DVWA on GitHub
- OWASP Top 10:2025 — A05 Injection
- OWASP Core Rule Set
- BlazeHTTP
Originally published at pabpereza.dev. The companion video (in Spanish) is on YouTube.
Top comments (0)