I run several self-hosted apps behind NPMplus (the ZoeyVid fork of Nginx Proxy Manager). They are reachable over my domain without any open ports other than the reverse proxy, but “reachable from the whole internet” is more exposure than most of them need. None of mine have to be accessible from outside the DACH region, so one cheap extra layer is country-level geoblocking: anything from outside the countries I care about simply gets dropped.
NPMplus has GeoIP2 built in, and the important part is that you can apply it per proxy host (or even per location), not just globally. So one host can be locked to Germany and Switzerland while the rest stay open. Here are the two recipes I use.
How it works
Under the hood this is nginx’s GeoIP2 module reading MaxMind’s free GeoLite2-Country database. You enable it once, define a map that turns a visitor’s country code into an allow/deny decision, and then enforce that decision on whichever hosts you want. It is IP-based, so it is a speed bump, not a wall: a VPN exit inside Germany defeats it, and GeoLite2 is country-accurate but not perfect. It is still very effective at cutting off the background noise of bots and scanners from networks that have no business reaching you.
1. Enable GeoIP in the compose file
In the NPMplus compose.yaml, uncomment the two GeoIP lines and add the MaxMind updater sidecar. The database needs a free MaxMind account (account ID + license key); the geoipupdate container downloads it and keeps it fresh:
|
1 2 3 4 5 6 7 8 9 10 |
geoipupdate: container_name: npmplus-geoipupdate image: ghcr.io/maxmind/geoipupdate:latest restart: always network_mode: host environment: - "TZ=Europe/Zurich" - "GEOIPUPDATE_EDITION_IDS=GeoLite2-Country" - "GEOIPUPDATE_ACCOUNT_ID=YourAccountID" - "GEOIPUPDATE_LICENSE_KEY=YourLicenseKey" |
Recreate the stack afterwards. The database lands at /data/goaccess/geoip/GeoLite2-Country.mmdb, which is the path the config below expects.
2. Load the database and define the rules
This goes in http_top.conf. First load the database and extract the ISO country code, then define the maps. I define two independent rules so I can use a whitelist on some hosts and a blocklist on others from the same instance:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
geoip2 /data/goaccess/geoip/GeoLite2-Country.mmdb { auto_reload 60m; $geoip2_country_iso_code default=ZZ country iso_code; } # Whitelist: only Germany and Switzerland are allowed map $geoip2_country_iso_code $geoip2_country_rule { default no; DE yes; CH yes; } # Blocklist: everyone allowed except Russia map $geoip2_country_iso_code $geoip2_block_ru { default yes; # yes = allowed RU no; # no = blocked } # Never block your own LAN geo $is_private_ip { default no; 10.0.0.0/8 yes; 172.16.0.0/12 yes; 192.168.0.0/16 yes; } |
ZZ is the fallback for IPs the database can’t resolve. With default no on the whitelist, unknown countries are denied; flip the host-level logic below if you’d rather let unknowns through.
3. Whitelist a host: Germany and Switzerland only
For the host you want locked down, open its Advanced tab in the NPMplus UI and paste:
|
1 2 3 4 5 6 7 8 |
# let the LAN through regardless of country if ($is_private_ip = yes) { set $geoip2_country_rule yes; } # let Let's Encrypt validate (see the gotcha below) if ($request_uri ~* "^/\.well-known/acme-challenge/") { set $geoip2_country_rule yes; } # drop everything that isn't DE or CH if ($geoip2_country_rule = no) { return 444; } |
return 444 closes the connection silently, which is what I want for a host nobody outside DE/CH should be touching. Use return 403 instead if you’d rather send a visible “Forbidden” page. Hosts without this block are unaffected and stay reachable from anywhere, which is exactly the point – one host locked down, the rest open.
If you only want to protect one path rather than the whole host, you can set the same rules per custom location using the gear button on that location instead of the Advanced tab.
4. Blocklist a host: everyone except one country
Sometimes you don’t want a whitelist at all – you want the host public, minus a specific troublemaker. That’s the second map. On that host’s Advanced tab:
|
1 2 |
# block Russia, allow everyone else if ($geoip2_block_ru = no) { return 403; } |
That’s the whole thing. Note there is no ACME exemption here and none is needed: Let’s Encrypt’s validation servers aren’t in the blocked country, so certificate renewals keep working. The exemption only matters for whitelists.
The Let’s Encrypt gotcha
This one cost people a lot of confused debugging, so it’s worth stating plainly. On a strict whitelist, Let’s Encrypt certificate issuance and renewal break. The ACME HTTP-01 challenge is validated by servers that hit your host from outside your allowed countries, so your own whitelist returns 444/403 to them and validation fails with a generic internal error.
The fix is the acme-challenge exemption already in the step 3 block: it force-allows requests to /.well-known/acme-challenge/ before the country check runs. Without that line, the only “workaround” is to temporarily whitelist all countries whenever a cert is due – which defeats the purpose. Add the exemption once and forget about it.
Gotchas
- It’s IP geolocation, not identity. A VPN or proxy exit inside DE/CH walks straight through, and a legitimate user travelling abroad gets locked out. I keep a VPN exit in my home country for exactly that case.
- Don’t forget your own LAN. Depending on how traffic reaches the proxy, internal clients can resolve to an unknown country and get dropped. The
$is_private_ipcheck prevents that. - The whitelist map is global. Every host that enforces
$geoip2_country_ruleshares the same DE/CH list. If you need different country sets per host, define another map with its own variable name, like the Russia one. - Don’t put the raw geoip2 block straight into a host’s Advanced tab. It belongs in
http_top.conf; the Advanced tab only gets theifenforcement lines. Mixing them up is the fastest way to break cert renewals. - Keep the database updated. That’s what the
geoipupdatesidecar andauto_reload 60mare for. A stale database slowly misroutes newly reassigned IP ranges.
Result
My sensitive hosts now answer only from Germany and Switzerland, my LAN is always let through, and certificates renew untouched thanks to the ACME exemption. The blocklist variant lets me keep a host public while quietly dropping one region. Both are a few lines of config and one shared database – a cheap, effective layer on top of everything else guarding the reverse proxy.