Web servers · 8 min read
How to block .env files in Nginx
Last updated
Why Nginx serves .env files in the first place
Nginx maps request paths to files below the root directory. If root points at your project directory and a .env file lives there, a request for /.env returns it, because Nginx has no built-in rule for dotfiles. The common front-controller setup makes no difference:
location / {
# $uri is tried first: if /.env exists on disk, it is served as-is.
try_files $uri $uri/ /index.php?$query_string;
}The most important fix is therefore to point root at the public directory of your application (for example /var/www/app/public), where no configuration file exists. The rules below add a second layer of protection that still works if someone gets the root wrong later.
The recommended rule: deny all dotfiles except /.well-known
# Block hidden files and directories (.env, .env.local, .git/, .htpasswd, ...)
# but keep /.well-known/ reachable for ACME certificates and security.txt.
location ~ /\.(?!well-known/) {
deny all;
return 404;
}How it works:
~starts a case-sensitive regular expression location./\.matches any path segment that begins with a dot, at any depth, so/.env,/.env.production,/app/.envand/.git/configare all covered.(?!well-known/)is a negative lookahead that excludes/.well-known/.return 404answers as if the file didn't exist, which reveals less than a 403. It runs before access checks, sodeny allis only a safety net if you remove thereturn.
Should I log these requests?
access_log off; to this block to reduce log noise from scanners. Keeping the log lets you see who probes for .env files and confirm that they receive a 404, which is useful after an incident.Blocking only .env files
If an application really needs to serve other dotfiles, restrict the rule to environment files. The case-insensitive ~* also catches /.ENV, which matters on case-insensitive file systems:
location ~* /\.env {
deny all;
return 404;
}This matches /.env, /.env.local, /.env.backup and also /.envrc. It doesn't protect .git/ or other hidden files, so prefer the general rule.
Location precedence pitfalls
Nginx chooses a location in this order, and a mistake here can silently bypass the rule:
- An exact match (
location = /path) wins immediately. - Otherwise, the longest matching prefix location is remembered. If it uses the
^~modifier, regular expressions are not checked. - Regular expression locations are checked in the order they appear. The first match wins.
- If no regex matches, the remembered prefix location is used.
Practical consequences:
- A block such as
location ^~ /uploads/bypasses the dotfile regex for everything below/uploads/. Repeat the rule inside such blocks or drop the^~. - Place the dotfile rule before other regex locations such as
location ~ \.php$or static-asset rules, so it is evaluated first. - A plain
location / { proxy_pass … }doesn't bypass the rule, because regex locations are checked after prefix locations without^~. Requests for/.envnever reach the upstream application.
Reuse the rule in every server block
Many exposures happen on a forgotten virtual host: the plain-HTTP server, a staging subdomain or the default server. Store the rule in a snippet and include it everywhere:
location ~ /\.(?!well-known/) {
deny all;
return 404;
}server {
# ...
include snippets/deny-dotfiles.conf;
}Complete example (PHP application)
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# A server-level return answers every request before any file is looked up.
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Only the public directory is published.
root /var/www/example-app/public;
index index.php;
# First regex location: evaluated before the PHP handler.
include snippets/deny-dotfiles.conf;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
autoindex off;
}Adjust the PHP-FPM socket to your installed version. For Laravel-specific settings, see the Laravel guide.
Test and reload
sudo nginx -t # validate the configuration
sudo systemctl reload nginx # apply without dropping connections
# Expect 404 for the env paths and 200 for .well-known resources you actually serve
curl -sS -o /dev/null -w "%{http_code}\n" https://example.com/.env
curl -sS -o /dev/null -w "%{http_code}\n" https://example.com/.env.local
curl -sS -o /dev/null -w "%{http_code}\n" http://example.com/.envThen run the IsMyENVPublic check. It also recognizes custom error pages that return HTTP 200.
Remediation checklist
rootpoints to the application's public directory- The dotfile rule is included in every server block that serves files, including plain-HTTP and default servers
- The rule appears before other regex locations
- No
^~prefix location bypasses the rule for directories that might contain dotfiles /.well-known/acme-challenge/still works (test a certificate renewal withcertbot renew --dry-run)nginx -tpasses and the server was reloaded- If the file was ever reachable: all secrets were rotated (see incident steps)