all systems self-hosted

~/selfhostjournal $

← All articles

,

Hardening Traefik: geo-blocking, CrowdSec and single sign-on

11–16 minutes

In short

Problem: As soon as ports 80 and 443 are open, bots from all over the world scan your services, try passwords and probe for known vulnerabilities. Traefik alone forwards all of it.

Solution: Several layers in front of every service: geo-blocking for countries you never visit from, CrowdSec for known attackers and attack patterns, single sign-on in front of apps without their own good login, plus a few container basics.

Tested with: Traefik 3.7, GeoBlock plugin 0.3.8, CrowdSec 1.8 with the Traefik bouncer plugin 1.7 and AppSec, Authentik 2026.8, Docker Compose on a small home server

This is the second part of my Traefik setup. The first part explains the basics: entrypoints, routers, labels and certificates. Here it’s about the question Traefik itself doesn’t answer: should this request get in at all? A reverse proxy decides where a request goes. The layers in this article decide whether.

The layers at a glance

Every request to port 443 passes these checks in order. Each one is cheap, and each one stops a different kind of visitor:

#LayerStopsApplies to
1GeoBlockEverything from countries I don’t allowall services
2CrowdSec (IP reputation)IP addresses known for attacks, and IPs that misbehave on my serverall services
3CrowdSec AppSec (WAF)Requests that look like attacks: SQL injection, path traversal, known exploitsall services
4Single sign-on (Authentik)Everyone who isn’t logged inapps without their own good login
5The app’s own loginEveryone without an accounteach app

No single layer is perfect. GeoBlock is fooled by a VPN, CrowdSec doesn’t know every attacker, and every login can have a bug. Together, they turn “anyone on the internet” into “a few people, in a few countries, with a valid login”.

1. One line for all services

You could add middlewares to every router separately. I attach the two global ones to the websecure entrypoint instead, in Traefik’s static configuration. That way no service can be forgotten, including the ones I add next year:

    command:
      # ... the configuration from part 1 ...

      # Security for EVERY site on 443: first GeoBlock, then CrowdSec, then security headers (step 5).
      # To switch both off quickly: comment out this line and run docker compose up -d
      - "--entrypoints.websecure.http.middlewares=geoblock@docker,crowdsec@docker,secheaders@docker"

      # The two plugins, downloaded from plugins.traefik.io at start
      - "--experimental.plugins.geoblock.modulename=github.com/PascalMinder/geoblock"
      - "--experimental.plugins.geoblock.version=v0.3.8"
      - "--experimental.plugins.crowdsec.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
      - "--experimental.plugins.crowdsec.version=v1.7.1"
  • The order matters: GeoBlock runs first because it’s the cheapest check. Requests from blocked countries never reach CrowdSec, which keeps its logs and work small.
  • @docker means the middleware is defined with Docker labels, here on the Traefik container itself (next sections).
  • Plugin versions are pinned, for the same reason as the Traefik image in part 1: a new plugin version can change options.
  • Port 80 is not covered, and that’s fine: it only answers with a redirect to HTTPS, which then passes every check.

Tip: the comment above the line is deliberate. If you ever lock yourself out (for example while travelling), one commented-out line and a restart turn all filtering off. Write that down somewhere you’ll find it under stress.

2. GeoBlock: only the countries you need

My services are used by my family, all of us in two European countries. There’s no reason for anyone in other countries to reach my login pages. GeoBlock looks up the country of each visitor’s IP address and only lets the allowed ones through. These labels go on the Traefik container:

    labels:
      # --- GeoBlock: only allow listed countries (+ private/home IPs) ---
      - "traefik.http.middlewares.geoblock.plugin.geoblock.countries[0]=DE"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.countries[1]=AT"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowLocalRequests=true"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowUnknownCountries=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.unknownCountryApiResponse=nil"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.blackListMode=false"

      # Exceptions: a service that has to reach you from abroad
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowedIPAddresses[0]=203.0.113.0/24"

      # Country lookup
      - "traefik.http.middlewares.geoblock.plugin.geoblock.api=https://get.geojs.io/v1/ip/country/{ip}"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.apiTimeoutMs=750"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.cacheSize=100"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.forceMonthlyUpdate=true"

      # Logging: only denied requests, into their own file
      - "traefik.http.middlewares.geoblock.plugin.geoblock.silentStartUp=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logLocalRequests=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logAllowedRequests=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logApiRequests=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logFilePath=/var/log/traefik/geoblock.log"
  • countries: the allowed countries as two-letter codes. Mine are different; pick your own. With blackListMode=false this is an allow list: everything not listed is blocked. The opposite (block a few countries, allow the rest) protects much less.
  • allowLocalRequests=true: private addresses (your home network) are always allowed. Without it, you lock yourself out at home as soon as a request arrives with a local address.
  • allowUnknownCountries=false: if the lookup finds no country, the request is blocked. Safer, at the price of a rare false block.
  • allowedIPAddresses: exceptions for services that call you from another country. In my case that’s an AI assistant’s connector to Home Assistant, which comes from the provider’s published IP range in the US. Only allow ranges the provider publishes officially, never a guessed one.
  • Lookup and cache: unknown IP addresses are looked up with an online service (geojs.io here), at most 750 ms. The last 100 results are cached, so repeat visitors don’t cause new lookups.
  • Log file: every blocked request ends up in geoblock.log. It’s surprisingly instructive to read it after a day: most of it is automated scanning.

