Running a WAF on services I expose to the internet

I have a handful of services that anyone on the internet can reach. Most of them are small, but “small” doesn’t mean “uninteresting to a scanner.” Within an hour of pointing a domain at a box, it gets probed. Not by a human, by a bot. And the probes get creative in ways you don’t expect.

That’s what pushed me to put a web application firewall in front of my public endpoints. I went with CrowdSec, and this is why, and how.

Why a WAF at all

A firewall at the network layer (the kind most people already run) is good at blocking by IP and port. It’s not good at understanding what a request is actually trying to do. A request to /admin with a SQL injection in the query string looks, at the packet level, exactly like a normal request. The network firewall has no idea it’s a problem.

A WAF reads the request the way a web server would: method, path, headers, query string. And it applies rules that know what a .env access attempt or an UNION SELECT looks like. That’s the layer where most of the automated abuse actually lives, so it’s the layer where I want my defense.

There’s a second reason that’s harder to point to but I care about: I want to see it. A WAF in monitoring mode gives me a feed of what’s hitting my services, which is genuinely useful for knowing what the internet thinks it can find on my boxes.

Why CrowdSec

I looked at the usual options. Most commercial WAFs are either tied to a specific cloud or priced for companies, not for a home lab. CrowdSec is open source, runs in Docker, and does two things I wanted:

  1. It can sit in front of my reverse proxy and block bad requests before they reach the app.
  2. It keeps a record of decisions and alerts, so I can look back and see what it blocked and why.

It’s not a one-liner install, but it’s not a research project either. A day of setup gets you a working WAF.

How it fits together

The pieces, in plain terms:

  • The CrowdSec engine runs the detection rules. It watches traffic and decides what looks like an attack.
  • The bouncer is the bridge between the engine and my reverse proxy. The proxy asks the bouncer “should I let this through?” and the bouncer answers based on what the engine decided.
  • The dashboard is where I look at what’s happening. Blocked requests, active bans, which IPs are doing what.
  • My reverse proxy (Traefik, in my case) is where the bouncer plugs in, as a middleware on the routers I want protected.

That last part is the key design choice: I don’t put the WAF in front of everything. I attach it, per router, to the specific services I actually expose. Internal stuff stays untouched.

Installing it

The whole thing is Docker Compose. One project, three containers, and a bit of config. Here’s the full file, with the specifics (domains, auth middleware) swapped for your own:

name: crowdsec

