{"id":3304,"date":"2026-09-30T16:23:27","date_gmt":"2026-09-30T16:23:27","guid":{"rendered":"https:\/\/siteharbour.com\/blog\/?p=3304"},"modified":"2026-09-30T16:23:28","modified_gmt":"2026-09-30T16:23:28","slug":"block-env-scanner-probes-nginx","status":"publish","type":"post","link":"https:\/\/siteharbour.com\/blog\/block-env-scanner-probes-nginx\/","title":{"rendered":"How to Block .env and Scanner Probes on Nginx (With Real Log Numbers)"},"content":{"rendered":"<p>To block .env scanner probes on Nginx, deny every request for a dotfile with a location rule such as <code>location ~ \/.(?!well-known) { deny all; }<\/code>, 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 <code>\/.env<\/code> and <code>\/.git\/config<\/code> 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.<\/p>\n<p>The commands assume Ubuntu 24.04 and Nginx. If your site runs WordPress, this pairs with the guides on <a href=\"\/blog\/optimize-wordpress-1gb-ram-server\/\">optimizing WordPress on a 1 GB server<\/a> and <a href=\"\/blog\/nginx-fastcgi-cache-wordpress\/\">setting up an Nginx FastCGI cache<\/a>.<\/p>\n<h2>Why scanners look for .env files<\/h2>\n<p>Many frameworks keep secrets such as database passwords, API keys and application keys in a file named <code>.env<\/code>. 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.<\/p>\n<p>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.<\/p>\n<h2>What one day of probes looked like on a real server<\/h2>\n<p>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.<\/p>\n<table>\n<thead>\n<tr>\n<th>Measurement<\/th>\n<th>Value<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Requests in the access log<\/td>\n<td>2,459<\/td>\n<\/tr>\n<tr>\n<td>Responses with status 403<\/td>\n<td>415 (about 17 percent)<\/td>\n<\/tr>\n<tr>\n<td>Log lines saying &#8220;access forbidden by rule&#8221;<\/td>\n<td>415<\/td>\n<\/tr>\n<tr>\n<td>Responses with status 404<\/td>\n<td>1,108<\/td>\n<\/tr>\n<tr>\n<td>Responses with status 200<\/td>\n<td>883<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The 403 count and the &#8220;forbidden by rule&#8221; 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:<\/p>\n<table>\n<thead>\n<tr>\n<th>Path<\/th>\n<th>Requests<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>\/.env<\/code><\/td>\n<td>30<\/td>\n<\/tr>\n<tr>\n<td><code>\/.env.local<\/code><\/td>\n<td>15<\/td>\n<\/tr>\n<tr>\n<td><code>\/@fs\/.env<\/code><\/td>\n<td>12<\/td>\n<\/tr>\n<tr>\n<td><code>\/.env.production<\/code><\/td>\n<td>12<\/td>\n<\/tr>\n<tr>\n<td><code>\/.env.development<\/code><\/td>\n<td>9<\/td>\n<\/tr>\n<tr>\n<td><code>\/\/.env<\/code> and <code>\/@fs\/src\/.env<\/code><\/td>\n<td>8 each<\/td>\n<\/tr>\n<tr>\n<td><code>\/@fs\/app\/.env<\/code><\/td>\n<td>7<\/td>\n<\/tr>\n<tr>\n<td><code>\/.git\/config<\/code><\/td>\n<td>6<\/td>\n<\/tr>\n<tr>\n<td><code>\/config\/.env<\/code>, <code>\/api\/.env<\/code>, <code>\/admin\/.env<\/code>, <code>\/.env.save<\/code>, <code>\/.env.example<\/code>, <code>\/.env.bak<\/code><\/td>\n<td>5 each<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The pattern is what you would expect from a generic scanner. It tries the plain file, then common environment names (<code>.local<\/code>, <code>.production<\/code>, <code>.development<\/code>), then backup suffixes (<code>.save<\/code>, <code>.bak<\/code>), then likely folders (<code>\/config\/<\/code>, <code>\/api\/<\/code>, <code>\/admin\/<\/code>). The <code>\/@fs\/<\/code> paths appear to be probes for development servers that expose their file system, which a production Nginx site simply does not have.<\/p>\n<h2>Step 1: Find your current exposure<\/h2>\n<p>Before adding rules, check what your server does today. Replace the log path with your own:<\/p>\n<pre><code>sudo grep -c \"access forbidden by rule\" \/var\/log\/nginx\/error.log\nsudo 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\nsudo awk '{print $9}' \/var\/log\/nginx\/access.log | sort | uniq -c | sort -rn | head -n 6<\/code><\/pre>\n<p>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.<\/p>\n<h2>Step 2: Deny dotfiles and other sensitive paths<\/h2>\n<p>These are the rules in use on the server measured above, placed inside the <code>server<\/code> block:<\/p>\n<pre><code>location ~ \/.(?!well-known) { deny all; }\nlocation ~ \/.(env|git|htaccess) { deny all; }\nlocation ~* ^\/(vendor|storage)\/ { deny all; }<\/code><\/pre>\n<p>What each line does:<\/p>\n<ul>\n<li><strong>The first rule<\/strong> denies any path containing a dot-prefixed name, except <code>\/.well-known\/<\/code>, which Let&#8217;s Encrypt and other services need. This alone covers <code>.env<\/code>, <code>.git<\/code> and <code>.htaccess<\/code>.<\/li>\n<li><strong>The second rule<\/strong> 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.<\/li>\n<li><strong>The third rule<\/strong> blocks the <code>vendor<\/code> and <code>storage<\/code> folders of a Laravel project, which should never be requested directly.<\/li>\n<\/ul>\n<p>For WordPress in a sub-path, also deny direct access to the configuration file:<\/p>\n<pre><code>location ~* wp-config.php$ { deny all; }<\/code><\/pre>\n<div class=\"callout callout-warning\">\n<p><strong>Warning.<\/strong> Never deny <code>\/.well-known\/<\/code>. Certificate renewal and several verification services request files there, and blocking it can make an SSL certificate fail to renew months later.<\/p>\n<\/div>\n<p>Then test and reload with a safety net, as in the <a href=\"\/blog\/nginx-fastcgi-cache-wordpress\/\">FastCGI cache guide<\/a>:<\/p>\n<pre><code>if sudo nginx -t; then sudo systemctl reload nginx &amp;&amp; echo \"RELOADED\"; else echo \"TEST FAILED\"; fi\ncurl -s -o \/dev\/null -w '%{http_code}n' https:\/\/example.com\/.env<\/code><\/pre>\n<p>The last command should print 403.<\/p>\n<h2>Step 3: Keep secrets out of the web root<\/h2>\n<p>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 <code>public<\/code> folder, and <code>.env<\/code> sits one level above it. On the server measured here the site root is <code>\/var\/www\/siteharbour\/public<\/code>, so a request for <code>\/.env<\/code> looks for a file that is not inside the served folder.<\/p>\n<p>For any application, check where your web root points and where the secrets live:<\/p>\n<pre><code>sudo nginx -T 2&gt;\/dev\/null | grep -n \"root \"\nls -la \/var\/www\/yoursite\/.env \/var\/www\/yoursite\/public\/.env 2&gt;&amp;1<\/code><\/pre>\n<p>If a <code>.env<\/code> 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.<\/p>\n<h2>Step 4: Audit the log correctly<\/h2>\n<p>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 <code>.env<\/code>, it printed one match, which looked like an incident. The line was:<\/p>\n<pre><code>\"GET \/?url=.env HTTP\/2.0\" 200 15118<\/code><\/pre>\n<p>That is a request for the homepage with a query parameter called <code>url<\/code> whose value was <code>.env<\/code>. The server returned the normal homepage, and 15,118 bytes is the size of a rendered HTML page, far larger than a typical <code>.env<\/code> file. A follow-up check confirmed the homepage does not echo the parameter back:<\/p>\n<pre><code>curl -s \"https:\/\/example.com\/?url=.env\" | grep -c \".env\"<\/code><\/pre>\n<p>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:<\/p>\n<pre><code>sudo awk '$9==200 { split($7, p, \"?\"); if (p[1] ~ \/.(env|git)\/) print $7, $10 }' \/var\/log\/nginx\/access.log<\/code><\/pre>\n<p>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 <code>.env<\/code> is a real leak, and the response is an incident. Rotate every secret in that file before doing anything else.<\/p>\n<h2>What Cloudflare changes in your logs<\/h2>\n<p>If your site sits behind Cloudflare, Nginx sees Cloudflare&#8217;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:<\/p>\n<ul>\n<li><strong>The IP addresses in your logs are not the attackers&#8217;.<\/strong> A list of top offenders from the access log is really a list of Cloudflare edge servers.<\/li>\n<li><strong>Do not ban by IP from these logs.<\/strong> A ban tool that reads them would block Cloudflare itself, and with it many legitimate visitors.<\/li>\n<li><strong>Per-IP rate limits act on Cloudflare&#8217;s addresses<\/strong> until real visitor IPs are restored with the <code>real_ip<\/code> module and Cloudflare&#8217;s published ranges.<\/li>\n<\/ul>\n<p>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.<\/p>\n<h2>Common mistakes<\/h2>\n<ul>\n<li><strong>Trusting a single rule.<\/strong> Keep secrets outside the web root, deny dotfiles, and audit the log. Each layer covers a failure of another.<\/li>\n<li><strong>Blocking <code>\/.well-known\/<\/code>.<\/strong> It breaks certificate renewal and domain verification.<\/li>\n<li><strong>Auditing with a loose search.<\/strong> Searching for <code>.env<\/code> anywhere in the line produces false alarms from query strings. Match the path only, and check the response size.<\/li>\n<li><strong>Leaving backups in the web root.<\/strong> Files like <code>.env.bak<\/code>, <code>wp-config.php.old<\/code> and <code>db.sql.gz<\/code> are on every scanner&#8217;s list, and the deny rules only cover some of them.<\/li>\n<li><strong>Banning by address behind a CDN.<\/strong> You end up blocking the CDN.<\/li>\n<li><strong>Reading the numbers as a trend.<\/strong> One day of logs shows what happened that day. Compare several weeks before drawing conclusions.<\/li>\n<\/ul>\n<h2>When rules on one server are not enough<\/h2>\n<p>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 <a href=\"\/dedicated-server\">dedicated server<\/a> gives you full control of the stack, and <a href=\"\/wordpress\">managed WordPress hosting<\/a> moves the maintenance work to the host.<\/p>\n<h2>Frequently asked questions<\/h2>\n<h3>Is it a problem that scanners probe my site?<\/h3>\n<p>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.<\/p>\n<h3>Should I return 403 or 404 for these paths?<\/h3>\n<p>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.<\/p>\n<h3>Do I need to block these paths if my framework keeps .env outside the web root?<\/h3>\n<p>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.<\/p>\n<h3>Will these rules affect Let&#8217;s Encrypt?<\/h3>\n<p>Not as long as you keep the <code>.well-known<\/code> exception. Test with a dry run of your renewal after any change.<\/p>\n<h3>Should I use fail2ban for this?<\/h3>\n<p>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.<\/p>\n<p>The short version: keep secrets out of the web root, deny dotfiles with a rule that keeps <code>\/.well-known\/<\/code>, audit the log by path and response size, and remember that behind Cloudflare the addresses in your logs are Cloudflare&#8217;s, not the attackers&#8217;.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How to block .env and .git scanner probes on Nginx, with real log numbers, a correct check for leaks, and what Cloudflare changes in your logs.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[17],"tags":[47,50,37,49,48],"class_list":["post-3304","post","type-post","status-publish","format-standard","hentry","category-website-security","tag-env-file","tag-laravel","tag-nginx","tag-scanner-bots","tag-security"],"_links":{"self":[{"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/posts\/3304","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/comments?post=3304"}],"version-history":[{"count":1,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/posts\/3304\/revisions"}],"predecessor-version":[{"id":3305,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/posts\/3304\/revisions\/3305"}],"wp:attachment":[{"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/media?parent=3304"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/categories?post=3304"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/siteharbour.com\/blog\/wp-json\/wp\/v2\/tags?post=3304"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}