Think about this before you switch it on: you’ll be blocked yourself when you travel to another country, unless you connect through a VPN to your home network first. And the country lookup sends visitors’ IP addresses to an external service. For a private homelab that only family uses, that’s fine for me; for a public website with visitors, it belongs in the privacy policy, or you use an offline country database instead.

Let’s Encrypt still works with GeoBlock on. The TLS challenge from part 1 is answered by Traefik before any middleware runs, so the validation servers abroad aren’t blocked.

3. CrowdSec: known attackers and attack patterns

CrowdSec does two things. It reads Traefik’s access log and notices when an IP address behaves like an attacker (many failed logins, scanning for /wp-login.php on a server without WordPress, and so on). And it shares this with a community list, so you also block addresses that attacked someone else yesterday. In addition, its AppSec component works as a web application firewall (WAF): it checks each request against rules for common attacks before Traefik forwards it.

There are two parts: the CrowdSec engine (its own container) and a bouncer inside Traefik that asks the engine about every request.

The engine

  crowdsec:
    image: crowdsecurity/crowdsec:v1.8.1
    container_name: crowdsec
    restart: unless-stopped
    environment:
      # Detection rules: Traefik log parser, common web attacks, known CVEs, AppSec (WAF)
      COLLECTIONS: "crowdsecurity/traefik crowdsecurity/base-http-scenarios crowdsecurity/http-cve crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
      # Registers the Traefik bouncer with the key from .env
      BOUNCER_KEY_traefik: ${CROWDSEC_BOUNCER_KEY}
      TZ: Europe/Berlin
    volumes:
      - ./crowdsec/config:/etc/crowdsec
      - ./crowdsec/data:/var/lib/crowdsec/data
      - ./crowdsec/acquis.d:/etc/crowdsec/acquis.d:ro
      - ./traefik-logs:/var/log/traefik:ro
    networks:
      - proxy
    # No published ports: Traefik reaches it as crowdsec:8080 and crowdsec:7422
  • Collections are rule bundles from the CrowdSec hub: a parser for Traefik’s log format, scenarios for typical web attacks and known vulnerabilities (CVEs), and the AppSec rule sets.
  • BOUNCER_KEY_traefik registers Traefik as a bouncer with a key you choose. Create one and put it in the .env file next to the compose file, never in the compose file itself: echo "CROWDSEC_BOUNCER_KEY=$(openssl rand -hex 32)" >> .env.
  • Traefik’s log folder is mounted read-only, so CrowdSec can read the access log from part 1.
  • No published ports: only Traefik talks to CrowdSec, through the shared proxy network.

What CrowdSec reads is defined in crowdsec/acquis.d/traefik.yaml:

# 1. Traefik access log: brute force, scans, known exploits
filenames:
  - /var/log/traefik/access.log
labels:
  type: traefik
---
# 2. AppSec (WAF): Traefik sends every request here before passing it on
source: appsec
name: appsec
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/appsec-default
labels:
  type: appsec

