15 min read

CrowdSec & Fail2Ban: Real-Time Threat Defense for SWAG

Stop brute-force attacks and secure your self-hosted apps. Learn to deploy harden SWAG with CrowdSec for an A+ security rating and Zero Trust access.
A picture showing on the left side a real castle, and on the right it's being 'digitized' and in cyberspace with blue and purple accents and grids.
Here, you're building your real & digitized castle.

Throughout my 20+ years in IT, I've consistently advocated for a layered and dynamic security approach โ€“ a strategy where defences are built in parallel and depth, creating a robust and resilient system. Think of your public IP address as the drawbridge to your digital castle. It's the link to the outside world and as such, needs to be keenly protected.

In Part 2, we got a bare SWAG container running: valid certs, one app proxied, nothing fancy. That's a working reverse proxy, but it's not yet a secure one. Today we fix that: harden the TLS and header configuration to earn an A+ SSL Labs score, arm Fail2Ban's bot traps properly, and bring CrowdSec online as real-time, crowd-sourced threat intelligence.

๐Ÿ”น Hardening TL;DR

  • SWAG = entry point (done, Part 2)
  • Fail2Ban & CrowdSec = threat blocking (today)
  • Authelia = identity (next post)
We aren't just opening ports; we're building a siege-ready fortress.
LEARNING PATH

Mastering Reverse Proxies with SWAG

Learn the concepts, deploy SWAG, secure your services, understand the request flow, and confidently migrate from other reverse proxy solutions.

Loading...

Difficulty Path
Beginner โ†’ Advanced

๐Ÿฐ What is the "Digital Castle" Architecture?

We are moving beyond simple "Firewalls." We are building Defence in Depth.

  • Layer 1 (The Moat): Your Router/Firewall (OPNsense/Unifi) blocking all ports except 443. Already in place. If not, see my complete OPNsense deployment series!
  • Layer 2 (The Traps): Fail2Ban (Integrated into SWAG). The classic "Three strikes and you're out" defender. Today.
  • Layer 3 (The Watchman): CrowdSec reads the logs. If an IP acts suspicious, it's banned instantly - and shared with the global community. Today.
  • Layer 4 (The Gatekeeper): Authelia intercepts valid traffic and demands your 2FA token. Next post.

Part 1: Implementing CrowdSec for Real-Time Threat Intelligence๐Ÿ›ก๏ธ

Prerequisite Steps - Building on your existing part 2 Deployment

This post assumes you've completed Part 2: Installing SWAG & Publishing Your First Service - a working SWAG container with a valid wildcard certificate and at least one app proxied. Everything below extends that same compose file. โœ…

We're not reinstalling anything, we're extending the same docker-compose.yml with one new service, CrowdSec. Fail2Ban needs no new container at all; it's already built into SWAG, we're just switching on and tuning its additional jails.

New Folder for CrowdSec

You already have a swag/ folder from Part 2. Add one more alongside it:

your-existing-project/
โ”œโ”€โ”€ docker-compose.yml   <- the same file from Part 2, we're extending it
โ”œโ”€โ”€ .env                 <- new โ€” for secrets like your CrowdSec API key
โ”œโ”€โ”€ swag/                <- already exists from Part 2
โ””โ”€โ”€ crowdsec/            <- new

๐Ÿ‘‰ Don't forget to set permissions on the new folder, with whatever user you decided to run crowdsec container with. Same one was SWAG maybe? chown -R 1000:1000 /yourpath/to/crowdsec and chmod 644 /yourpath/to/crowdsec as well.

๐ŸŽฏ
If you're running SWAG on macvlan per Part 2's network mode section, give CrowdSec a static IP on that same network so it can see real client IPs. If you're on the default bridge networking from Part 2, skip this - Docker's internal DNS already handles container-to-container communication by name.

Adding CrowdSec to Your Compose File

Add these lines to your existing swag service's environment: block:

      - DOCKER_MODS=linuxserver/mods:swag-crowdsec  # append with a | if you already have other mods, e.g. linuxserver/mods:swag-dashboard|linuxserver/mods:swag-crowdsec
      - CROWDSEC_API_KEY=${CROWDSEC_API_KEY}
      - CROWDSEC_LAPI_URL=http://crowdsec:8080

