Skip to content
IsMyENVPublic

Web servers · 8 min read

How to block .env files in Nginx

Nginx configuration examples that deny access to .env and other dotfiles while keeping /.well-known working, plus how to test the rule and avoid common location-order mistakes.

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:

nginx.confnginx
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.

inside server { … }nginx
# 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/.env and /.git/config are all covered.
  • (?!well-known/) is a negative lookahead that excludes /.well-known/.
  • return 404 answers as if the file didn't exist, which reveals less than a 403. It runs before access checks, so deny all is only a safety net if you remove the return.

Should I log these requests?

Some guides add 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:

inside server { … }nginx
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:

  1. An exact match (location = /path) wins immediately.
  2. Otherwise, the longest matching prefix location is remembered. If it uses the ^~ modifier, regular expressions are not checked.
  3. Regular expression locations are checked in the order they appear. The first match wins.
  4. 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 /.env never 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:

/etc/nginx/snippets/deny-dotfiles.confnginx
location ~ /\.(?!well-known/) {
    deny all;
    return 404;
}
each server blocknginx
server {
    # ...
    include snippets/deny-dotfiles.conf;
}

Complete example (PHP application)

/etc/nginx/sites-available/example.comnginx
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

Shellbash
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/.env

Then run the IsMyENVPublic check. It also recognizes custom error pages that return HTTP 200.

Remediation checklist

  • root points 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 with certbot renew --dry-run)
  • nginx -t passes and the server was reloaded
  • If the file was ever reachable: all secrets were rotated (see incident steps)

Frameworks

Laravel .env security

Point the document root at public/, disable debug mode, cache config and rotate APP_KEY safely.

9 min read