Why your nginx deny all never fires
The rule is in your config. nginx -t says syntax is ok. The file is still served. nginx isn't ignoring you - it stopped reading before it reached your rule.
Serhii Drobot · · 7 min read
How I found this on my own server
I was writing a different guide and ran a habit check against our own production host:
$ curl -sI https://example.com/.git/HEAD | head -1
HTTP/2 404 # good
$ curl -sI https://example.com/.gitignore | head -1
HTTP/2 404 # good - the file exists, so a rule is blocking it
$ curl -sI https://example.com/package.json | head -1
HTTP/2 200 # …what?
That combination is the interesting part. .gitignore exists on disk and returns 404 - so a dotfile deny rule is active. But package.json is served. And so is node_modules/.package-lock.json, which is also a dotfile, and which should have hit the same rule.
Same server. Same config. One dotfile blocked, another served. That isn't nginx misbehaving - it's nginx doing precisely what the config says, in an order almost nobody has internalised.
The order nginx actually uses
Given a request URI, nginx picks exactly one location block, like this:
- Exact match -
location = /path. If it matches, stop. Done. - Prefix match - all
location /pathblocks are scanned and the longest match is remembered. Not used yet. - If that longest prefix match was declared with
^~, stop and use it. Regex is skipped entirely. - Regex -
location ~andlocation ~*are tested in the order they appear in the file. The first one that matches wins. Length, specificity and "how obviously security-critical it looks" count for nothing. - If no regex matched, fall back to the prefix match from step 2.
Step 4 is the whole story. Among regex locations, position in the file decides. Not specificity. Position.
What that does to a typical config
Every control panel and most tutorials produce roughly this shape:
server {
# 1 - static assets, served directly for speed
location ~* ^.+\.(css|js|json|png|jpg|webp|svg|woff2|ico)$ {
root /var/www/site;
expires max;
}
# 2 - "security"
location ~ /\.(?!well-known\/) {
deny all;
return 404;
}
location / {
proxy_pass http://backend;
}
}
Now trace the three requests through step 4:
/.gitignore- no matching extension, block 1 misses. Block 2 matches → 404. The rule works./.git/HEAD- no extension. Block 2 matches → 404. Works./node_modules/.package-lock.json- ends in.json. Block 1 matches first. nginx stops. Block 2 is never evaluated. 200.
The deny rule isn't broken. It's unreachable for anything the static block claims first. And the static block claims, by extension, a very large set: every .json, every .js, every .css, anywhere in the tree, at any depth, dotfile or not.
Which means the actual rule on that server is: "deny dotfiles, unless the filename happens to end in something the static block likes." Nobody wrote that rule. It's what the config says.
If you also run Apache behind nginx - the standard panel layout - this has a second edge. Static files never reach Apache, so your .htaccess is not consulted for them. Every <FilesMatch> deny you wrote for .env or package.json is dead code. It only applies to what nginx proxies back, which is usually just HTML and PHP.
Test yours
Don't read the config - ask the server. Reading configs is how this happens in the first place.
for p in .git/HEAD .gitignore .env package.json package-lock.json \
composer.json node_modules/.package-lock.json .htaccess \
config.json.bak webpack.config.js; do
printf '%-38s %s\n' "$p" "$(curl -sfI "https://YOUR-SITE/$p" -o /dev/null -w '%{http_code}')"
done
Anything other than 403/404 is served. Pay attention to the pattern, not just the hits: if the extensionless paths are blocked and the ones with extensions are not, you have exactly this bug.
You can also ask nginx directly which block it chose - turn on rewrite logging temporarily in a non-production vhost:
error_log /var/log/nginx/debug.log debug;
rewrite_log on;
# then: grep "using location" /var/log/nginx/debug.log
Debug logging is loud and writes request paths to disk. Turn it off when you're done, and don't leave it on a host that handles anyone else's data.
Why you can't just move the deny rule to the top
That's the obvious fix and it does work. It's also the one that gets quietly reverted.
The static block is first on purpose. It exists so CSS, images and fonts are served straight off disk instead of being proxied to a PHP-capable backend. That's the difference between a 2 ms and a 40 ms asset. Move deny rules above it and you've added a regex evaluation to every asset request - usually fine, occasionally not, and you should at least know you did it.
The bigger problem: you often don't own the file. Hestia, Plesk, cPanel and ISPConfig all generate vhosts from templates. Edit the generated config and the next v-rebuild-web-domains, the next panel update, or the next certificate renewal silently overwrites your fix. You'll find out months later, from a curl.
What to do instead
1. Use ^~ - it wins regardless of order
This is the part most people never learn. A prefix location declared with ^~ is decided at step 3, before nginx looks at any regex. Put it anywhere in the file. It still wins.
# order-independent: beats every regex location, wherever it sits
location ^~ /.git/ { deny all; return 404; }
location ^~ /node_modules/ { deny all; return 404; }
location ^~ /vendor/ { deny all; return 404; }
location ^~ /.svn/ { deny all; return 404; }
That covers whole directories, which is most of the real exposure. For filename patterns you still need a regex - and that one must sit above the static block:
# must be declared BEFORE the static-assets location
location ~* ^/(package(-lock)?\.json|composer\.(json|lock)|webpack\.config\.js)$ {
deny all; return 404;
}
location ~* \.(bak|old|orig|save|swp|sql|log|env|dist)$ {
deny all; return 404;
}
Keep the ACME path alive or your certificates stop renewing: any dotfile rule needs (?!well-known\/), and it's worth curling /.well-known/acme-challenge/test afterwards. A 404 from "file not found" is fine; a 403 from your own deny rule means you just broke Let's Encrypt on a 90-day timer.
2. Edit the template, not the vhost
On a panel, copy the template first and assign it to the domain. On Hestia that's /usr/local/hestia/data/templates/web/nginx/*.tpl and *.stpl - and it's the .stpl, the TLS one, that actually serves your traffic. Changes there survive rebuilds. Changes in the generated vhost do not.
3. Better: don't have the files there
Every rule above is you defending files that have no reason to exist in a public directory. node_modules, package.json and build config are on that server because the build runs there. Move the build to your laptop or CI, commit the compiled asset, and deploy the output. Nothing to deny, nothing to get wrong on the next panel update, and one less toolchain on a machine that faces the internet.
Same for .git: it's in the webroot because you deploy with git pull. You can keep that workflow and still move the metadata out -
git --git-dir=/srv/repo.git --work-tree=/var/www/site pull works exactly the same, with nothing to block.
A deny rule is a control. Not having the file is an outcome. Prefer outcomes.
The short version
- Among regex locations, nginx takes the first match in file order. Specificity is irrelevant.
- A static-assets block above your deny rule swallows every path with a matching extension - including dotfiles.
- If Apache sits behind nginx,
.htaccessnever sees static requests at all. ^~prefix locations beat regex regardless of position. Use them for directories.- On a panel, edit the template or your fix gets overwritten.
- Test with curl, not by reading the config. Reading the config is what caused this.
We found this on our own production host, while hardening it for the write-up here. Nothing was actually reachable that mattered - but "nothing that mattered" was luck, not design, and luck isn't a control.
Config says one thing. Server does another.
This is the whole category: rules that exist, pass syntax checks, and never run. You can't find them by reading - only by asking the server what it actually does. An Infrastructure Security Audit is three days, read-only, $2,995 - we test the behaviour, not the intent, and hand you the findings ranked by what an attacker reaches first. Run the free scanner first - if it comes back clean, we'll tell you so rather than sell you an audit.