The bouncer in Traefik

    labels:
      # --- CrowdSec bouncer ---
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.enabled=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecMode=stream"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecLapiScheme=http"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecLapiHost=crowdsec:8080"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecLapiKey=${CROWDSEC_BOUNCER_KEY}"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.updateIntervalSeconds=60"
      # AppSec (WAF)
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecEnabled=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecHost=crowdsec:7422"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecFailureBlock=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecUnreachableBlock=false"
      # never ban the home network
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.clientTrustedIPs[0]=192.168.10.0/24"
  • crowdsecMode=stream: the bouncer downloads the full list of blocked addresses every 60 seconds and checks requests locally. That’s fast, and Traefik doesn’t have to ask CrowdSec for each request.
  • crowdsecAppsecFailureBlock=true: if AppSec can’t decide about a request (an error in the check), the request is blocked.
  • crowdsecAppsecUnreachableBlock=false: if the CrowdSec container is down, requests are let through. That’s a deliberate trade-off: during a CrowdSec update, my services stay online and GeoBlock still filters. If you’d rather be offline than unprotected, set it to true.
  • clientTrustedIPs: your home network is never banned. Without it, a family member typing the wrong password five times can lock out the whole house.

Tip: Docker Compose fills ${CROWDSEC_BOUNCER_KEY} from the .env file in labels too, so the key appears in neither file you’d share or back up publicly. Keep .env out of any Git repository.

Everyday commands

# which addresses are blocked right now, and why?
docker exec crowdsec cscli decisions list

# what was detected recently?
docker exec crowdsec cscli alerts list

# is the log being read, are the bouncer and AppSec talking to CrowdSec?
docker exec crowdsec cscli metrics

# unblock an address (e.g. a friend who got banned)
docker exec crowdsec cscli decisions delete --ip 198.51.100.7

# block an address by hand for 24 hours
docker exec crowdsec cscli decisions add --ip 198.51.100.7 --duration 24h

The access and GeoBlock logs grow every day, so I rotate them with logrotate on the host (7 days, compressed). copytruncate keeps the file in place, so neither Traefik nor CrowdSec needs a restart:

# /etc/logrotate.d/traefik
/path/to/traefik/traefik-logs/*.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    copytruncate
}

4. Single sign-on in front of apps

Some apps have no login of their own, or a weak one: Traefik’s dashboard, the Zigbee2MQTT frontend, small admin tools. For those, Traefik asks my identity provider Authentik before every request: is this person logged in, and allowed to use this app? If not, they see Authentik’s login page, with two-factor authentication. This is called forward auth.

The middleware is defined once, with labels on the Authentik server container:

    labels:
      # Embedded outpost path (needed for forward auth on other domains)
      - traefik.http.routers.authentik-outpost.rule=HostRegexp(`.+`) && PathPrefix(`/outpost.goauthentik.io/`)
      - traefik.http.routers.authentik-outpost.priority=1000
      - traefik.http.routers.authentik-outpost.entrypoints=websecure
      - traefik.http.routers.authentik-outpost.tls=true
      - traefik.http.routers.authentik-outpost.service=authentik

      # Forward-auth middleware for apps WITHOUT their own login
      - traefik.http.middlewares.authentik.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik
      - traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true
      - traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid
  • The outpost router catches the path /outpost.goauthentik.io/ on every host name and sends it to Authentik. That’s where the login redirect comes back to. priority=1000 makes sure it wins against the app’s own router.
  • forwardauth.address: Traefik asks this URL about every request. Authentik answers “yes” (the request continues) or with a redirect to the login page.
  • authResponseHeaders: after a successful login, Authentik passes the user name, groups and e-mail to the app as headers. Apps that understand them can log the user in automatically.

Protecting an app is then one more label on its router, here for the Traefik dashboard from part 1:

      - "traefik.http.routers.traefik.middlewares=authentik@docker"

In Authentik, each protected app needs a Proxy Provider in forward-auth mode (single application) and an application that is bound to the users or groups allowed in. Without that, Authentik doesn’t know the host name and refuses everyone.

Not for every app: forward auth only works for browsers. Mobile apps and other clients that talk to an API (the Home Assistant app, password manager apps, sync clients) can’t show Authentik’s login page and simply stop working. For those apps I use their native single sign-on through OpenID Connect where they support it (I explained it for WordPress in this article), and otherwise rely on their own login plus the layers above.

5. Security headers for every service

Security headers are short instructions that the server sends to the browser with each page. Many apps set some of them, but rarely all, and every app does it differently. A third middleware on the entrypoint adds the important ones to every service and removes the headers that only tell attackers which software and version you run. These labels go on the Traefik container, and the entrypoint line from step 1 gets a third entry: geoblock@docker,crowdsec@docker,secheaders@docker.

    labels:
      # --- Security headers for every site on 443 ---
      # HSTS: browsers use https only, for 1 year
      - "traefik.http.middlewares.secheaders.headers.stsSeconds=31536000"
      - "traefik.http.middlewares.secheaders.headers.contentTypeNosniff=true"
      # Hide software names and versions (an empty value removes the header)
      - "traefik.http.middlewares.secheaders.headers.customResponseHeaders.Server="
      - "traefik.http.middlewares.secheaders.headers.customResponseHeaders.X-Powered-By="
  • HSTS (stsSeconds): after the first visit, the browser refuses to open the site over plain HTTP for one year, even if someone tries to redirect it there in a public Wi-Fi. I left out includeSubdomains and preload on purpose: they also bind every other subdomain of your domain to HTTPS, and preload is very hard to undo.
  • contentTypeNosniff: the browser trusts the declared file type and doesn’t guess. That stops an uploaded “image” from being run as a script.
  • Removing Server and X-Powered-By: before, my services told everyone things like Server: nginx or X-Powered-By: PHP/8.5. That doesn’t make anything vulnerable, but it saves an attacker the first step of looking up exploits for exactly that version.

Not every header belongs here: X-Frame-Options and a Content Security Policy depend on the app. My office suite is shown inside Nextcloud in a frame; a central “never show in a frame” header would break it. Those I leave to the apps, which mostly set them correctly anyway. After adding the middleware, open every service once and check that it still works.

Checking takes one command per service. You should see strict-transport-security and x-content-type-options, and no server line:

curl -s -o /dev/null -D - https://app.example.com/ | grep -i -E "strict-transport|x-content-type|^server|x-powered"

6. Container basics

A few small settings on the containers themselves, which cost nothing:

  traefik:
    image: traefik:v3.7
    security_opt:
      - no-new-privileges:true
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      # ...
  • no-new-privileges: processes in the container can’t gain more rights later (for example through a setuid program). If someone ever breaks into Traefik, this makes the next step harder.
  • Docker socket read-only: Traefik only needs to read container information. Be aware that :ro only protects the socket file; the Docker API behind it still accepts commands. A stricter solution is a socket proxy, a small container that only lets through the read requests Traefik needs. I use one for my monitoring portal and plan to switch Traefik to it too.
  • No published ports on any service except Traefik (80 and 443). Everything else is only reachable through Traefik and therefore through all the layers above. A forgotten ports: line bypasses all of them.
  • exposedbydefault=false from part 1 belongs here too: nothing is public by accident.

7. Testing that it works

Don’t trust a security setup you haven’t tested. These checks take five minutes. Run the attack tests from outside your home network (mobile data, not Wi-Fi), because your home network is on every allow list:

# AppSec should block typical attack requests with 403
curl -s -o /dev/null -w "%{http_code}n" "https://app.example.com/?id=1%27%20OR%20%271%27=%271"
curl -s -o /dev/null -w "%{http_code}n" "https://app.example.com/../../etc/passwd"
curl -s -o /dev/null -w "%{http_code}n" "https://app.example.com/.env"

# did CrowdSec see it?
docker exec crowdsec cscli alerts list | head

# GeoBlock: requests from other countries show up here
tail -n 5 traefik-logs/geoblock.log

# forward auth: without a login you should be sent to Authentik (302)
curl -s -o /dev/null -w "%{http_code} %{redirect_url}n" https://traefik.example.com/

To test GeoBlock from another country, use a VPN with an exit in a blocked country, or one of the many online “check website from another location” services. You should get a 403.

What I don’t do (yet)

  • Central frame and content policies (X-Frame-Options, Content Security Policy): they depend too much on each app, so the apps set them themselves (see step 5).
  • Custom TLS options: not needed for me. I checked with openssl s_client: Traefik’s defaults already refuse TLS 1.0 and 1.1 and only accept 1.2 and 1.3.
  • Rate limiting: Traefik’s ratelimit middleware on login pages. CrowdSec covers most of it by banning brute-force attempts.
  • The socket proxy for Traefik, as mentioned above.

What I learned

  • Entrypoint middlewares beat per-router middlewares: new services are protected from their first second, without me remembering anything.
  • Allow lists beat block lists. Listing the two countries I need is safer and simpler than listing the hundred I don’t.
  • Always exempt the home network, in GeoBlock and in CrowdSec. Otherwise the first person locked out is you.
  • Decide what happens when a security layer fails: block everything, or let traffic through. Both are valid, but it should be a decision, not a default you never looked at.
  • Test from outside. Everything looks secure from your own sofa, because your home network is on every allow list.

Everything here worked in my setup at the time of writing. Software changes, and your setup is different: back up first, test carefully, and use the steps at your own risk. Details in the legal notice.