services:
  crowdsec:
    image: crowdsecurity/crowdsec:v1.8.1
    container_name: crowdsec
    restart: unless-stopped
    environment:
      TZ: America/New_York
      # Auto-installs the WAF rule collections from the CrowdSec hub on
      # start (idempotent). Remove if you prefer manual `cscli collections install`.
      COLLECTIONS: crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
    volumes:
      # Whole config dir, rw so `cscli collections install` can persist the
      # hub files. Your files are the source of truth.
      - ./config:/etc/crowdsec
      # SQLite DB + hub-downloaded data. v1.8.x hard-exits at startup if
      # this is not a mounted volume, so it is mandatory.
      - ./data:/var/lib/crowdsec/data
    networks:
      - proxy
    healthcheck:
      # v1.8.x has no `cscli healthcheck`; `lapi status` verifies the engine
      # is up and LAPI is reachable.
      test: ["CMD", "cscli", "lapi", "status"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

  crowdsec-web-ui:
    image: ghcr.io/theduffman85/crowdsec-web-ui:latest
    container_name: crowdsec_web_ui
    restart: unless-stopped
    depends_on:
      crowdsec:
        condition: service_healthy
    environment:
      TZ: America/New_York
      CONFIG_INSTANCE_LAPI_URL: http://crowdsec:8080
      CONFIG_INSTANCE_LAPI_AUTH_USERNAME: crowdsec-web-ui
      CONFIG_INSTANCE_LAPI_AUTH_PASSWORD: ${CROWDSEC_WEB_UI_PASSWORD}
    volumes:
      - ./webui:/app/data
    networks:
      - proxy
    # Put the dashboard behind whatever auth you already use.
    labels:
      - traefik.enable=true
      - traefik.http.routers.crowdsec-web-ui.rule=Host(`crowdsec.example.com`)
      - traefik.http.routers.crowdsec-web-ui.entrypoints=websecure
      - traefik.http.routers.crowdsec-web-ui.tls.certresolver=letsencrypt
      - traefik.http.routers.crowdsec-web-ui.middlewares=your-auth@file
      - traefik.http.services.crowdsec-web-ui.loadbalancer.server.port=3000

  # Throwaway test target so we can verify the WAF actually blocks before
  # attaching it to real services. Remove this service once verified.
  whoami:
    image: traefik/whoami:latest
    container_name: crowdsec-whoami
    restart: unless-stopped
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.http.routers.crowdsec-whoami.rule=Host(`waf-test.example.com`)
      - traefik.http.routers.crowdsec-whoami.tls=true
      - traefik.http.routers.crowdsec-whoami.tls.certresolver=letsencrypt
      - traefik.http.routers.crowdsec-whoami.middlewares=crowdsec@file
      - traefik.http.services.crowdsec-whoami.loadbalancer.server.port=80

networks:
  proxy:
    external: true

Three things to notice. The engine has no published ports at all: everything talks to it over the shared Docker network, which is the whole point of keeping it inside the proxy network. The dashboard waits for the engine to be healthy before starting, so you don’t get a dashboard that can’t log in. And the whoami service is a throwaway target that exists only so you can prove the WAF blocks before you trust it with real services.

The whoami test target

That third service deserves a moment, because it’s the part people are most likely to delete or skip.

traefik/whoami is a tiny image whose entire job is to echo back the request it received: the host, the path, the headers, the source IP. It does nothing else. It’s the cheapest possible “real” web service you can put behind a router.

I point it at a throwaway domain (waf-test.example.com) and attach the CrowdSec middleware to that one router, exactly the way I’d attach it to a real service. That gives me a live endpoint I can shoot test traffic at without risking anything I actually care about.

Why not just test against a real service? Because the first thing you want to confirm is that the WAF blocks at all, and you want to confirm it in a place where a false positive costs you nothing. Once the whoami endpoint is returning 403s on bad requests and 200s on good ones, you know the middleware, the bouncer, and the engine are all wired correctly. Only then do you start attaching it to the routers that matter.

It’s a deliberate “test on a dummy before you test on the real thing” step. The whoami container is the dummy.

Everything else in this section is the config that lives in ./config. Here’s the order I’d do it in, because each step depends on the one before it.

1. The engine

The engine is the brain. It runs the detection rules and exposes two things: the LAPI (the API the bouncer and dashboard talk to) and the AppSec endpoint (the one the reverse proxy middleware talks to).

The two config files that matter:

  • config.yaml sets the LAPI address and the AppSec listener. The AppSec listener needs to bind to 0.0.0.0, not 127.0.0.1, because the reverse proxy middleware reaches it over the Docker network, not localhost. This is the single most common reason the middleware fails to connect, and it’s easy to miss.
  • appsec-configs/appsec-default.yaml controls which rules run and what happens when one fires. The default_remediation field is your block/monitor switch: set it to ban to block, allow to just log.

You’ll also want to keep the engine’s data directory on a host volume, not in the container. The rule cache and decision history live there, and you don’t want to lose them on a container rebuild.

2. The dashboard

The dashboard is a separate container that talks to the engine’s LAPI. It needs a username and password, which you generate with cscli rather than hand-typing. The dashboard authenticates against the engine and starts pulling alerts and decisions.

Nothing to configure here beyond the LAPI host, port, and credentials. It’s the easiest of the three.

3. The bouncer

The bouncer is the piece the reverse proxy actually talks to. You create it with cscli bouncers add and it gives you a key. That key is the secret the middleware uses to authenticate.

One thing worth doing here: create a dedicated bouncer for the reverse proxy, separate from the dashboard’s credentials. That way if one leaks, the other still works, and you can revoke one without breaking the other.

4. The reverse proxy middleware

This is the part that’s specific to whatever proxy you run. For Traefik, it’s a plugin. You declare the plugin in the static config, then define a middleware in the dynamic config that points at the engine’s AppSec endpoint with the bouncer key.

The middleware config looks roughly like this:

http:
  middlewares:
    crowdsec:
      plugin:
        crowdsec-bouncer:
          crowdsecMode: appsec
          crowdsecAppsecEnabled: true
          crowdsecAppsecHost: crowdsec:7422
          crowdsecAppsecScheme: http
          crowdsecLapiHost: crowdsec:8080
          crowdsecLapiScheme: http
          crowdsecLapiKey: "the-k...cli"

The hostnames here are Docker network names, not real DNS. crowdsec is the container name, 7422 is the AppSec port, 8080 is the LAPI port.

Then on each router you want protected, you add the middleware name to the middlewares list. One label, one line.

5. Test it

Don’t skip this. This is where the whoami target earns its place. Send a request that should be blocked, like a request to /.env on your waf-test domain, and check two things:

  1. The response is a 403, not a 200.
  2. The engine log shows a line like WAF block: crowdsecurity/vpatch-env-access from <ip>.

Both have to be true. A 403 with no log line means something else is blocking it. A 200 with a log line means the middleware is logging but not actually enforcing.

What usually goes wrong

A few things I hit, or that are common:

  • AppSec bound to 127.0.0.1. The middleware can’t reach it. Fix: bind to 0.0.0.0.
  • Wrong bouncer key in the middleware config. The middleware gets a 401 from AppSec and falls open, letting everything through. This is the dangerous one, because it fails silently. Check the engine log for “missing API key” errors.
  • Plugin not built yet. Traefik builds Go plugins on first load. The first request after a restart can be slow or fail while it compiles. Subsequent requests are fine.
  • Dashboard can’t reach LAPI. Usually a network name mismatch. The dashboard container needs to resolve the engine’s container name over the shared Docker network.

What “attaching it” looks like

On each service I want protected, I add one label to the router: the name of the CrowdSec middleware. That’s it. The request path becomes:

client -> reverse proxy -> [CrowdSec middleware] -> my service

If the middleware decides the request is bad, it returns a 403 and the request never reaches the app. If it’s fine, it passes through and I never notice.

A note on mode

CrowdSec can run in two modes:

  • Block mode actually returns 403s and bans IPs.
  • Monitor mode logs and alerts but lets everything through.

I’d start in monitor mode. Let it run for a few days, watch what it flags, and make sure it’s not blocking legitimate traffic. Then flip it to block. A WAF that blocks your own users is worse than no WAF at all.

What it’s not

It’s not a replacement for patching your software. A WAF stops the automated probes and the known attack patterns, but it can’t fix a vulnerability your app has. It’s a layer, not a substitute. And it’s not zero-config: the rules are community-maintained, and the set that ships covers a lot but not everything.

For what it is, though, it’s the right tool for the job. It sits exactly where the traffic is, it’s open source, it’s self-hosted, and it gives me a log I can actually read. That’s more than I had before, and it’s the difference between “something is probably probing me” and “here’s exactly what it was probing for.”