Country-based geoblocking in NPMplus: allow only Germany and Switzerland (or block a single country)

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:

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:

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:

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:

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_ip check prevents that.
  • The whitelist map is global. Every host that enforces $geoip2_country_rule shares 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 the if enforcement lines. Mixing them up is the fastest way to break cert renewals.
  • Keep the database updated. That’s what the geoipupdate sidecar and auto_reload 60m are 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.

Leave a comment