Then add the new crowdsec service alongside it:

crowdsec:
    container_name: crowdsec
    image: crowdsecurity/crowdsec:latest
    depends_on:
      - swag
    environment:
      - COLLECTIONS=crowdsecurity/nginx crowdsecurity/base-http-scenarios crowdsecurity/appsec-virtual-patching crowdsecurity/whitelist-good-actors
      - CUSTOM_HOSTNAME=crowdsec
      - PID=1000
      - GID=1000
      - BOUNCER_KEY_SWAG=${CROWDSEC_API_KEY}
    volumes:
      - /yourpath/DOCKERS/swag/log/nginx:/var/log/swag:ro
      - /yourpath/DOCKERS/crowdsec/data:/var/lib/crowdsec/data:rw
      - /yourpath/DOCKERS/crowdsec/config:/etc/crowdsec:rw
      - /var/log:/var/log/host:ro
    security_opt:
      - no-new-privileges=true
    restart: unless-stopped

CROWDSEC_API_KEY is a variable, not a literal value - it's pulled from a .env file, not hardcoded into the compose file. We generate the actual key further down.

Starting CrowdSec Up

  1. Start the stack once - docker compose up -d - just to let CrowdSec generate its default folders, then docker compose down.
  2. Edit acquis.yaml to add the proper log sources (shown below).

CrowdSec Bouncer Setup / Bouncer API Key

This key is what lets SWAG (the bouncer) actually execute the bans CrowdSec (the brain) decides on:

docker compose up -d
docker exec -t crowdsec cscli bouncers add swag
๐Ÿšง
STOP: Copy the API Key immediately. You cannot see it again. Paste it into notepad++ or whatever you can paste it in. Now you need to edit the .env file
# .env
CROWDSEC_API_KEY=the_key_you_just_copied

This is why the compose file references ${CROWDSEC_API_KEY} instead of a literal string - Docker Compose pulls it from .env automatically, keeping the secret out of a file you might paste into a support forum or commit to git by accident.

Telling CrowdSec What to Read

Edit crowdsec/acquis.yaml which is buried wherever you put crowdsec, nano /opt/DOCKERS/crowdsec/acquis.yml as an example:

