How to Block .env and Scanner Probes on Nginx (With Real Log Numbers)
To block .env scanner probes on Nginx, deny every request for a dotfile with a location rule such as location ~ /.(?!well-known) { deny all; }, keep the web root free of secrets, and then audit your access log to confirm that no such request ever returned a 200. Automated scanners request paths like /.env and /.git/config all day on every public server, so the goal is not to stop them from asking but to make sure the answer is always a refusal. This guide shows the rules, a correct audit, and real numbers from one day of logs on a small server.
The commands assume Ubuntu 24.04 and Nginx. If your site runs WordPress, this pairs with the guides on optimizing WordPress on a 1 GB server and setting up an Nginx FastCGI cache.
Why scanners look for .env files
Many frameworks keep secrets such as database passwords, API keys and application keys in a file named .env. If a server is misconfigured so that this file sits inside the public web root and is served as plain text, anyone who requests it can read every secret in it. Attackers know this, so they run automated tools that request the same list of paths on every server they can find, without caring what the site is.
You do not need to be a target for this to happen. A brand new site with almost no visitors receives these probes as soon as its address appears in certificate logs or DNS data. The requests are cheap for the attacker and harmless for you, as long as your server answers correctly.
What one day of probes looked like on a real server
These figures come from the SiteHarbour server, which runs a Laravel application and a WordPress blog behind Cloudflare. The error log I read covered 03:10 to 12:38 on a single day, about nine and a half hours, so treat the numbers as a snapshot and not as a long-term trend.
| Measurement | Value |
|---|---|
| Requests in the access log | 2,459 |
| Responses with status 403 | 415 (about 17 percent) |
| Log lines saying “access forbidden by rule” | 415 |
| Responses with status 404 | 1,108 |
| Responses with status 200 | 883 |
The 403 count and the “forbidden by rule” count match exactly, which means every blocked request was refused by a deny rule and not by anything else. The most requested blocked paths were:
| Path | Requests |
|---|---|
/.env |
30 |
/.env.local |
15 |
/@fs/.env |
12 |
/.env.production |
12 |
/.env.development |
9 |
//.env and /@fs/src/.env |
8 each |
/@fs/app/.env |
7 |
/.git/config |
6 |
/config/.env, /api/.env, /admin/.env, /.env.save, /.env.example, /.env.bak |
5 each |
The pattern is what you would expect from a generic scanner. It tries the plain file, then common environment names (.local, .production, .development), then backup suffixes (.save, .bak), then likely folders (/config/, /api/, /admin/). The /@fs/ paths appear to be probes for development servers that expose their file system, which a production Nginx site simply does not have.
Step 1: Find your current exposure
Before adding rules, check what your server does today. Replace the log path with your own:
sudo grep -c "access forbidden by rule" /var/log/nginx/error.log
sudo grep "access forbidden by rule" /var/log/nginx/error.log | grep -o 'request: "[A-Z]* [^ ]*' | awk '{print $3}' | sed 's/?.*//' | sort | uniq -c | sort -rn | head -n 15
sudo awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -n 6
The first command counts the requests your rules refused. The second lists the paths most often refused. The third shows the mix of status codes. If you have no deny rules yet, the second command prints nothing, and the probes are being answered with 404 or, worse, 200.
Step 2: Deny dotfiles and other sensitive paths
These are the rules in use on the server measured above, placed inside the server block:
location ~ /.(?!well-known) { deny all; }
location ~ /.(env|git|htaccess) { deny all; }
location ~* ^/(vendor|storage)/ { deny all; }
What each line does:
- The first rule denies any path containing a dot-prefixed name, except
/.well-known/, which Let’s Encrypt and other services need. This alone covers.env,.gitand.htaccess. - The second rule names the three most common targets explicitly. It is redundant with the first, but it documents intent and survives someone later loosening the general rule.
- The third rule blocks the
vendorandstoragefolders of a Laravel project, which should never be requested directly.
For WordPress in a sub-path, also deny direct access to the configuration file:
location ~* wp-config.php$ { deny all; }
Warning. Never deny /.well-known/. Certificate renewal and several verification services request files there, and blocking it can make an SSL certificate fail to renew months later.
Then test and reload with a safety net, as in the FastCGI cache guide:
if sudo nginx -t; then sudo systemctl reload nginx && echo "RELOADED"; else echo "TEST FAILED"; fi
curl -s -o /dev/null -w '%{http_code}n' https://example.com/.env
The last command should print 403.
Step 3: Keep secrets out of the web root
A deny rule is a second layer, not the first. The first layer is that the secret file should not be reachable at all. Laravel does this by design: the web root is the public folder, and .env sits one level above it. On the server measured here the site root is /var/www/siteharbour/public, so a request for /.env looks for a file that is not inside the served folder.
For any application, check where your web root points and where the secrets live:
sudo nginx -T 2>/dev/null | grep -n "root "
ls -la /var/www/yoursite/.env /var/www/yoursite/public/.env 2>&1
If a .env file exists inside the folder Nginx serves, move it out and update the application to read it from the new location, then rotate the secrets in it, because you cannot know who already read it.
Step 4: Audit the log correctly
The question to answer is: did any request for a sensitive file ever return 200? A naive search gets this wrong. When I first searched the access log for any 200 response whose request line contained .env, it printed one match, which looked like an incident. The line was:
"GET /?url=.env HTTP/2.0" 200 15118
That is a request for the homepage with a query parameter called url whose value was .env. The server returned the normal homepage, and 15,118 bytes is the size of a rendered HTML page, far larger than a typical .env file. A follow-up check confirmed the homepage does not echo the parameter back:
curl -s "https://example.com/?url=.env" | grep -c ".env"
It printed 0. To avoid false alarms, look only at the path part of the request, before any query string, and print the response size so you can judge it:
sudo awk '$9==200 { split($7, p, "?"); if (p[1] ~ /.(env|git)/) print $7, $10 }' /var/log/nginx/access.log
On the server measured here, that check should print nothing. Anything it does print deserves a look: a small response for a path ending in .env is a real leak, and the response is an incident. Rotate every secret in that file before doing anything else.
What Cloudflare changes in your logs
If your site sits behind Cloudflare, Nginx sees Cloudflare’s servers as the client. Every blocked request on the server measured here came from a Cloudflare address, not from the scanner. That is expected, but it has consequences:
- The IP addresses in your logs are not the attackers’. A list of top offenders from the access log is really a list of Cloudflare edge servers.
- Do not ban by IP from these logs. A ban tool that reads them would block Cloudflare itself, and with it many legitimate visitors.
- Per-IP rate limits act on Cloudflare’s addresses until real visitor IPs are restored with the
real_ipmodule and Cloudflare’s published ranges.
On this server, Nginx does not restore visitor IPs yet, and the blocking described in this guide is done by the deny rules, not by an automatic ban tool. Restoring real IPs is a separate change that I will cover in its own guide, and I would do it before pointing any log-reading ban tool at these logs.
Common mistakes
- Trusting a single rule. Keep secrets outside the web root, deny dotfiles, and audit the log. Each layer covers a failure of another.
- Blocking
/.well-known/. It breaks certificate renewal and domain verification. - Auditing with a loose search. Searching for
.envanywhere in the line produces false alarms from query strings. Match the path only, and check the response size. - Leaving backups in the web root. Files like
.env.bak,wp-config.php.oldanddb.sql.gzare on every scanner’s list, and the deny rules only cover some of them. - Banning by address behind a CDN. You end up blocking the CDN.
- Reading the numbers as a trend. One day of logs shows what happened that day. Compare several weeks before drawing conclusions.
When rules on one server are not enough
Deny rules stop the obvious probes. They do not patch vulnerable plugins, protect weak passwords or stop a determined attacker. If you manage several sites, need isolated environments, or want a server hardened and monitored for you, a dedicated server gives you full control of the stack, and managed WordPress hosting moves the maintenance work to the host.
Frequently asked questions
Is it a problem that scanners probe my site?
Not by itself. Every public server is probed constantly. It becomes a problem only if a probe receives a real file, so the audit in Step 4 matters more than the number of probes.
Should I return 403 or 404 for these paths?
Either refuses the request. A 403 makes your intent clear in the logs, and it is easy to count, which is why it is used here. Some people prefer 404 to reveal less. The important part is that the file is never served.
Do I need to block these paths if my framework keeps .env outside the web root?
Keeping secrets outside the web root is the stronger protection. The deny rules are still worth having, because a later mistake, such as a backup copied into the wrong folder, is then still refused.
Will these rules affect Let’s Encrypt?
Not as long as you keep the .well-known exception. Test with a dry run of your renewal after any change.
Should I use fail2ban for this?
It can help, but only if it reads the right log files and sees real visitor addresses. Behind a CDN that means restoring real IPs first. Check that any ban tool is actually monitoring a file before you rely on it.
The short version: keep secrets out of the web root, deny dotfiles with a rule that keeps /.well-known/, audit the log by path and response size, and remember that behind Cloudflare the addresses in your logs are Cloudflare’s, not the attackers’.