Skip to content

SWAG (Secure Web Application Gateway)

TL;DR / when to use this: SWAG is the LinuxServer.io Nginx container that makes a reverse proxy with Let's Encrypt, CrowdSec, GeoIP filtering, and a built-in dashboard nearly turnkey. If you want "Nginx on steroids" without hand-rolling certbot + bouncers, use SWAG. For a bare certbot/DNS-plugin approach, see Nginx in Docker; for routing through Pangolin, see Pangolin.

Docker compose

Start from the official SWAG compose, with two changes I always make:

  • SUBDOMAINS: wildcard
  • VALIDATION: dns
services:
  swag:
    image: lscr.io/linuxserver/swag
    container_name: swag
    network_mode: "host"          # preferred: host mode (needs port 9000 free)
    cap_add:
      - NET_ADMIN
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Europe/London
      - URL=yourdomain.url
      - SUBDOMAINS=wildcard
      - VALIDATION=dns
      - DNSPLUGIN=cloudflare
      - EMAIL=<e-mail>
      - DOCKER_MODS=ghcr.io/linuxserver/mods:swag-crowdsec   # see CrowdSec below
      - CROWDSEC_API_KEY=<api-key>
      - CROWDSEC_LAPI_URL=http://<server-lan-url>:8080
    volumes:
      - ./swag:/config
    ports:
      - 443:443
      - 80:80
    restart: unless-stopped

The primary domain for self-hosted apps goes in URL; extra domains go in EXTRA_DOMAINS.

Cloudflare API for the DNS challenge

First run will log an auth error. In the persisted swag config folder, open dns-conf/cloudflare.ini and set dns_cloudflare_api_token to a Cloudflare API token with DNS edit rights on your zones. Restart; certbot should fetch the wildcard cert. Confirm in log/letsencrypt/letsencrypt.log that certs were requested and received for *.domain.com and domain.com.

Site configs

Example files live in nginx/proxy-confs — I keep them. For my own apps I use nginx/site-confs, and since subdomain blocks are nearly identical, I keep all apps in one file:

homelab.conf
# HTTP -> HTTPS redirect
server {
    listen 80;
    listen [::]:80;
    server_name *.domain.com *.tail.domain.com *.local.domain.com;
    return 301 https://$host$request_uri;
}

# Mealie
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name mealie.domain.com;

    include /config/nginx/ssl.conf;
    include /config/nginx/proxy.conf;

    location / {
        proxy_pass http://127.0.0.1:9925;
    }
}

Worth reading nginx.conf to see the defaults SWAG sets up.

CrowdSec

SWAG alone doesn't make decisions — run the CrowdSec engine as its own container, pointed at SWAG's logs:

  crowdsec:
    image: crowdsecurity/crowdsec:latest-debian
    container_name: crowdsec
    restart: unless-stopped
    environment:
      COLLECTIONS: >
        crowdsecurity/nginx
        crowdsecurity/http-cve
        crowdsecurity/appsec-generic-rules
        crowdsecurity/appsec-virtual-patching
    ports:
      - "6060:6060"   # metrics
      - "8080:8080"   # local API for bouncers
    volumes:
      - ./crowdsec/config:/etc/crowdsec
      - ./crowdsec/data:/var/lib/crowdsec/data
      - ./swag/log/nginx:/var/log/nginx:ro
      - /var/log/auth.log:/var/log/auth.log:ro

Trim acquis.yaml to parse only access.log (the JSON log is not a syslog file):

filenames:
  - /var/log/nginx/access.log
labels:
  type: nginx

Then add the SWAG bouncer mod: generate an API key with docker exec -it crowdsec cscli bouncers add swag, add DOCKER_MODS, CROWDSEC_API_KEY, and CROWDSEC_LAPI_URL to the SWAG environment (pipe-separate multiple mods), and restart. Verify:

docker exec -it crowdsec cscli bouncers list
docker exec crowdsec cscli collections list
docker exec crowdsec cscli metrics          # confirm nginx logs are parsed
docker exec crowdsec cscli decisions list
docker exec crowdsec cscli decisions add --ip <YOUR IP> -d 1m --type ban   # test a ban

GeoIP2 (MaxMind)

Follow the swag-maxmind mod. My maxmind.conf adds the country name, whitelists US, blacklists a few countries, and maps the Tailscale range as LAN:

geoip2 /config/geoip2db/GeoLite2-City.mmdb {
    auto_reload 1w;
    $geoip2_data_country_iso_code country iso_code;
    $geoip2_data_country_name country names en;
    # ... city, postal, lat/long, state as needed
}

map $geoip2_data_country_iso_code $geo-whitelist {
    default no;
    US yes;
}

geo $lan-ip {
    default no;
    10.0.0.0/8 yes;
    172.16.0.0/12 yes;
    192.168.0.0/16 yes;
    127.0.0.1 yes;
    100.64.0.0/10 yes;   # Tailscale
}

Reusable snippet (snippets/country-and-lan-whitelist.conf):

if ($lan-ip = yes) { set $geo-whitelist yes; }
if ($geo-whitelist = no) { return 403; }

Include it in any server block. Keep a separate json_access.log for analytics (Grafana) so CrowdSec's access.log parsing stays clean — full JSON log format in the original post.

Dashboard + Fail2Ban

  • Add the swag-dashboard mod — it shows Fail2Ban jails too. Allow your Tailscale network on the dashboard (allow 100.64.0.0/10;).
  • Unban an IP: sudo fail2ban-client set <jail-name> unbanip <IP>
  • Whitelist an IP: add ignoreip = ... to the jail's .local file under /etc/fail2ban/jail.d/, then systemctl restart fail2ban.

See also