filenames:
  - /var/log/swag/*
labels:
  type: nginx
---
filenames:
  - /var/log/auth.log*
  - /var/log/syslog
labels:
  type: syslog

The type: nginx label matters more than it looks - without it, CrowdSec has no idea which parser to apply to those logs, and it'll sit there technically running while catching nothing.

Confirmation & Testing CrowdSec Function

Now you can drop yourself into the crowdsec docker to check things out quickly. Type: docker exec -it crowdsec /bin/sh

You'll get dropped into the crowdsec's shell. This means the container is running and alive. You can try cscli metrics and will see some output. This is good. Type exit until it drops you back to normal console/command prompt on your host again.

Now check their logs one at a time, CTRL-C to drop out.

docker logs -f swag or docker logs -f crowdsec and then CTRL-C to cancel.

Now you check to see if the bouncer is live, talking to the CrowdSec LAPI and all is ok!

docker exec -t crowdsec cscli bouncers list

You're looking for your SWAG entry with a checkmark and a recent timestamp under "Last API pull":

docker exec -t crowdsec cscli bouncers list
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 Name  IP Address  Valid  Last API pull         Type                    Version  Auth Type
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 SWAG  10.0.0.4    โœ”๏ธ     2026-04-12T03:06:26Z  crowdsec-nginx-bouncer  v1.1.3   api-key
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

So long as you see there's a DTG in the "Last API pull" and a check mark, you're GTG! โœ…

Output of docker exec -t crowdsec cscli metrics

โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Acquisition Metrics โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Source โ”‚ Lines read โ”‚ Lines parsed โ”‚ Lines unparsed โ”‚ Lines poured to bucket โ”‚ Lines whitelisted โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ : โ”‚ - โ”‚ - โ”‚ - โ”‚ - โ”‚ 202 โ”‚
โ”‚ file:/var/log/swag/access.log โ”‚ 67.98k โ”‚ 67.98k โ”‚ - โ”‚ 41.17k โ”‚ 6.75k โ”‚
โ”‚ file:/var/log/swag/error.log โ”‚ 24 โ”‚ 24 โ”‚ - โ”‚ 13 โ”‚ - โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Local API Alerts โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Reason โ”‚ Count โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ LePresidente/http-generic-401-bf โ”‚ 1 โ”‚
โ”‚ LePresidente/http-generic-403-bf โ”‚ 7 โ”‚
โ”‚ crowdsecurity/http-crawl-non_statics โ”‚ 1 โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Local API Decisions โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Reason โ”‚ Origin โ”‚ Action โ”‚ Count โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ http:bruteforce โ”‚ CAPI โ”‚ ban โ”‚ 24809 โ”‚
โ”‚ http:crawl โ”‚ CAPI โ”‚ ban โ”‚ 2554 โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Local API Metrics โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Route โ”‚ Method โ”‚ Hits โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ /v1/decisions โ”‚ GET โ”‚ 56288 โ”‚
โ”‚ /v1/heartbeat โ”‚ GET โ”‚ 5497 โ”‚
โ”‚ /v1/usage-metrics โ”‚ POST โ”‚ 550 โ”‚
โ”‚ /v1/watchers/login โ”‚ POST โ”‚ 94 โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Local API Bouncers Metrics โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Bouncer โ”‚ Route โ”‚ Method โ”‚ Hits โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ SWAG โ”‚ /v1/decisions โ”‚ GET โ”‚ 56287 โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Local API Bouncers Decisions โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Bouncer โ”‚ Empty answers โ”‚ Non-empty answers โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ SWAG โ”‚ 56287 โ”‚ 0 โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Local API Machines Metrics โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Machine โ”‚ Route โ”‚ Method โ”‚ Hits โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ crowdsec โ”‚ /v1/heartbeat โ”‚ GET โ”‚ 5497 โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Parser Metrics โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Parsers โ”‚ Hits โ”‚ Parsed โ”‚ Unparsed โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ child-crowdsecurity/http-logs โ”‚ 204.01k โ”‚ 141.45k โ”‚ 62.55k โ”‚
โ”‚ child-crowdsecurity/nginx-logs โ”‚ 68.03k โ”‚ 68.00k โ”‚ 24 โ”‚
โ”‚ crowdsecurity/cdn-whitelist โ”‚ 202 โ”‚ 202 โ”‚ - โ”‚
โ”‚ crowdsecurity/dateparse-enrich โ”‚ 68.00k โ”‚ 68.00k โ”‚ - โ”‚
โ”‚ crowdsecurity/geoip-enrich โ”‚ 61.25k โ”‚ 61.25k โ”‚ - โ”‚
โ”‚ crowdsecurity/http-logs โ”‚ 68.00k โ”‚ 68.00k โ”‚ - โ”‚
โ”‚ crowdsecurity/nginx-logs โ”‚ 68.00k โ”‚ 68.00k โ”‚ - โ”‚
โ”‚ crowdsecurity/non-syslog โ”‚ 68.00k โ”‚ 68.00k โ”‚ - โ”‚
โ”‚ crowdsecurity/overseerr-whitelist โ”‚ 68.00k โ”‚ 68.00k โ”‚ - โ”‚
โ”‚ crowdsecurity/public-dns-allowlist โ”‚ 68.00k โ”‚ 68.00k โ”‚ - โ”‚
โ”‚ crowdsecurity/rdns โ”‚ 202 โ”‚ 202 โ”‚ - โ”‚
โ”‚ crowdsecurity/seo-bots-whitelist โ”‚ 202 โ”‚ 202 โ”‚ - โ”‚
โ”‚ crowdsecurity/whitelists โ”‚ 68.00k โ”‚ 68.00k โ”‚ - โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Scenario Metrics โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Scenario โ”‚ Current Count โ”‚ Overflows โ”‚ Instantiated โ”‚ Poured โ”‚ Expired โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ LePresidente/http-generic-401-bf โ”‚ - โ”‚ - โ”‚ 12 โ”‚ 12 โ”‚ 12 โ”‚
โ”‚ LePresidente/http-generic-403-bf โ”‚ - โ”‚ - โ”‚ 6 โ”‚ 6 โ”‚ 6 โ”‚
โ”‚ crowdsecurity/http-admin-interface-probing โ”‚ - โ”‚ 45 โ”‚ 407 โ”‚ 554 โ”‚ 362 โ”‚
โ”‚ crowdsecurity/http-backdoors-attempts โ”‚ - โ”‚ 11 โ”‚ 98 โ”‚ 109 โ”‚ 87 โ”‚
โ”‚ crowdsecurity/http-bad-user-agent โ”‚ - โ”‚ - โ”‚ 86 โ”‚ 86 โ”‚ 86 โ”‚
โ”‚ crowdsecurity/http-crawl-non_statics โ”‚ 2 โ”‚ - โ”‚ 24.79k โ”‚ 29.63k โ”‚ 24.79k โ”‚
โ”‚ crowdsecurity/http-path-traversal-probing โ”‚ - โ”‚ - โ”‚ 7 โ”‚ 8 โ”‚ 7 โ”‚
โ”‚ crowdsecurity/http-probing โ”‚ - โ”‚ 161 โ”‚ 4.53k โ”‚ 9.82k โ”‚ 4.37k โ”‚
โ”‚ crowdsecurity/http-sensitive-files โ”‚ - โ”‚ - โ”‚ 276 โ”‚ 288 โ”‚ 276 โ”‚
โ”‚ crowdsecurity/http-wordpress-scan โ”‚ - โ”‚ 95 โ”‚ 333 โ”‚ 675 โ”‚ 238 โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Whitelist Metrics โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Whitelist โ”‚ Reason โ”‚ Hits โ”‚ Whitelisted โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ crowdsecurity/cdn-whitelist โ”‚ CDN provider โ”‚ 202 โ”‚ 202 โ”‚
โ”‚ crowdsecurity/overseerr-whitelist โ”‚ Overseerr whitelist โ”‚ 68002 โ”‚ - โ”‚
โ”‚ crowdsecurity/public-dns-allowlist โ”‚ public DNS server โ”‚ 68002 โ”‚ - โ”‚
โ”‚ crowdsecurity/seo-bots-whitelist โ”‚ good bots (search engine crawlers) โ”‚ 202 โ”‚ - โ”‚
โ”‚ crowdsecurity/whitelists โ”‚ private ipv4/ipv6 ip/ranges โ”‚ 68002 โ”‚ 6752 โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

There's another post you can dig through once you've finished with this series, to really dig in๐Ÿ‘‰ and see if CrowdSec is blocking actively for you.

Why CrowdSec Beats Fail2Ban Alone

Fail2Ban blocks based on your own logs. CrowdSec shares and receives ban intelligence with every other CrowdSec user - if an IP got flagged attacking someone else's server an hour ago, it can be blocked on yours before it tries anything. This is crowd-sourced threat intelligence - the kind enterprise security hardware charges real money for.

Here's my complete SWAG & CrowdSec Compose for reference:

๐Ÿ“„ View Full YAML Configuration (Click to Expand)

networks:
  vlan7_home:
    driver: macvlan
    driver_opts:
      parent: enp40s0f1 # Use your actual parent interface
    ipam:
      config:
        - subnet: 10.0.0.0/24 # Any class C / RFC1918 range
          gateway: 10.0.0.1 # Gateway of this subnet/vlan/network
  backend:
    name: dockernet
    driver: bridge

### SWAG config begin
  swag:
    image: lscr.io/linuxserver/swag
    container_name: swag
    networks:
      docker_vlan:
        ipv4_address: X.X.X.X
    cap_add:
      - NET_ADMIN
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=America/Toronto
      - URL=corelab.tech
      - VALIDATION=dns #can be http or dns
      - SUBDOMAINS=wildcard #optional
#      - CERTPROVIDER= #optional
      - DNSPLUGIN=cloudflare
      - DOCKER_MODS=linuxserver/mods:swag-crowdsec|linuxserver/mods:swag-dashboard|linuxserver/mods:swag-cloudflare-real-ip
#      - PROPAGATION= #optional
#      - EMAIL= #optional
      - ONLY_SUBDOMAINS=false #optional, set as false will have swag fetch certs for my extra domains as well!
      - EXTRA_DOMAINS=corelab.tech #optional
#      - STAGING=false #optional
#      - DISABLE_F2B= #optional
#      - SWAG_AUTORELOAD= #optional
#      - SWAG_AUTORELOAD_WATCHLIST= #optional
      - CROWDSEC_API_KEY=${CROWDSEC_API_KEY} | OR put your API key here directly
      - CROWDSEC_LAPI_URL=http://crowdsec:8080 <--- Can be IP or name of your crowdsec below
    volumes:
      - /brain/DOCKERS/swag:/config
    ports:
      - 443:443 # Mandatory, required for functioning
      - 80:80 # Optional
      - 81:81 # If you enabled the dashboard mod above
    restart: unless-stopped

  crowdsec:
    container_name: crowdsec
    image: crowdsecurity/crowdsec:latest
    depends_on:
      - swag
    networks:
      docker_vlan:
        ipv4_address: X.X.X.X
    environment:
      - COLLECTIONS=crowdsecurity/nginx crowdsecurity/base-http-scenarios crowdsecurity/appsec-virtual-patching crowdsecurity/whitelist-good-actors
      - CUSTOM_HOSTNAME=crowdsec
      - PID=1000
      - GID=1000
      - BOUNCER_KEY_SWAG=${CROWDSEC_API_KEY} | Or API key
    volumes:
      - /yourpath/DOCKERS/swag/log/nginx:/var/log/swag:ro
      - /yourpath/DOCKERS/crowdsec/data:/var/lib/crowdsec/data:rw
      - /yourpath/DOCKERS/crowdsec/config:/etc/crowdsec:rw
      - /var/log:/var/log/host:ro
    security_opt:
      - no-new-privileges=true
    restart: unless-stopped
        


Digitized view of a futuristic server room with a screen showing an A+ on a Qualys SSL scanner result.
A+ with Qualys SSL scanner.

Part 2: SWAG Security Hardening - HSTS, Headers, and TLS 1.3

๐ŸŽฏ The Goal: HSTS & The A+ Rating

A Word of Caution Before You Touch HSTS

HSTS (HTTP Strict Transport Security) tells browsers "never talk to me over plain HTTP again, HTTPS only, period." That's exactly what you want - right up until your certificate fails to renew for some reason, at which point HSTS will lock you (and everyone else) out of your own domain until it's fixed.

๐Ÿ“ก
Tactical Warning: Only enable this if your SSL certificates (Certbot) are working perfectly. If they expire with HSTS active, you will be locked out of your own domain.

The Reward: It stops "Protocol Downgrade Attacks" dead in their tracks.

Keep this checklist handy for your periodic security audits. A secure Digital Fortress requires constant maintenance.
Keep this checklist handy for your periodic security audits. A secure Digital Fortress requires constant maintenance.

Step 1: Enable HSTS

Open /swag/config/nginx/ssl.conf. LinuxServer.io ships this commented out by default specifically to protect people from locking themselves out. Find the HSTS line and uncomment it:

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

max-age=63072000 is two years in seconds. Qualys SSL Labs only requires about six months (roughly 15.7 million seconds) for an A+, so this is comfortably over the bar.

Step 2: Harden the Response Headers

Still in ssl.conf, make sure these are active:

# Stop other sites from embedding yours (clickjacking protection)
add_header X-Frame-Options "SAMEORIGIN" always;

# Stop the browser from guessing MIME types
add_header X-Content-Type-Options "nosniff" always;

# Browser's built-in XSS filter
add_header X-XSS-Protection "1; mode=block" always;

# Referrer privacy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

None of these are exotic - they're the standard baseline any public-facing site should have, and SWAG makes them a one-line uncomment away instead of something you'd have to write from scratch. It seems over time what is enabled by default here has changed so you may have to uncomment or not!

Step 3: Drop Legacy TLS Versions

Still in the same file, find ssl_protocols and restrict it:

ssl_protocols TLSv1.2 TLSv1.3;

TLS 1.0 and 1.1 are old enough that keeping them enabled mainly just widens your attack surface for negligible compatibility benefit. TLS 1.2 is still considered secure by Qualys, so this is the safe default - if you know every device hitting your services supports 1.3, you can go further and drop 1.2 too, but that's a "know your audience" call, not a default recommendation. I believe all up to date browsers support TLS 1.3, but even still, this site supports TLS1.2 as well.

Step 4: Restart and Verify

docker compose up -d --force-recreate swag && docker logs -f swag

This re-creates the container and shows you a live running log of the startup so you can ensure everything fires back up ok. CTRL-C to cancel out.

Then run your domain through Qualys SSL Labs. With HSTS, the hardened headers, and legacy TLS disabled, you should land at A or A+.


Part 3: Arming Fail2Ban (The Bot Traps)

By default, Fail2Ban might only watch for SSH attempts. For a web server, attacks come via HTTP - password guessing, bots scanning for wp-login.php, and so on. SWAG ships pre-written jails for Nginx, but they're often disabled.

  1. Open your jail config: /swag/fail2ban/jail.local.
  2. Enable the core 5:
[nginx-http-auth]
enabled  = true
filter   = nginx-http-auth
port     = http,https
logpath  = /config/log/nginx/error.log

[nginx-badbots]
enabled  = true
filter   = nginx-badbots
port     = http,https
logpath  = /config/log/nginx/access.log

[nginx-botsearch]
enabled  = true
filter   = nginx-botsearch
port     = http,https
logpath  = /config/log/nginx/access.log

[nginx-deny]
enabled  = true
filter   = nginx-deny
port     = http,https
logpath  = /config/log/nginx/error.log

[nginx-unauthorized]
enabled  = true
filter   = nginx-unauthorized
port     = http,https
logpath  = /config/log/nginx/error.log

Full jail.local reference:

[DEFAULT]
ignoreip = 10.0.0.0/8
           192.168.0.0/16
           172.16.0.0/12
banaction = iptables-allports
bantime  = 1800
findtime  = 600
maxretry = 3

[ssh]
enabled = false

[nginx-http-auth]
enabled  = true
filter   = nginx-http-auth
port     = http,https
logpath  = /config/log/nginx/error.log

[nginx-badbots]
enabled  = true
port     = http,https
filter   = nginx-badbots
bantime  = 12h
logpath  = /config/log/nginx/access.log
maxretry = 2

[nginx-botsearch]
enabled  = true
port     = http,https
filter   = nginx-botsearch
logpath  = /config/log/nginx/access.log

[nginx-deny]
enabled  = true
port     = http,https
filter   = nginx-deny
logpath  = /config/log/nginx/error.log

[nginx-unauthorized]
enabled  = true
port     = http,https
filter   = nginx-unauthorized
logpath  = /config/log/nginx/access.log
maxretry = 6

[nginx-limit-req]
enabled = true
port    = http,https
filter  = nginx-limit-req
logpath = /config/log/nginx/error.log
bantime = 7200
  1. Restart SWAG: docker restart swag

๐Ÿง  Deep Dive: What Are These Traps Actually Catching?

  • nginx-http-auth โ€” brute-force protection for HTTP Basic Auth popups
  • nginx-badbots โ€” blocks known malicious scraper user agents
  • nginx-botsearch โ€” the most active jail, catching scans for /phpmyadmin, /wp-login.php, /setup.cgi, and similar
  • nginx-unauthorized โ€” bans IPs repeatedly triggering 401s

The "Perma-Ban" (Recidive Jail)

Standard bans are temporary โ€” smart bots wait it out. Recidive stops that cycle:

[recidive]
enabled  = true
logpath  = /config/log/fail2ban/fail2ban.log
banaction = iptables-allports
bantime  = 1w
findtime = 1d
maxretry = 3

Restart: docker compose up -d --force-recreate swag

Verification & The "Ban-Hammer"

docker exec -it swag fail2ban-client status

Expected:

Status
|- Number of jail:	6
`- Jail list:	nginx-badbots, nginx-botsearch, nginx-deny, nginx-http-auth, nginx-limit-req, nginx-unauthorized

"Number of jail: 0" means a jail.local syntax error. Check specific catches with:

docker exec -it swag fail2ban-client status nginx-botsearch

Fail2Ban isn't set-and-forget - review logs regularly, watch for false positives, and remember it's one layer among several, not a silver bullet.

Monitoring and Fine-Tuning

A robust Fail2ban configuration isn't a set-and-forget solution. Continuous monitoring and fine-tuning are essential. Regularly review your Fail2ban logs to identify false positives and adjust your filters accordingly. Pay attention to the number of bans triggered by each jail and investigate any unusual activity. Consider implementing a centralized logging system to aggregate Fail2ban logs for easier analysis.

Commands for Monitoring:

  • docker exec -it swag /bin/bash
  • fail2ban-client status: Shows the status of all jails.
  • fail2ban-client status <jail_name>: Shows the status of a specific jail.
  • fail2ban-client set <jail_name> <parameter> <value>: Changes a jail's parameters dynamically (live, on the fly).

Important Notes and Cautions

  • Testing is Crucial: Always test your configuration changes in a non-production (if possible/available) environment before deploying them to production. Incorrectly configured filters can lead to legitimate users being blocked.
  • Regular Updates: Keep your Fail2ban software and filters up to date to protect against new attack vectors.
  • Understand Your Logs: Familiarize yourself with your web server and system logs to write effective filters.
  • Fail2ban is a Defence Layer: Fail2ban is a valuable security tool, but itโ€™s not a silver bullet. It should be part of a comprehensive security strategy along with CrowdSec and a reverse proxy, plus a firewall. See how many layers we're talking about here and we haven't even gotten into IDS/IPS nor WAF yet!

Part 4: Mapping Defence in Depth to the OSI Model & Your Homelab (Sidebar!)

Infrastructure & Network Segmentation (layering)

Let's reinforce the core concept: layered security. Itโ€™s not about relying on a single solution. Itโ€™s about creating a defence-in-depth strategy. Remember the OSI model from my networking series? Here's how it ties into security with multiple layers:

<pre class="mermaid">
flowchart TB
    subgraph L7["๐Ÿ–ฅ๏ธ L7 Application"]
        direction TB
        L7a["๐Ÿ›ก๏ธ WAF - C/I"]
        L7b["๐Ÿ’ป Secure coding - I"]
        L7c["๐Ÿ” Auth & MFA - C/I"]
        L7d["๐Ÿ›ก๏ธ EDR - C/I"]
    end
    subgraph L6["๐ŸŽจ L6 Presentation"]
        direction TB
        L6a["๐Ÿ” TLS - C/I"]
        L6b["๐Ÿ“ Encoding & sanitization - I"]
        L6c["๐Ÿ”‘ Masking & tokenization - C/I"]
    end
    subgraph L5["๐Ÿค L5 Session"]
        direction TB
        L5a["โณ Timeouts - C"]
        L5b["๐Ÿ”‘ Tokens - C/I"]
        L5c["๐Ÿ›ก๏ธ VPN / ZTNA - C"]
    end
    subgraph L4["๐Ÿ“ฆ L4 Transport"]
        direction TB
        L4a["๐Ÿ” TLS / SSH - C/I"]
        L4b["๐Ÿšช Port filtering ACLs - C"]
        L4c["โšก Rate limiting / DoS - A"]
    end
    subgraph L3["๐Ÿ›ฐ๏ธ L3 Network"]
        direction TB
        L3a["๐Ÿ”ฅ Firewall - C/A"]
        L3b["๐Ÿ•ต๏ธ IDS/IPS - C/I"]
        L3c["๐Ÿงฑ Segmentation VLANs - C"]
        L3d["๐Ÿ” IPsec tunnels - C/I"]
    end
    subgraph L2["๐Ÿ”— L2 Data Link"]
        direction TB
        L2a["๐Ÿ”’ 802.1X NAC - C"]
        L2b["๐Ÿท๏ธ MAC filtering - C"]
        L2c["๐Ÿ“œ ARP/DHCP checks - I"]
        L2d["๐Ÿ“ถ WiFi WPA3 - C"]
    end
    subgraph L1["โšก L1 Physical"]
        direction TB
        L1a["๐Ÿ” Locks - C"]
        L1b["๐ŸŽฅ CCTV - C/I"]
        L1c["โšก Power/HVAC/Fire - A"]
        L1d["๐Ÿ—‘๏ธ Media sanitization - C/I"]
    end
    L7 --> L6 --> L5 --> L4 --> L3 --> L2 --> L1
    classDef conf fill:#00ffff,stroke:#00ffff,stroke-width:2px,color:#111,font-weight:bold;
    classDef integ fill:#ff00ff,stroke:#ff33ff,stroke-width:2px,color:#111,font-weight:bold;
    classDef avail fill:#ff8c00,stroke:#ffaa33,stroke-width:2px,color:#111,font-weight:bold;
    class L7a,L7c,L7d,L6a,L6c,L5a,L5b,L5c,L4a,L4b,L3a,L3b,L3c,L3d,L2a,L2b,L2d,L1a,L1b,L1d conf;
    class L7a,L7b,L7c,L7d,L6a,L6b,L6c,L5b,L4a,L3b,L3d,L2c,L1b,L1d integ;
    class L4c,L3a,L1c avail;
</pre>
๐Ÿ’ก
๐Ÿ”ฐ The letters represent the CISSP triad:
Confidentiality (C)
Integrity (I)
Availability (A)

Obviously in a home network you likely won't have all of this, but it's to illustrate that you should kinda be aiming for it. Just having "a firewall" isn't enough. That's like, the moat around your castle only!

Network Segmentation (VLANs)

If you followed my networking series and setup your VLANs properly, you can now have something like this! Head over to the OPNsense setup guide, if you don't have an effective firewall or want one that SLAPS.


๐Ÿ›ก๏ธ Optional: Contributing to the Global Shield (AbuseIPDB)

If you want to give back, report repeat offenders to AbuseIPDB:

  1. Create an account at AbuseIPDB.
  2. Generate an API key.
  3. Add to the bottom of jail.local's [DEFAULT] section:
action = %(action_)s
         %(action_abuseipdb)s[abuseipdb_apikey="YOUR_API_KEY_HERE", abuseipdb_category="5,14,15,18,19,21"]

Reload Fail2Ban: docker exec -it swag /bin/bash, then fail2ban-client reload.

Troubleshooting Pro-Tip: if CrowdSec says "bouncer connected" but isn't banning anything, check docker logs crowdsec. "File not found" errors almost always mean acquis.yaml's path doesn't match the container's internal path (/var/log/swag), not your host path.


Frequently Asked Questions

Will enabling HSTS lock me out of local network access? No โ€” HSTS is domain-specific. Local IP access (192.168.x.x) is unaffected.

Why run both Fail2Ban and CrowdSec? Isn't that redundant? Fail2Ban is your fast, local reactive agent. CrowdSec is your proactive watchman using community threat intelligence. Together: speed and global awareness.

fail2ban-client status shows 0 jails โ€” what happened? Almost always a jail.local syntax error.

Is TLS 1.2 still safe in 2026? Yes, with strong ciphers โ€” kept enabled alongside 1.3 mainly for older smart TVs and IoT devices.

Can I get an A+ rating without a reverse proxy? Very difficult โ€” individual apps generally aren't built to manage their own TLS and headers the way SWAG does.

What if I get banned by my own Fail2Ban or CrowdSec? Switch networks (phone LTE) to reach your terminal. Fail2Ban: fail2ban-client set <jail-name> unbanip <your-ip>. CrowdSec: cscli decisions delete --ip <your-ip>.


What's Next

Layers 1 through 3 of the Digital Castle are up: the moat, the traps, and the watchman. Next: Authelia - Centralized MFA for Self-Hosted Services, the Gatekeeper that demands a second factor even from traffic that makes it past everything above.