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:wildcardVALIDATION: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:
# 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):
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):
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.localfile under/etc/fail2ban/jail.d/, thensystemctl restart fail2ban.
See also¶
- Security — CrowdSec + Cloudflare mTLS hardening
- Logging — GoAccess → Loki evolution
- Blog post: Setting up the best Nginx container: SWAG