Tech Expert & Vibe Coder

With 15+ years of experience, I specialize in self-hosting, AI automation, and Vibe Coding – building applications using AI-powered tools like Google Antigravity, Dyad, and Cline. From homelabs to enterprise solutions.

Implementing Geo-Blocking for Self-Hosted Services: Using GeoIP2 with Traefik Middleware to Restrict Access by Country

Why I worked on this

I run a handful of small web services on my Proxmox box—nothing fancy, just a handful of Docker containers behind Traefik. One evening I noticed a steady trickle of login attempts on a private service that should never see traffic from outside my country. The requests came from IPs that MaxMind pinned to places I’ve never been. I already had fail2ban running, but I wanted to cut the noise earlier, at the edge, before the request even reached the application. Geo-blocking seemed like the cleanest way to do it.

My real setup or context

  • Traefik 3.0 running in a Docker container on a single Proxmox VM.
  • Services declared in separate compose.yml files on the same custom external network called traefik-proxy.
  • Let’s Encrypt wildcard cert handled by Traefik via Cloudflare DNS challenge.
  • I already used labels for routing; I wanted to keep the geo-block config in the same style—no separate Apache, no pfSense, just Traefik.

What worked (and why)

1. Picked the nscuro plugin

I tried the traefik-plugin-geoblock because it’s a pure Go plugin and doesn’t need Pilot anymore. The static config lives in my traefik.yml:

experimental:
  plugins:
    geoblock:
      moduleName: github.com/nscuro/traefik-plugin-geoblock
      version: v0.11.0

2. Mounted the GeoIP2 database

MaxMind’s free “GeoLite2-Country” mmdb is enough. I created a folder on the host:

mkdir -p /opt/traefik/geoip

Downloaded the mmdb from my MaxMind account (you need one, but it’s free) and placed it there. Then bind-mounted it into the Traefik container:

volumes:
  - /opt/traefik/geoip:/geoip:ro

3. Declared the middleware in a file provider

I keep dynamic config in /opt/traefik/dynamic/geoip.yml:

http:
  middlewares:
    geo-allow-de:
      plugin:
        geoblock:
          enabled: true
          databaseFilePath: /geoip/GeoLite2-Country.mmdb
          allowedCountries: ["DE"]
          allowPrivate: true
          disallowedStatusCode: 403

The file provider is loaded by adding this line to traefik.yml:

providers:
  file:
    directory: /dynamic
    watch: true

Bind-mounted that folder too:

volumes:
  - /opt/traefik/dynamic:/dynamic:ro

4. Attached the middleware with a label

On any service that should be Germany-only I just add:

labels:
  - traefik.http.routers.myapp.middlewares=geo-allow-de@file

Reload the container and Traefik picks it up within seconds. Requests from elsewhere get a plain 403, no further logs in the app.

5. Verified with a VPN

I connected to a server in the Netherlands, hit the endpoint, and got 403. Disconnected, hit again from my German IP, and the page loaded. That was enough confirmation for me.

What didn’t work

  • First I forgot the allowPrivate: true flag and my own LAN IPs (192.168.x.x) started receiving 403s. Took me a moment to realise the plugin treats private ranges as “unknown country” unless explicitly allowed.
  • I initially tried to stuff the middleware definition into a Docker label as a multi-line string. Traefik swallowed it without error but never applied the plugin. File provider is the only way that actually worked.
  • The plugin caches the mmdb on start-up; if you swap the file you must restart the Traefik container. A simple config hot-reload isn’t enough.

Key takeaways

  1. Geo-blocking at the reverse-proxy layer is straightforward once you accept that Traefik plugins need a file provider—labels alone won’t do.
  2. MaxMind’s free country database is accurate enough for casual use; updates arrive monthly and I automate the download with a cron job that restarts Traefik afterwards.
  3. Always whitelist private ranges unless you want to lock yourself out while debugging from the couch.
  4. The plugin returns a clean 403, so failed requests never hit your application logs—exactly the noise reduction I wanted.

Leave a Comment

Your email address will not be published. Required fields are marked *