29 min read

The Ultimate Homelab Security Guide (2026): Hardening SWAG, Authelia, and CrowdSec

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. This blog post will delve into three powerful tools – Fail2Ban, CrowdSec, and Authelia – that, when combined, create a formidable defence against malicious activity targeting your Nginx / selfhosted setup behind your firewall.

🔹 Digital Castle TL;DR

  • SWAG = entry point
  • Authelia = identity
  • CrowdSec & Fail2Ban = threat blocking
  • VLANs = containment

This blog post will delve into three powerful tools - SWAG (Fail2Ban), CrowdSec, and Authelia—that, when combined, create a formidable defense against malicious activity targeting your self-hosted setup.

We aren't just opening ports; we are building a siege-ready fortress.

Prerequisite Steps:

  1. Completed docker compose setup guide here!✅
  2. Completed the NGINX (SWAG) guide or, if you've already got SWAG running, continue below!✅

🏰 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.
  • Layer 2 (The Traps): Fail2Ban (Integrated into SWAG). The classic "Three strikes and you're out" defender.
  • Layer 3 (The Watchman): CrowdSec reads the logs. If an IP acts suspicious (scanning, spamming login), it is banned instantly - and shared with the global community.
  • Layer 4 (The Gatekeeper): Authelia intercepts valid traffic. Even if a hacker bypasses the firewall, they cannot pass without your unique 2FA token.

🐳 Part 1: Setting up SWAG (Nginx) for Secure Remote Access

We will deploy the entire stack using Bind Mounts and docker compose for full control.

Installing SWAG (Docker)

If you haven't already installed SWAG, no sweat, can continue on here but if you run into any issues you might want to skip back to the dedicated SWAG guide and follow through.

Folder Structure & Bind Mounts

Create a folder structure like this:

/home/user/digital-castle/ <-- Whatever path you want. Mine is /opt/DOCKERS/
├── docker-compose.yml <-- Or wherever you want to keep it
├── .env                <-- CRITICAL for security variables
├── swag/
├── crowdsec/
└── authelia/
⚠️
The examples below use a macvlan networking config! This can be more complex but allows your SWAG container to have it's own IP address and mac address, like a real network appliance.

You can also just use the default user defined bridge network as well. Read up more on docker networking.

Why: MACVLAN is blocked from the host by default, acts like a real network appliance, and segregates this (SWAG) docker from everything else.

Part 1.A The Master Compose File

Save this as docker-compose.yml. Note the use of ${VARIABLES} which we will define shortly. This includes SWAG, with the new Crowdsec below that, all put together. We'll immediately begin configuring CrowdSec below. Patience!

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

Part 1.B - Firing it up!

Docker commands: docker compose up -d or you can do docker compose up -d crowdsec, if you just want to focus on one. Also, docker compose down crowdsec, or docker compose down for all your dockers.

  1. Once you start the SWAG & CrowdSec docker up, give it a minute or so, then shut it down. This generates all default folders/files needed.
  2. Go edit the acquis.yaml file to add the proper log sources in. (Shown below)

A futuristic digitized datacenter, with glowing electric looking edges around server racks and monitor showing A+ from Qualys SSL.
A+ with Qualys SSL scanner.

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

🎯 The Goal: HSTS & The A+ Rating

To get that elusive "A+" rating, we need to enable HSTS (HTTP Strict Transport Security).

  • What it does: It tells browsers, "Never, ever try to talk to me over HTTP again. HTTPS only. Period."
    • ⚠️ 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.
Infographic: 6 Steps to Hardening SWAG Nginx for A Plus Rating (2026) including HSTS, Fail2Ban, and Recidive.
Keep this checklist handy for your periodic security audits. A secure Digital Fortress requires constant maintenance

🛠️ Step 1: The "One-Line" HSTS Fix

The LinuxServer team has already done the heavy lifting; they commented out the HSTS line in the default config to protect beginners from locking themselves out.

  1. Open your SWAG config: Navigate to your mapped volume: /swag/config/nginx/ssl.conf.
  2. Find the HSTS line: Scroll down until you see the # HSTS section. You will see a line starting with # add_header Strict-Transport-Security....
  3. Uncomment it: Remove the # so it looks like this:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    • Note: max-age=63072000 is 2 years. Qualys requires at least 6 months (approx 15.7M seconds) for an A+.
  • Save and Restart SWAG:
docker restart swag

🚨 Reminder: Only do this if you have a working SSL certificate and you are confident your auto-renewal (Certbot) is working!

🛡️ Step 2: Hardening HTTP Headers

While we are in the config, let's stop other sites from "framing" your services (a common phishing/clickjacking tactic) or injecting bad scripts.

Still in /swag/config/nginx/ssl.conf, ensure these lines are active (uncommented):

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

