A digital marketer I work with sent me a message last week. They were
burning ad spend on traffic from regions they couldn’t sell to, getting
hammered by bot requests from certain countries, and had read somewhere
that you could just “add something to .htaccess” to fix it.
They weren’t wrong. But they weren’t quite right either.
Country blocking sounds simple. In practice, the right solution depends on your web server, whether you’re behind Cloudflare, your traffic volume, and how much maintenance overhead you’re willing to carry. This guide covers the two implementations I actually use in production, plus a few things the tutorials tend to skip.
Why the question comes up
Before the how, understand the why — it shapes which solution fits. Country blocking gets asked about for four reasons:
- Ad fraud and invalid traffic — bots and click farms concentrated in specific regions inflating campaign costs.
- Regional compliance — GDPR posture, sanctions regimes, or simply not operating in certain markets.
- Bot traffic polluting analytics — making conversion data unreliable.
- Reducing server load — blocking regions with zero legitimate customers.
All valid. Country blocking addresses them — with caveats. VPNs bypass it. Sophisticated bots rotate exit nodes. It is not a fraud-prevention silver bullet. For the majority of low-effort abuse traffic though, it works well enough to be worth implementing.
Why .htaccess alone is the wrong answer
The .htaccess answer your marketer friend read about assumes
Apache with mod_authz_core and mod_setenvif (both compiled in by
default on modern Apache 2.4). It also assumes you have a source of
country-to-IP data loaded into the server. A bare .htaccess file
has no idea where a request is coming from geographically; you can
only block specific IP ranges by hand, which does not scale to
country-level blocking.
So the real question is: where does the country data come from?
Choosing your approach
Decision tree I use:
Are you proxied through Cloudflare?
|-- Yes -> use a Cloudflare Worker (free tier, easiest)
`-- No -> Running Apache?
|-- Yes -> Apache + MaxMind GeoLite2 (free, reliable)
`-- No (Nginx/Caddy) -> Nginx: ngx_http_geoip2_module
Caddy: caddy-maxmind-geolocation plugin
This guide covers the two most common cases: Apache + MaxMind and a Cloudflare Worker. The Nginx and Caddy modules follow the same MaxMind pattern as the Apache route.
Option 1: Apache + MaxMind GeoLite2
Right choice if you control your server and are running Apache. MaxMind’s GeoLite2 database is free, accurate to the country level, and updated weekly.
Step 1: Get a MaxMind licence key
Since 2020 MaxMind requires a free licence key and EULA
acceptance before you can download GeoLite2 — the days of a
bare wget are over. Sign up at
maxmind.com/en/geolite2/signup,
generate a licence key from your account, and note both the account
ID and the key for the geoipupdate step below.
Then download GeoLite2-Country.mmdb (via the account UI or
geoipupdate covered in Step 4) and place it at:
/usr/share/GeoIP/GeoLite2-Country.mmdb
Step 2: Install the Apache module
Ubuntu / Debian:
1sudo apt install libapache2-mod-maxminddb
2sudo a2enmod maxminddb
3sudo systemctl restart apache2
RHEL / AlmaLinux 9:
1sudo dnf install mod_maxminddb
Step 3: Configure your VirtualHost
Add the following to your site’s VirtualHost block. This example uses 443 with Let’s Encrypt certificates — the baseline you should be running in production anyway. See the SSH hardening and nginx-tls-2026 guides for the rest of that baseline.
1<VirtualHost *:443>
2 ServerName example.com
3
4 SSLEngine on
5 SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
6 SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
7
8 MaxMindDBEnable On
9 MaxMindDBFile COUNTRY_DB /usr/share/GeoIP/GeoLite2-Country.mmdb
10 MaxMindDBEnv MM_COUNTRY_CODE COUNTRY_DB/country/iso_code
11
12 # ISO 3166-1 alpha-2 codes. See the sanctions note in the intro
13 # before deciding what belongs on this list.
14 SetEnvIf MM_COUNTRY_CODE ^KP$ BlockCountry
15 SetEnvIf MM_COUNTRY_CODE ^IR$ BlockCountry
16 SetEnvIf MM_COUNTRY_CODE ^CU$ BlockCountry
17 SetEnvIf MM_COUNTRY_CODE ^SY$ BlockCountry
18
19 # Log the country code on every request. Useful for analytics
20 # and for tuning the block list; not just for the blocked ones.
21 LogFormat "%h %{MM_COUNTRY_CODE}e \"%r\" %>s %b" geo_combined
22 CustomLog /var/log/apache2/example.com-access.log geo_combined
23
24 <Location />
25 <RequireAll>
26 Require all granted
27 Require not env BlockCountry
28 </RequireAll>
29 </Location>
30
31 # HTTP 451 is the correct status for legally-required blocks
32 # (RFC 7725). Use 403 if the block is your commercial choice.
33 ErrorDocument 451 "Access refused for legal reasons."
34</VirtualHost>
35
36<VirtualHost *:80>
37 ServerName example.com
38 Redirect permanent / https://example.com/
39</VirtualHost>
Replace example.com with your actual domain and update the SSL
cert paths if you’re not using Certbot defaults.
The ^KP$ regex anchors are deliberate — a bare KP would
match any country code containing those letters (which happens not
to exist today but is the correct habit).
Step 4: Keep the database updated
MaxMind refreshes GeoLite2 twice a week. Use their geoipupdate
tool:
1sudo apt install geoipupdate
Edit /etc/GeoIP.conf with your MaxMind account ID and licence key,
then schedule the refresh:
1echo "17 3 * * 3 root geoipupdate" | sudo tee /etc/cron.d/geoipupdate
Runs every Wednesday at 03:17 (the randomised minute avoids the
top-of-the-hour thundering herd MaxMind sees from cron jobs). Verify
which geoipupdate before relying on the cron.
Step 5: Test before you rely on it
Confirm the module loaded and the config is valid:
1apache2ctl -M | grep maxminddb
2apachectl configtest
3sudo systemctl reload apache2
Verify a block. The cleanest way is to use curl --resolve against
your own server from a VPN exit node in a blocked country:
1curl -sI https://example.com/ | head -1
2# expect: HTTP/2 451 Unavailable For Legal Reasons
Then tail the access log during the test:
1sudo tail -f /var/log/apache2/example.com-access.log
2# entries should show KP (or whichever code) in the country field.
If you see the request pass through with a 200, either the module
did not load, the .mmdb path is wrong, or the client IP is being
seen as loopback (check your RemoteIPHeader / mod_remoteip config
if you’re behind a proxy).
Option 2: Cloudflare Worker
If your site is already proxied through Cloudflare, a Worker is the cleaner solution. It runs at the edge — the request never reaches your origin server.
Note on the free tier as of 2026: Cloudflare removed free-tier Firewall Rules for country blocking in 2024. WAF-based country blocking now needs a Pro plan (~$20/month). Workers are still available on the free tier at 100,000 requests per day per account — not per Worker — so if you run other Workers on the same account, they share the pool.
Create the Worker
Workers & Pages → Create → Create Worker:
1export default {
2 async fetch(request) {
3 const country = request.cf?.country ?? "XX";
4
5 // Sanctions blocks first (HTTP 451 per RFC 7725).
6 const sanctioned = ["KP", "IR", "CU", "SY"];
7 if (sanctioned.includes(country)) {
8 return new Response("Access refused for legal reasons.", {
9 status: 451,
10 });
11 }
12
13 // Commercial blocks (HTTP 403). Adjust to your policy.
14 const commercial_block = [];
15 if (commercial_block.includes(country)) {
16 return new Response("Access denied.", { status: 403 });
17 }
18
19 return fetch(request);
20 },
21};
The request.cf?.country fallback of "XX" handles the rare case
where Cloudflare cannot determine a country (dev tunnels, private
networks). Fail open on unknown; a fail-closed default will lock
out your own monitoring probes.
Add a route
Workers & Pages → your worker → Settings → Triggers → Add Route:
*example.com/*
Select your zone and save. The Worker intercepts every request to your domain before it reaches your server.
Test the Worker
You can synthesise a country header for local testing:
1curl -sI https://example.com/ | head -1
2# should be 200 from your normal exit IP.
3
4# If you use `wrangler dev`, the `--var country=KP` env override
5# fakes a request.cf.country of KP for local testing without
6# needing a VPN.
For a real test, curl from a VPS in a blocked country works; a
consumer VPN’s exit-node country is enough for spot checks.
Comparing the two approaches
| Apache + MaxMind | Cloudflare Worker | |
|---|---|---|
| Cost | Free | Free (100k req/day/account) |
| Blocks at edge | No | Yes |
| DB maintenance | Weekly geoipupdate cron | None |
| Request limits | None | 100k/day free tier |
| Works without Cloudflare | Yes | No |
| Setup complexity | Moderate | Low |
| Correct status code | 451 / 403 (configurable) | 451 / 403 (in code) |
For sites you control, Apache + MaxMind is the more robust long-term choice — no request cap, no vendor dependency, log data stays on your box. The Cloudflare Worker is faster to deploy and requires no server-side maintenance, but the 100k per-account daily request cap is a real constraint for anything with meaningful traffic.
What country blocking will not do
Country blocking is a blunt instrument. It stops the majority of unsophisticated traffic from a given region. It will not stop:
- Anyone using a VPN with an exit node in an allowed country.
- Sophisticated botnets that distribute across multiple geographies.
- Legitimate users in blocked countries who have a valid reason to access your site.
For a marketer’s use case — cleaning up analytics, reducing invalid ad traffic — it does the job well enough. For a security-hardened production environment it should be one layer among several, not a standalone control. Pair it with rate limiting (nginx-ratelimit), a WAF, and log analytics that can spot abuse patterns after the geo block has done its first-pass filtering.
In practice
- Apache + MaxMind GeoLite2 if you control your server and want
no request limits. Set the
geoipupdatecron, log the country code, and use HTTP 451 for legally-required blocks so downstream tooling can distinguish them from commercial denials. - Cloudflare Worker if you’re already on Cloudflare and traffic is under 100k requests/day per account. Fail open on unknown country codes.
- Neither is a substitute for proper bot mitigation, rate limiting, or WAF rules — both are worth implementing as part of a hardened baseline, not as the whole strategy.
Country codes referenced here use ISO 3166-1 alpha-2 format.