Setting up the best Nginx container: SWAG¶
In this post I will detail the setup instructions for SWAG, Crowdsec, MaxMind's GeoIP2, and the SWAG Dashboard. This is truly nginx on steroids because SWAG makes everything so easy to set up.
SWAG Setup¶
Docker¶
We'll start the the docker-compose.yml file provided by the swag documentation here: https://docs.linuxserver.io/general/swag/#docker-compose.
services:
swag:
image: lscr.io/linuxserver/swag
container_name: swag
cap_add:
- NET_ADMIN
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/London
- URL=yourdomain.url
- SUBDOMAINS=www,
- VALIDATION=http
- CERTPROVIDER= #optional
- DNSPLUGIN=cloudflare #optional
- EMAIL=<e-mail> #optional
- ONLY_SUBDOMAINS=false #optional
- EXTRA_DOMAINS=<extradomains> #optional
- STAGING=false #optional
volumes:
- ./swag:/config
ports:
- 443:443
- 80:80 #optional
restart: unless-stopped
The documentation explains quite a bit, but I will note the things I am changing in this compose file.
SUBDOMAINS
: Change towildcard
VALIDATION
: Change todns
The primary domain for your self hosted apps will be set to URL
, but you can add extra domains to the EXTRA_DOMAINS
variable.
I also prefer running my reverse proxy in host mode so I set network_mode: "host"
below the container name. You will need port 9000 free in addition to the http and https ports.
Cloudflare API for DNS Challenge¶
If you run the container, you'll notice in the logs that it has an error indicating that it wasn't able to authenticate with the DNS provider. In our persisted bind mount folder swag
, open up the dns-conf
folder and go to the .ini
file for your dns provider. For me, that is cloudflare.ini
. You should set the dns_cloudflare_api_token
token to a token that you check out on cloudflare. Go get an API token that has DNS edit rights to the zones that you want.
Restart the container again, and this time you should see that certbot is able to fetch SSL certs. In the log/letsencrypt/letsencrypt.log
file you should see records that certificates were requested and received, i.e. something like this:
{
"status": "valid",
"expires": "2025-08-05T17:54:53Z",
"identifiers": [
{
"type": "dns",
"value": "*.domain.com"
},
{
"type": "dns",
"value": "domain.com"
}
],
"authorizations": [
"https://acme-v02.api.letsencrypt.org/acme/authz/",
"https://acme-v02.api.letsencrypt.org/acme/authz/"
],
"finalize": "https://acme-v02.api.letsencrypt.org/acme/finalize/",
"certificate": "https://acme-v02.api.letsencrypt.org/acme/cert/"
}
Site configs¶
You can see a bunch of example files inside the nginx/proxy-confs
files and I like to keep those there. For my own sites, I use the nginx/site-confs
folder. In fact, I like to put all my sub domain apps in the same .conf
file, just because they're so similar I don't care to have a bunch of different files. This is what your standard config file looks like
# ------------------------------------ HTTP to HTTPS redirect ------------------------------------
server {
listen 80;
listen [::]:80;
# Any hosts that should redirect HTTP to HTTPS (Cloudflare hosts not needed)
server_name *.domain.com *.tail.domain.com *.local.domain.com;
return 301 https://$host$request_uri;
}
# ------------------------------------------ PROXY HOSTS ------------------------------------------
# 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;
}
}
# More apps...
#
#
It doesn't hurt to take a look at nginx.conf
to see what all the default settings are that SWAG set up for you.
Crowdsec¶
Crowdsec Agent in Docker¶
Before we setup the nginx bouncer inside of the SWAG, let's go ahead and install Crowdsec, the actual engine that is parsing files and making decisions. We can find the docker compose instructions here: https://docs.crowdsec.net/u/getting_started/installation/docker/
The main thing we need to change is that we need to point to our nginx logs file. Here is my crowdsec
compose:
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" # optional 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 # Mount your Nginx logs here
- /var/log/auth.log:/var/log/auth.log:ro # (optional) SSH auth log
Then in your crowdsec/config/acquis.yaml
file which will be generated for you, you should already have a nginx parser configured. I changed it to only look at the access.log
file. I'm also creating a json_access.log file that I don't need it parsing. So that should look like this:
filenames:
- /var/log/nginx/access.log
# - /var/log/nginx/*.log
# - ./tests/nginx/nginx.log
#this is not a syslog log, indicate which kind of logs it is
labels:
type: nginx
SWAG Bouncer¶
Follow the instructions here: https://github.com/linuxserver/docker-mods/tree/swag-crowdsec
Go ahead and generate an api key by running the following. We're giving this an arbitrary name of "swag".
This will generate and API key. Copy it and paste it somewhere temporary.
Now we need to add the following to our swag docker compose file under environment
:
- DOCKER_MODS=ghcr.io/linuxserver/mods:swag-crowdsec
- CROWDSEC_API_KEY=<the-api-key>
- CROWDSEC_LAPI_URL=http://<server-lan-url>:8080
Info
If you already have DOCKER_MODS
, separate the mods with a |
Now for the server-lan-url, this just kind of depends on how you set up your docker networking. Assuming you binded the crowdsec port 8080 to the host, you can use the server lan url here.
Restart the docker stack.
You should now see a crowdsec-nginx-bouncer.conf
file under your bind mount swag/crowdsec
.
You should now see an active bouncer when you run the following:
While you're at it, check to make sure you're collections are indeed loaded:
Check that the nginx logs are indeed being parsed when you access your sites:
You should see something like:
+--------------------------------------------------------------------------------------------------------------------------+
| Acquisition Metrics |
+--------------------------------+------------+--------------+----------------+------------------------+-------------------+
| Source | Lines read | Lines parsed | Lines unparsed | Lines poured to bucket | Lines whitelisted |
+--------------------------------+------------+--------------+----------------+------------------------+-------------------+
| file:/var/log/nginx/access.log | 791 | 791 | - | 313 | 14 |
+--------------------------------+------------+--------------+----------------+------------------------+-------------------+
Any decision made can be seen with:
To put a 1 minute ban on your IP to test a decision, use the following:
To test the equivalent of a ban based on unauthorized attempts, first disable the fail2ban
nginx-unauthorized
jail.
Now you can enter the wrong password and attempt several times and see what happens
GeoIP2¶
For the MaxMind GeoIP2Lite module follow the instructions here: https://github.com/linuxserver/docker-mods/tree/swag-maxmind
The instructions are quite clear so I don't think I really need to repeat them.
I did make some changes to nginx/maxmind.conf
which is generated after restarting the app.
- I added the country name to a variable:
$geoip2_data_country_name country names en
, I configured the Whitelist to be US, the black list to exclude certain countries, and I added the Tailscale network to the$lan-ip
mapping
geoip2 /config/geoip2db/GeoLite2-City.mmdb {
auto_reload 1w;
$geoip2_data_city_name city names en;
$geoip2_data_postal_code postal code;
$geoip2_data_latitude location latitude;
$geoip2_data_longitude location longitude;
$geoip2_data_state_name subdivisions 0 names en;
$geoip2_data_state_code subdivisions 0 iso_code;
$geoip2_data_continent_code continent code;
$geoip2_data_country_iso_code country iso_code;
$geoip2_data_country_name country names en;
}
# Country Codes: https://en.wikipedia.org/wiki/ISO_3166-2
map $geoip2_data_country_iso_code $geo-whitelist {
# default yes;
# Example for whitelisting a country, comment out 'default yes;' above and uncomment 'default no;' and the whitelisted country below
default no;
US yes;
}
map $geoip2_data_country_iso_code $geo-blacklist {
# default yes;
# Example for blacklisting a country, uncomment the blacklisted country below RU|CN|KP|IR
RU no;
CN no;
KP no;
IR no;
}
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 network
}
If you want to use the Whitelist in a server block, I would create a reusable .conf
file first and paste this into it:
You can then easily just reference that file like so:
# Mealie over Cloudflare Tunnel
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name mealie.domain.com;
include /config/nginx/ssl.conf;
include /config/nginx/proxy.conf;
# allow large file uploads
client_max_body_size 10M;
# Set real ip from
include /config/nginx/snippets/real_ip_from_cf_tunnel_docker.conf;
# Whitelist US IPs (block all other countries)
include /config/nginx/snippets/country-and-lan-whitelist.conf;
location / {
proxy_pass http://192.168.4.142:9925;
}
}
Custom logging with json and GeoIP data¶
I like to just create a separate json_access.log
log and not mess with the default access.log
since crowdsec is parsing that. So create a json_log.conf
file and paste the following into it
??? abstract title="json_conf.conf"
log_format json_analytics escape=json '{'
'"msec": "$msec", ' # request unixtime in seconds with a milliseconds resolution
'"connection": "$connection", ' # connection serial number
'"connection_requests": "$connection_requests", ' # number of requests made in connection
'"pid": "$pid", ' # process pid
'"request_id": "$request_id", ' # the unique request id
'"request_length": "$request_length", ' # request length (including headers and body)
'"remote_addr": "$remote_addr", ' # client IP
'"remote_user": "$remote_user", ' # client HTTP username
'"remote_port": "$remote_port", ' # client port
'"time_local": "$time_local", '
'"time_iso8601": "$time_iso8601", ' # local time in the ISO 8601 standard format
'"request": "$request", ' # full path no arguments if the request
'"request_uri": "$request_uri", ' # full path and arguments if the request
'"args": "$args", ' # args
'"status": "$status", ' # response status code
'"body_bytes_sent": "$body_bytes_sent", ' # the number of body bytes exclude headers sent to a client
'"bytes_sent": "$bytes_sent", ' # the number of bytes sent to a client
'"http_referer": "$http_referer", ' # HTTP referer
'"http_user_agent": "$http_user_agent", ' # user agent
'"http_x_forwarded_for": "$http_x_forwarded_for", ' # http_x_forwarded_for
'"http_host": "$http_host", ' # the request Host: header
'"server_name": "$server_name", ' # the name of the vhost serving the request
'"request_time": "$request_time", ' # request processing time in seconds with msec resolution
'"upstream": "$upstream_addr", ' # upstream backend server for proxied requests
'"upstream_connect_time": "$upstream_connect_time", ' # upstream handshake time incl. TLS
'"upstream_header_time": "$upstream_header_time", ' # time spent receiving upstream headers
'"upstream_response_time": "$upstream_response_time", ' # time spend receiving upstream body
'"upstream_response_length": "$upstream_response_length", ' # upstream response length
'"upstream_cache_status": "$upstream_cache_status", ' # cache HIT/MISS where applicable
'"ssl_protocol": "$ssl_protocol", ' # TLS protocol
'"ssl_cipher": "$ssl_cipher", ' # TLS cipher
'"scheme": "$scheme", ' # http or https
'"request_method": "$request_method", ' # request method
'"server_protocol": "$server_protocol", ' # request protocol, like HTTP/1.1 or HTTP/2.0
'"pipe": "$pipe", ' # "p" if request was pipelined, "." otherwise
'"gzip_ratio": "$gzip_ratio", '
'"http_cf_ray": "$http_cf_ray",'
'"geoip2_data_country_iso_code": "$geoip2_data_country_iso_code",'
'"geoip2_data_country_name": "$geoip2_data_country_name",'
'"geoip2_data_city_name": "$geoip2_data_city_name",'
'"geoip2_data_latitude":"$geoip2_data_latitude",'
'"geoip2_data_longitude":"$geoip2_data_longitude",'
'"geoip2_data_state_name": "$geoip2_data_state_name"'
'}';
access_log /config/log/nginx/json_access.log json_analytics;
Great, now we have a json log with country information that will be great for a Grafana dashboard.
SWAG Dashboard¶
Because it's so easy to set up and also displays Fail2Ban jails (in case we accidently get ourselves banned by fail2ban), go ahead and set up this mode here: https://github.com/linuxserver/docker-mods/tree/swag-dashboard
Info
This will create a dashboard.subdomain.conf
file inside of proxy-confs
. You may want to allow your tailscale network access if you use tailscale.
Fail2Ban¶
To unban an IP
To whitelist an IP edit the jail.local
or the specific .local file inside /etc/fail2ban/jail.d/
and add:
Then restart fail2ban
sudo fail2ban-client set nginx-unauthorized unbanip 100.77.47.82