# Stop the browser from "guessing" the MIME type (MIME Sniffing protection)
add_header X-Content-Type-Options "nosniff" always;

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

# Referrer Policy (Privacy)
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

🚫 Step 3: Killing Legacy TLS (1.0 & 1.1)

In 2026, NGINX might still support TLS 1.0 or 1.1 for ancient devices (think: Android phones from 2012). For a Digital Castle, we cut the weak links. Only allow modern, encrypted lanes.

  1. In ssl.conf, look for ssl_protocols.
  2. Change it to:
ssl_protocols TLSv1.2 TLSv1.3;
  1. Optional: If you want to be bleeding edge (and potentially break older smart TVs), you can use TLSv1.3 only, but 1.2 is still considered secure and "Green" by Qualys.

🪤 Step 4: Arming Fail2Ban (The Bot Traps)

By default, Fail2Ban might only be watching for SSH attemptas. But for a web server, the attacks are coming via HTTP (people guessing passwords, bots scanning for wp-login.php, etc.).

SWAG comes with pre-written "Jails" for Nginx, but they are often disabled (commented out) or need explicit activation.

  1. Open your Jail Config: Navigate to /swag/fail2ban/jail.local.
  2. Find the NGINX section: Scroll down until you see the headers like [nginx-http-auth].
  3. Enable the Core 5: Ensure enabled = true is set for these specific jails. If they are missing, add them:
