Skip to content

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 to wildcard
  • VALIDATION: Change to dns

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

homelab.conf
# ------------------------------------ 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".

docker exec -it crowdsec cscli bouncers add 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:

docker exec -it crowdsec cscli bouncers list

While you're at it, check to make sure you're collections are indeed loaded:

docker exec crowdsec cscli collections list

Check that the nginx logs are indeed being parsed when you access your sites:

docker exec crowdsec cscli metrics

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:

docker exec crowdsec cscli decisions list

To put a 1 minute ban on your IP to test a decision, use the following:

docker exec crowdsec cscli decisions add --ip <YOUR IP> -d 1m --type ban

To test the equivalent of a ban based on unauthorized attempts, first disable the fail2ban nginx-unauthorized jail.

docker exec -it swag bash
fail2ban-client stop nginx-unauthorized

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:

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

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.

allow 100.64.0.0/10;

Fail2Ban

To unban an IP

sudo fail2ban-client set <jail-name> unbanip <IP_ADDRESS>

To whitelist an IP edit the jail.local or the specific .local file inside /etc/fail2ban/jail.d/ and add:

ignoreip = 127.0.0.1/8 ::1 203.0.113.42

Then restart fail2ban

systemctl restart fail2ban

sudo fail2ban-client set nginx-unauthorized unbanip 100.77.47.82

Comments