[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 config below:

# This is the custom version of the jail.conf for fail2ban
# Feel free to modify this and add additional filters
# Then you can drop the new filter conf files into the fail2ban-filters
# folder and restart the container

[DEFAULT]
# Prevents banning LAN subnets
ignoreip = 10.0.0.0/8
           192.168.0.0/16
           172.16.0.0/12

# Changes the default ban action from "iptables-multiport", which causes issues on some platforms, to "iptables-allports".
banaction = iptables-allports

# "bantime" is the number of seconds that a host is banned.
bantime  = 1800

# A host is banned if it has generated "maxretry" during the last "findtime"
# seconds.
findtime  = 600

# "maxretry" is the number of failures before a host get banned.
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: The classic brute-force protector. If someone fails HTTP Basic Auth (the popup login box) too many times, they get banned.
  • nginx-badbots: Blocks known malicious User Agents (scrapers like "EmailSiphon" or "80legs") that harvest your site data.
  • nginx-botsearch: This is the most active one. It catches scripts looking for known vulnerabilities like /phpmyadmin, /wp-login.php, or /setup.cgi. If an IP asks for a non-existent file that implies hacking intent, they are gone.
  • nginx-unauthorized: Bans IPs that trigger a "401 Unauthorized" error repeatedly.

⛓️ Step 5: The "Perma-Ban" (Recidive Jail)

When they really piss you off!

Standard bans are temporary (usually 10 minutes). Smart bots know this; they wait 11 minutes and try again. The Recidive jail stops this cycle.

  1. Open jail.local again.
  2. Add/Enable the Recidive block:
[recidive]
enabled  = true
logpath  = /config/log/fail2ban/fail2ban.log
banaction = iptables-allports
bantime  = 1w  ; Ban for 1 week
findtime = 1d  ; Look back 1 day
maxretry = 3   ; If they get banned 3 times in 1 day... bye bye.
  1. Restart SWAG. docker compose up -d --force-recreate swag

Why this matters: This keeps your logs clean. Instead of seeing the same Russian bot IP get banned 50 times a day, you see it get banned once by the standard jail, then once by Recidive, and then silence for a week. Or a year, you decide!

✅ Step 6: Verification & The "Ban-Hammer"

You configured the jails, but are they actually running? Fail2Ban often fails silently if there is a typo in your jail.local. Let's verify the guard dogs are awake.

  1. Check the Status: Run this command in your terminal (outside the container):
docker exec -it swag fail2ban-client status
  1. The Correct Output: You should see a list of your enabled jails under "Jail list". It should look like this:
Status
|- Number of jail:	6
`- Jail list:	nginx-badbots, nginx-botsearch, nginx-deny, nginx-http-auth, nginx-limit-req, nginx-unauthorized

If you see "Number of jail: 0" or missing names, check your jail.local for typos!

  1. Check the Banned List (Optional): Want to see if you have caught anyone yet? Check the nginx-botsearch jail specifically:
docker exec -it swag fail2ban-client status nginx-botsearch

It will list the specific IP addresses currently in the "penalty box."

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:

  • s 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 3: Implementing CrowdSec for Real-Time Threat Intelligence

This is where most guides fail. They tell you to run the container, but they don't tell you how to link the Bouncer securely. CrowdSec (The Brain) needs to give SWAG (The Bouncer) an API Key so it can execute bans.

Bouncer Setup

Next, nano /opt/DOCKERS/swag/crowdsec/acquis.yml Ensure it looks like the code below:

filenames:
  - /var/log/swag/*
#this is not a syslog log, indicate which kind of logs it is
labels:
  type: nginx
---
filenames:
 - /var/log/auth.log*
 - /var/log/syslog
labels:
  type: syslog
#---
#filename: /var/log/apache2/*.log
#labels:
#  type: apache2

Pro Tip: If you skip this, CrowdSec is running blind. It needs the type: nginx label to know which parser to use.

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, we generate the API key that will link the bouncer to crowdsec and allow blocks to start happening.

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 (The "Missing Tip"). We do not hardcode secrets in docker-compose.yml. You CAN if you're having issues but you should put them in the .env file.

Create a file named .env in your folder and paste your key that you just copied above:

nano .env and;
Paste the key you just copied into the top line.

#Crowdsec config
CROWDSEC_API_KEY=yourkeywhichisveryrandom
💡
Why do this? The line - CROWDSEC_API_KEY=${CROWDSEC_API_KEY} in your compose file we created earlier pulls from this hidden file. It keeps your config clean and secure.

Confirmation & Testing

Now at this stage, I run:

docker compose down && docker compose up -d

This restarts the entire stack, if things aren't quite working, try this:

docker compose up -d --force-recreate swag

Which rebuilds SWAG, ensuring it picks up the new mods & variables you have set, and starts communicating with CrowdSec! Check logs like this, one at a time:

docker logs -f swag
docker logs -f crowdsec
CTRL-C to cancel

You should see that crowdsec has picked up and read logs you specified in acquis.yaml and now by running: docker exec -t crowdsec cscli bouncers list

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! ✅

Now check CrowdSec is fully functional with:

docker exec -t crowdsec cscli metrics
docker exec -t crowdsec cscli decisions list

With the first command, you'll see a pile of info about everything it's downloaded and about it's 'buckets' and logs parsed. You really want to ensure it has logs parsed and not unparsed! It's pretty lengthy so I'll enclose the output in a toggle you can expand:

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 │
╰────────────────────────────────────┴────────────────────────────────────┴───────┴─────────────╯

Decisions list shows you if it's made any active decisions to block already. Likely that will take quite some time unless you have a busy publicly facing site.

Now, if you're hosting anything publicly, run docker exec -t crowdsec cscli alerts list and see what you get. At the moment I'm showing some brute force IP's being blocked:

 docker exec -t crowdsec cscli alerts list
╭──────┬───────────────────┬──────────────────────────────────────┬─────────┬───────────┬───────────┬──────────────────────┬──────────╮
│  ID  │       value       │                reason                │ country │     as    │ decisions │      created_at      │   kind   │
├──────┼───────────────────┼──────────────────────────────────────┼─────────┼───────────┼───────────┼──────────────────────┼──────────┤
│ 8014 │ Ip:142.127.208.18 │ LePresidente/http-generic-403-bf     │ CA      │ 577 BACOM │ ban:1     │ 2026-04-05T06:08:21Z │ crowdsec │
│ 8013 │ Ip:142.127.208.18 │ LePresidente/http-generic-403-bf     │ CA      │ 577 BACOM │ ban:1     │ 2026-04-05T06:07:21Z │ crowdsec │
│ 8012 │ Ip:142.127.208.18 │ LePresidente/http-generic-403-bf     │ CA      │ 577 BACOM │ ban:1     │ 2026-04-05T06:05:48Z │ crowdsec │
│ 8011 │ Ip:142.127.208.18 │ LePresidente/http-generic-403-bf     │ CA      │ 577 BACOM │ ban:1     │ 2026-04-05T06:04:30Z │ crowdsec │
│ 8010 │ Ip:142.127.208.18 │ LePresidente/http-generic-403-bf     │ CA      │ 577 BACOM │ ban:1     │ 2026-04-05T06:02:57Z │ crowdsec │
│ 8009 │ Ip:142.127.208.18 │ LePresidente/http-generic-403-bf     │ CA      │ 577 BACOM │ ban:1     │ 2026-04-05T06:01:39Z │ crowdsec │
│ 8008 │ Ip:142.127.208.18 │ crowdsecurity/http-crawl-non_statics │ CA      │ 577 BACOM │ ban:1     │ 2026-04-05T06:00:37Z │ crowdsec │
│ 8007 │ Ip:142.127.208.18 │ LePresidente/http-generic-401-bf     │ CA      │ 577 BACOM │ ban:1     │ 2026-04-05T06:00:37Z │ crowdsec │
│ 8006 │ Ip:142.127.208.18 │ LePresidente/http-generic-403-bf     │ CA      │ 577 BACOM │ ban:1     │ 2026-04-05T06:00:37Z │ crowdsec │
╰──────┴───────────────────┴──────────────────────────────────────┴─────────┴───────────┴───────────┴──────────────────────┴──────────╯

Real-time Blocking

You now have live, real-time blocking on the fly protecting your exposed services.

Why it beats Fail2Ban

CrowdSec's power is the crowd-sourced threat intelligence. You are protected and defended by all the other users utilizing CrowdSec and this is it's true power. Fail2Ban simply blocks things based on defined variables and lists. CrowdSec adapts and moves on the fly with it's crowd-sourced threat feeds.

🎆
Congratulations, that one is a bit of work but VERY well worth it! You just enhanced your protection greatly! Although nothing is impossible to hack, with a reverse proxy like SWAG with Fail2Ban & CrowdSec, there's far far easier targets to go after than you.

Part 4: OSI Model Security - Mapping the CIA Triad to your Homelab

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:

flowchart TB
    %% ========================
    %% Layer 7: Application
    subgraph L7["🖥️ L7 Application"]
        direction TB
        L7a["🛡️ WAF - C/I"]
        L7b["💻 Secure coding - I"]
        L7c["🔐 Auth & MFA - C/I"]
        L7d["🛡️ EDR - C/I"]
    end

    %% Layer 6: Presentation
    subgraph L6["🎨 L6 Presentation"]
        direction TB
        L6a["🔐 TLS - C/I"]
        L6b["📝 Encoding & sanitization - I"]
        L6c["🔑 Masking & tokenization - C/I"]
    end

    %% Layer 5: Session
    subgraph L5["🤝 L5 Session"]
        direction TB
        L5a["⏳ Timeouts - C"]
        L5b["🔑 Tokens - C/I"]
        L5c["🛡️ VPN / ZTNA - C"]
    end

    %% Layer 4: Transport
    subgraph L4["📦 L4 Transport"]
        direction TB
        L4a["🔐 TLS / SSH - C/I"]
        L4b["🚪 Port filtering ACLs - C"]
        L4c["⚡ Rate limiting / DoS - A"]
    end

    %% Layer 3: Network
    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

    %% Layer 2: Data Link
    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

    %% Layer 1: Physical
    subgraph L1["⚡ L1 Physical"]
        direction TB
        L1a["🔐 Locks - C"]
        L1b["🎥 CCTV - C/I"]
        L1c["⚡ Power/HVAC/Fire - A"]
        L1d["🗑️ Media sanitization - C/I"]
    end

    %% Layer flow (top-down)
    L7 --> L6 --> L5 --> L4 --> L3 --> L2 --> L1

    %% Class Definitions: CIA Neon/Cyberpunk
    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;

    %% Glow effect for cyberpunk
    classDef confGlow stroke:#00ffff,stroke-width:4px,stroke-dasharray:2 1;
    classDef integGlow stroke:#ff00ff,stroke-width:4px,stroke-dasharray:2 1;
    classDef availGlow stroke:#ff8c00,stroke-width:4px,stroke-dasharray:2 1;

    %% Assign classes
    class L7a,L7c,L7d,L6a,L6c,L5a,L5b,L5c,L4a,L4b,L3a,L3b,L3c,L3d,L2a,L2b,L2d,L1a,L1b,L1d conf,confGlow;
    class L7a,L7b,L7c,L7d,L6a,L6b,L6c,L5b,L4a,L3b,L3d,L2c,L1b,L1d integ,integGlow;
    class L4c,L3a,L1c avail,availGlow;
🔰
The letters in brackets (C, I and A) are representing which part of the CISSP 'triad' each security tech stack ties into.
Confidentiality (C) → cyan (#0ff)
Integrity (I) → magenta (#ff00ff)
Availability (A) → bright orange (#ff8c00)

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.

flowchart TD
    %% ==========================
    %% Host Layer
    subgraph Host["🖥️ Docker Host / Engine"]
        H["💻 Host OS / Docker Engine"]
    end

    %% ==========================
    %% WAN / Firewall
    subgraph Firewall["🛡️ OPNsense Firewall / WAN"]
        FW["🌐 WAN / Internet"]
        FW --> H
    end

    %% ==========================
    %% SWAG / NGINX Reverse Proxy
    subgraph SWAG["🏰 SWAG Container / NGINX Reverse Proxy"]
        NGINX["🌐 NGINX Proxy"]
        H --> NGINX
        NGINX --> FW
    end

    %% ==========================
    %% Backend Containers - Bridge Network
    subgraph BridgeNet["🌉 Bridge Network Backend"]
        Ghost["👻 Ghost Blog"]
        Jellyseerr["🎬 Jellyseerr"]
        HomeAssistant["🏠 Home Assistant"]
        NGINX --> Ghost
        NGINX --> Jellyseerr
        NGINX --> HomeAssistant
        Ghost --- Jellyseerr
        Jellyseerr --- HomeAssistant
        Ghost --- HomeAssistant
    end

    %% Backend Containers - Macvlan / IPVLAN
    subgraph MacvlanNet["🔗 Macvlan / IPVLAN Backend"]
        Radarr["🎞️ Radarr"]
        Sonarr["📺 Sonarr"]
        NGINX --> Radarr
        NGINX --> Sonarr
        Radarr --- Sonarr
    end

    %% Backend Containers - Internal Docker-only
    subgraph InternalNet["🔒 Internal Docker Network"]
        Redis["🟢 Redis DB"]
        Postgres["🐘 Postgres DB"]
        Ghost --- Redis
        Jellyseerr --- Postgres
        Redis --- Postgres
    end

    %% ==========================
    %% Node Styling
    style H fill:#f88,stroke:#333,stroke-width:2px,color:#111
    style FW fill:#fbbf24,stroke:#333,stroke-width:2px,color:#111
    style NGINX fill:#d8b4fe,stroke:#333,stroke-width:2px,color:#111
    style Ghost fill:#93c5fd,stroke:#333,stroke-width:1px,color:#111
    style Jellyseerr fill:#5eead4,stroke:#333,stroke-width:1px,color:#111
    style HomeAssistant fill:#60a5fa,stroke:#333,stroke-width:1px,color:#111
    style Radarr fill:#fcd34d,stroke:#333,stroke-width:1px,color:#111
    style Sonarr fill:#fbbf24,stroke:#333,stroke-width:1px,color:#111
    style Redis fill:#34d399,stroke:#333,stroke-width:1px,color:#111
    style Postgres fill:#3b82f6,stroke:#333,stroke-width:1px,color:#111

    %% ==========================
    %% Subgraph Styling (dashed outlines)
    style Host fill:transparent,stroke:#888,stroke-width:1px,stroke-dasharray:4 4
    style Firewall fill:transparent,stroke:#888,stroke-width:1px,stroke-dasharray:4 4
    style SWAG fill:transparent,stroke:#888,stroke-width:1px,stroke-dasharray:4 4
    style BridgeNet fill:transparent,stroke:#888,stroke-width:1px,stroke-dasharray:4 4
    style MacvlanNet fill:transparent,stroke:#888,stroke-width:1px,stroke-dasharray:4 4
    style InternalNet fill:transparent,stroke:#888,stroke-width:1px,stroke-dasharray:4 4

🛑 Explainer: Fail2Ban (The Silent Defender)

Fail2Ban is a daemon that monitors log files for suspicious activity and automatically blocks offending IP addresses. It's incredibly easy to set up and use but luckily for you, it's 👉included in SWAG by default 🏁

Fail2Ban monitors your logs for "dumb" attacks - script kiddies trying admin/admin 50 times in a second.

  • Verification: Check your ./swag/fail2ban/jail.local.
  • The Default Jails: SWAG includes 5 jails by default (including nginx-http-auth and nginx-botsearch).
  • Why keep it? CrowdSec is smart, but Fail2Ban is fast. It handles the "noise" so CrowdSec can focus on the complex threats.

🛡️ Explainer: CrowdSec (The Watchmen)

This is where most guides fail. They tell you to run the container, but they don't tell you how to link the Bouncer securely.

CrowdSec takes threat detection to the next level by employing behavioural analysis. Unlike Fail2Ban, which relies on predefined patterns, CrowdSec learns the "normal" behaviourof your system.

  • How it Works: CrowdSec analyzes network traffic in real-time, identifying anomalies and deviations from established baselines. It uses a lightweight, distributed architecture and is "crowdsourced". The beauty of this is you share your bans with all the other users of CrowdSec, and vice-versa!
  • Key Features:
    • Behavioural Rules: Define rules based on traffic patterns (e.g., requests per second, URL access, etc.).
    • Automatic Learning: CrowdSec continuously adapts to changes in your system's behaviour and receives updates from the "Crowd".
    • Integration with Firewalls: Can automatically block traffic based on its analysis with the Bouncer.

Part 5: Authelia - Centralized Multi-Factor Authentication & Authorization

Authelia provides a centralized solution for user authentication and authorization, simplifying user management and enhancing security for your Nginx applications.

🎇This can be a bit of a beast to setup for the first time, but is worth it in the end. Make sure your SWAG instance is running properly, and you have a handle on the basics like enabling a new subdomain served site. Example, you have yourdomain.com but you want to simply serve sonarr.yourdomain.com right? That's actually a subdomain site served via SWAG! This requires proper DNS setup for whomever you run DNS through as well.

How Authelia Performs Multi-Factor Authentication

  • How it Works: Authelia acts as an intermediary between your reverse proxy (SWAG) and apps someone would want to access, intercepting all incoming requests and performing authentication and authorization checks before forwarding them to your SWAG protected applications.
  • 👉I chose to make a simple user file, but you might prefer to use LDAP or another method.
  • Add this to your docker-compose.yml directly below your SWAG and then CrowdSec portions!

5.1 Authelia Docker Compose:

# Authelia begin
# to add a user, add directly to `authelia/users_database.yml`
# then get the encrypted password with
# docker run --rm ghcr.io/authelia/authelia:4.34.6 authelia hash-password yourpassword
# https://www.linuxserver.io/blog/2020-08-26-setting-up-authelia#users_database-yml
  authelia:
    image: ghcr.io/authelia/authelia:latest
    container_name: authelia
    networks:
      backend:
      docker_vlan:
        ipv4_address: X.X.X.X
    environment:
      TZ: 'America/Toronto'
      PUID: '1000'
      PGID: '100'
#    expose:
#      - 9091
#    env_file:
#      - common.env
    volumes:
      - /yourpath/DOCKERS/authelia:/config
    restart: unless-stopped

  KeyDB-Authelia:
    image: eqalpha/keydb:latest
    container_name: keydb-authelia
    restart: unless-stopped
    environment:
      REDIS_ARGS: "--save 60 10"
    networks:
      - backend

See full documentation & setup guidance here.

I am utilizing keydb vs redis because redis went closed source, and keydb is a more performant drop in replacement! FOSS or death!

If you're not hosting email or don't want to have to integrate email with authelia, you can do what I did and just make a file as your users 'database'. Simple as nano /yourpath/authelia/users_database.yml

5.2 users_database.yml

# yamllint disable rule:line-length
---
###############################################################
#                         Users Database                      #
###############################################################

# This file can be used if you do not have an LDAP set up.

users:
  first_user:
    displayname: "myusername"
    password: "crazyargon2_hashed_pw" <--Change this!!!
    email: [email protected]
    groups:
      - admins
      - dev
  second_user:
    displayname: "My Buddy"
    password: "Hiscrazy_encrypted_pw"
    email: [email protected]
    groups: []

...
# yamllint enable rule:line-length

😏Users declaration

users: at the top has to stay that way, this is where you are 'declaring' all users below that line. It's YML so again, be careful with your indents! Placeholder first_user: can be anything, such as john or Tim_Bob or whatever you want. I kept my username and display name the same for simplicity but you could do something like displayname: "Mr Soandso" and their username is actually, Adam Soandso.

🤫Password declaration

This is a hashed password you cannot simply just type in. To get the proper hashed password you must run a command, which temporarily runs an instance of authelia, generates the password after you enter it, you COPY THIS HASH and paste it into your already created users_database.yml. As here:

docker run --rm -it authelia/authelia:latest authelia crypto hash generate argon2

Now enter and confirm your typed password - Hassanl0v3sBDAYs! as an example (don't use that!)

Copy the hash string, it'll start with $argon2id$v=... blah blah blah it'll be relatively long. Argon2 hashed password complete!

Open your users_database.yml again and paste it into the password: spot inside quotes "$argon2is$v...blahblah"

Further reading from their official docs here: https://www.authelia.com/reference/guides/passwords/

5.3 Authelia Configuration - Read carefully‼️

You can get to your authelia config after you've fired up authelia with docker compose. It will populate the default configuration.yml for you whatever path you put authelia at. Example: nano /yourpath/authelia/configuration.yml

---
###############################################################################
#                           Authelia Configuration                            #
###############################################################################
theme: dark

identity_validation:
  reset_password:
    jwt_secret: 'big_string' # change this to at least a 32-character string

server:
#  address: "tcp://:9091/authelia"
  address: 'tcp://0.0.0.0:9091/authelia' # do not change
  buffers:
    read: 4096
    write: 4096

log:
  level: info
  file_path: /config/logs/authelia.log
  keep_stdout: true

totp:
  issuer: authelia.yourdomain.ca # such as auth.yourdomain.com
  period: 30
  skew: 1

authentication_backend:
  password_reset:
    disable: true # change to true if you want to stop users from resetting their passwords
  refresh_interval: 30m # I found that the default of 5 is too short
  file:
    path: /config/users_database.yml # don't change this
    password:
      algorithm: argon2id
      iterations: 1
      key_length: 32
      salt_length: 16
      memory: 1024
      parallelism: 8

access_control: # Recommend reading up on all the options for access control
  default_policy: deny
  rules:
    - domain:
      - yourdomain.ca
      - "*.yourdomain.ca" # This is for a wildcard cert and subdomains
      policy: two_factor

session:
  name: authelia_session # do not change
  secret: 'Make your own' # make this a long strong, 26 characters or so
  same_site: 'lax'
  expiration: '1h'
  inactivity: '5m'
  remember_me: '1M'
  cookies:
    - domain: 'yourdomain.ca' # change
      authelia_url: 'https://authelia.yourdomain.ca' # change
      default_redirection_url: 'https://yourdomain.ca'
      name: 'authelia_session'
      same_site: 'lax'
      inactivity: '5m'
      expiration: '1h'
      remember_me: '1d'
  redis:
    host: keydb-authelia # change to your host machine's IP if this doesn't work
    port: 6379 # if you use IP above, make sure this matches the mapped port
    # password: "" # do not change. if this doesn't work, remove the "" and try again
    # database_index: 0
    # maximum_active_connections: 10
    # minimum_idle_connections: 0

regulation:
  max_retries: 3
  find_time: 10m
  ban_time: 2h

storage:
  encryption_key: 'make your own!' # use a 20-character random string
  local:
    path: /config/db.sqlite3

notifier: # you can only use one of smtp or filesystem. uncomment/comment below as necessary to choose which
  filesystem:
    filename: /config/notification.txt
  disable_startup_check: false
  # smtp:
    # username: [email protected]
    # password: "YOUR_EMAIL_PASSWORD"
    # address: smtp.gmail.com:587 # change to match your smtp uri and port
    # sender: [email protected]
    # subject: "[Authelia] {title}"
    # startup_check_address: [email protected]
    # disable_require_tls: false
    # disable_html_emails: false
    # tls:
      # skip_verify: false
      # minimum_version: TLS1.2

So that's a lot... You can get VERY fine-grained and detailed control, having it ignore your local LAN and bypass 2FA/MFA, integrate with LDAP, the works. This is a more basic config that is bulletproof and just works.

Note the notifier: section! That is where you will find your one time authentication key for linking your Google authenticator to your authelia instance!

5.4 Google Authenticator with Authelia

Yes, you can (and I do) use Google Authenticator with my Authelia. So no, you don't necessarily HAVE to use yet another MFA app on your phone! Please read in detail all you need here: https://www.authelia.com/overview/authentication/one-time-password/

If you have authelia running, as above, and your users_database.yml, all GTG. Proceed!

Steps:

  • LOGIN to Authelia for the first time with your username and password you put in 3.2 the users_database.yml. Yes your normal password, not that crazy hashed password.
    • As soon as you login, you have completed the "First Factor" and you will see some options. ▶️Click One-Time Password method and then;
    • ➡️Register device. To register your google authenticator, go look at that file I pointed out a few minutes ago "notifier". nano /yourpath/authelia/config/notification.txt or cat /yourpath/authelia/config/notification.txt
    • It should spit out a link you can now click or copy and paste into your browser of choice which will then display a QR code. Exciting yes?
😀
You can now use Google Authenticator or whatever Authenticator you use, to scan the code and activate it on your device!

5.5 Enabling Authelia protection with SWAG, finally

Now that the hard parts are all over, to make SWAG & Authelia dance and protect what you want publicly exposed, simply uncomment a couple lines in the SWAG config, and restart SWAG!

Steps:

  1. Create a DNS entry to act as a subdomain for you. Example: sonarr.yourdomain.com
    1. Should point to your root domain, or to your WAN IP depending which DNS provider you're using.
  2. nano /yourpath/swag/nginx/proxy-confs/sonarr.subdomain.conf now uncomment the 2 lines where it literally says, # enable for Authelia.
  3. Once you've uncommented those 2 lines, you restart SWAG and test.
    1. docker compose up -d --force-recreate swag
    2. Check the logs to see the container restarted properly and all looks well:
    3. docker logs -f swag
  4. Try to login from outside your house / turn off wifi on your phone and hit it!
    1. You should get prompted for your Authelia login first, then the OTP token in your Authenticator!

Part 6: Backup & Recovery (Ransomware Reality)

You have several ways to get backups running. Two steps I immediately recommend you take are:
1. Deploy Dockhand! It can do backups for you and;

Docker & Compose on Ubuntu 24.04 / Debian 13: The 2026 Operations Manual
The definitive 2026 setup guide. Install Docker, setup Dockge & Dozzle, and master the “Apartment Model” of networking. Start self-hosting the right way.
  1. Setup my backup script, which intelligently backs up your entire docker infrastructure.
Simple Docker Backups
Hey fellow homelabbers! 👨‍💻 Backing up your Docker containers might sound complex, but it’s really not. Today, I’m going to show you a simple, barebones (but reliable!) method to back up your Docker Compose stack’s persistent data using a basic script and a cron job. This is all about simplicity, reliability,

Congrats! You now have fully functioning MFA with SWAG!
fireworks at night
Photo by weston m / Unsplash

🏁 The Result: The Castle Gates Are Closed

You have successfully built a Digital Castle.

Troubleshooting Pro-Tip: If CrowdSec says "bouncer connected" but isn't banning anything, check docker logs crowdsec. If you see "file not found" errors, your acquis.yaml path maps are wrong. Remember: The path in acquis.yaml must match the path inside the container (/var/log/swag), not your host path.

Deploy your own firewall from the Core Lab's complete practical cybersecurity roadmap:

A Practical Cybersecurity Roadmap for Homelabs
Learn to protect your data, services and privacy with actionable steps and clear tutorials. So, you’ve built an incredible homelab. You’re spinning up services in Docker, managing your media, and maybe even self-hosting your own website. You are the master of your own data. But with every new service you

🛡️ Optional: Contributing to the Global Shield (AbuseIPDB)

If you want to be a nice guy - you can contribute what you ban as repeat bad offenders, to the abuse.ch database! Although this is partially redundant and you're already receiving active defence from CrowdSec, it helps the FOSS community.

Steps:

  1. Create an account at Abuse.ch here.
  2. Generate an API key once you've done this and SAVE IT in notepad++ or something...
  3. Modify your jail again:
nano /your/path/swag/fail2ban/jail.local

Scroll down to the bottom of the [DEFAULT] part and add this to the bottom:

# Abuse IPDB reporting! Categories are from most common.
action = %(action_)s
         %(action_abuseipdb)s[abuseipdb_apikey="YOUR_API_KEY_HERE", abuseipdb_category="5,14,15,18,19,21"]

If done right should look like:

[DEFAULT]
# Prevents banning LAN subnets
ignoreip = 10.0.0.0/8
           192.168.0.0/16
           172.16.0.0/12

# Changes the default ban action from "iptables-multiport", which causes issues on some platforms, to "iptables-allports".
banaction = iptables-allports

# "bantime" is the number of seconds that a host is banned.
bantime  = 1800

# A host is banned if it has generated "maxretry" during the last "findtime"
# seconds.
findtime  = 600

# "maxretry" is the number of failures before a host get banned.
maxretry = 3

# Abuse IPDB reporting! Categories are from most common.
action = %(action_)s
         %(action_abuseipdb)s[abuseipdb_apikey="YOUR_OWN_ABUSE_APIKEY", abuseipdb_category="5,14,15,18,19,21"]

Important: After making changes to jail.local, reload Fail2ban again:

  1. docker exec -it swag /bin/bash
  2. fail2ban-client reload
  3. fail2ban-client status

The Digital Castle: Frequently Asked Questions

Q: Will enabling HSTS lock me out of my local network access? A: No. HSTS (HTTP Strict Transport Security) is domain-specific. As long as you access your server via its local IP (e.g., 192.168.x.x), HSTS will not trigger. It only applies when accessing your services via your public domain (e.g., vault.yourdomain.com).

Q: Why run both Fail2Ban and CrowdSec? Isn't that redundant? A: Think of them as a "Layered Defense." Fail2Ban is your local, high-speed reactive agent; it catches "dumb" scripts and brute-force attempts instantly based on your local logs. CrowdSec is your proactive "Watchman"; it uses community-sourced threat intelligence to ban known malicious IPs before they even touch your castle. Together, they provide both speed and global awareness.

Q: I enabled the jails, but fail2ban-client status shows 0 jails. What happened? A: This is almost always a syntax error in jail.local. Ensure you don't have duplicate [header] sections and that every jail has enabled = true. Crucially, verify that your logpath inside the container (e.g., /config/log/nginx/access.log) matches the internal path of the SWAG container, not your host path.

Q: Is TLS 1.2 still safe in 2026? A: Yes. While TLS 1.3 is the modern gold standard, TLS 1.2 remains secure when configured with strong ciphers. We keep 1.2 enabled in this guide to ensure compatibility with older "smart" devices (like older TVs or IoT hubs) that don't yet support 1.3. For the ultimate "Digital Castle," you can restrict to 1.3 if all your clients support it.

Q: Can I get an A+ rating without a Reverse Proxy? A: It is extremely difficult. A Reverse Proxy like SWAG acts as the "Security Negotiator" for your traffic. Without it, your individual apps (Plex, Ghost, etc.) would have to handle their own SSL and complex security headers, which most are not designed to do natively.

Q: If I use Authelia (MFA), do I still need a VPN? A: They serve different purposes. Authelia provides Zero Trust access to specific web apps. A VPN (like Wireguard) provides a secure pipe into your entire network. For maximum security, use Authelia for public-facing web services and a VPN for sensitive admin tasks like OPNsense or Proxmox management.

Q: What happens if I get banned by my own Fail2Ban or CrowdSec? A: Switch to a different network (like your phone's LTE) to access your terminal.

  • For Fail2Ban: Run docker exec -it swag fail2ban-client set <jail-name> unbanip <your-ip>.
  • For CrowdSec: Run docker exec -it crowdsec cscli decisions delete --ip <your-ip>.

Q: Why use KeyDB instead of Redis for Authelia? A: In early 2024, Redis moved to a non-open-source license. KeyDB is a high-performance, multithreaded fork that remains fully open-source (BSD licensed). It's a drop-in replacement that aligns better with the "Core Lab" ethos of digital sovereignty.