.git in the webroot: why a 404 doesn't mean you're safe
If you deploy with git pull, your repository lives in the directory your web server publishes. Testing it is easy. Testing it correctly is where people - including us - get it wrong.
Serhii Drobot · · 7 min read
How we fooled ourselves
While hardening our own production host, we ran the check everyone runs:
$ curl -sI https://example.com/.git/HEAD | head -1
HTTP/2 404
404. Clean. Move on. That's what we concluded, and we were wrong - not about the response, about what the response meant.
Then we listed the directory:
$ ls -la /var/www/site
drwxrwxr-x 8 deploy deploy 4096 Jul 16 10:08 .git
-rw-r--r-- 1 deploy deploy 10716 Jul 16 10:49 contact.php
-rw-r--r-- 1 deploy deploy 102325 Jul 16 10:49 index.html
drwxr-xr-x 3 deploy deploy 4096 Jun 29 21:58 node_modules
...
The repository was sitting in the webroot the whole time. The 404 wasn't coming from anything we had configured - it came from a rule inside the hosting panel's default nginx template, something like:
location ~ /\.(?!well-known\/) { deny all; return 404; }
A vendor default. We didn't write it, we didn't know it was load-bearing, and we couldn't have told you it existed. It works - until the template is regenerated on a panel update, until someone switches web server, until a migration copies files to a host without it.
A 404 tells you what the server did today. It doesn't tell you what's on disk. Absence of a response is not absence of a file - and a defence you didn't build is a defence you can't rely on.
What an exposed repository actually costs you
The usual objection: "it's just our HTML, the source is boring." Two problems with that.
It's not the files. It's the history.
Your working tree is what you meant to publish. .git is every version of everything you ever committed. Including the commit where someone hardcoded a key, and the commit two weeks later that took it out again. Deleting a secret from the current files does nothing: the old blob is still in the object store, addressable forever.
We know because we found exactly that in our own history - a captcha secret, committed early, still sitting in the objects long after the code stopped referencing it. It's why "we removed it" is never the answer to a leaked credential. Rotation is the answer.
It's not one file. It's the whole tree.
Directory listing being off changes nothing here, because nobody needs a listing. A git repository has a fixed, documented layout: HEAD, config, index, packed-refs, logs/HEAD, and objects under predictable paths. Every filename is known in advance. Public tooling walks that structure and reconstructs the full repository - source, history, branches - from nothing but HTTP GETs. It takes seconds, and it needs no vulnerability: the files are simply being served.
Out of that reconstruction falls, typically:
- Server-side code - the parts a browser never sees. Form handlers, auth logic, admin paths.
- Credentials in history: database passwords, API keys, SMTP logins, tokens.
- Internal hostnames, staging URLs, IP addresses in configs.
- Commit metadata: names and emails of everyone who ever touched it. Useful for phishing.
- Comments.
// TODO: this is vulnerable to..."is a real thing people write in commits they assume are private.
This is also one of the most reliably scanned misconfigurations that exists. Automated crawlers request /.git/HEAD against everything, continuously. You don't need to be a target - you only need to be reachable.
Test yours properly
The HTTP check is still worth running, but as one input, not a verdict:
for p in .git/HEAD .git/config .git/index .git/logs/HEAD .git/packed-refs; do
printf '%-22s %s\n' "$p" "$(curl -sI "https://YOUR-SITE/$p" -o /dev/null -w '%{http_code}')"
done
Then answer the question the curl can't:
# On the box. This is the only authoritative check.
ls -la /path/to/webroot/.git
# And: what is producing the 404? If you can't name the rule
# and point at the file it lives in, you don't control it.
grep -rn 'location ~ /\\\.' /etc/nginx/ 2>/dev/null
If the directory exists and you can't name the rule blocking it, treat it as exposed. That is the honest reading, and it's the one we should have applied to ourselves.
Why you can't just delete it
"Don't put .git in the webroot" is correct and unhelpful, because the directory isn't there by accident. It's there because git pull is how you deploy - and that's a genuinely good workflow:
- Atomic-ish, fast, and you can see exactly what's live with
git log -1 - Rollback is
git checkout <sha>, not a restore from backup - No CI to build, no artifacts to store, no third party in the path
Delete .git and you don't have a deployment method any more. Which is why people don't delete it - they add a deny rule instead, and then that deny rule turns out to be skipped by location ordering, or owned by a panel template, or missing on the next server. The control is one config change away from gone, permanently, in either direction.
So don't choose between the workflow and the safety. Git doesn't require them to be in the same place.
The fix: keep git pull, move the metadata out
Git has always separated the repository from the work tree. They usually sit together out of convention, not necessity. Split them once and every command you type stays the same.
WEB=/var/www/site # what the server publishes
REPO=/srv/deploy/site.git # NOT under the webroot
# 1. Move the metadata out.
mkdir -p "$(dirname "$REPO")"
mv "$WEB/.git" "$REPO"
# 2. Tell git where the files live, permanently.
git --git-dir="$REPO" config core.bare false
git --git-dir="$REPO" config core.worktree "$WEB"
# 3. Deploy exactly as before.
git --git-dir="$REPO" --work-tree="$WEB" pull
Wrap step 3 so nobody has to remember it, and so nobody re-clones into the webroot out of muscle memory:
printf '#!/bin/bash\nset -euo pipefail\ngit --git-dir=%s --work-tree=%s pull\n' "$REPO" "$WEB" \
| sudo tee /usr/local/bin/site-deploy >/dev/null
sudo chmod 700 /usr/local/bin/site-deploy
Then verify - on disk and over HTTP:
ls -la "$WEB/.git" # No such file or directory
site-deploy # still works
curl -sI https://YOUR-SITE/.git/HEAD | head -1 # 404 - now because there's nothing there
Same 404 as before. Completely different meaning. The first one depended on a stranger's config; this one depends on the file not existing.
While you're in there
.git is rarely alone. If the repository is in the webroot, the build usually is too - node_modules/, package.json, composer.json, webpack.config.js, .env.example. Same principle, and the same better answer: build somewhere else, deploy the output, and there's nothing to protect.
And rotate anything that was ever committed. Not "if you think it leaked" - if it was in a repository that was reachable over HTTP, treat it as public. Rewriting history doesn't help either: mirrors, forks and caches keep the objects alive.
The alternative, if you'd rather not touch the server
Push-to-deploy: a bare repository outside the webroot with a post-receive hook that checks out into it. Same separation, and deploys happen from your laptop instead of over SSH. More moving parts, but no repository on the public host at all - the metadata never leaves your machine and the build server.
The short version
- A 404 on
/.git/HEADproves the server said no today. It doesn't prove the directory isn't there. - If you can't name the rule producing that 404 and point at the file, you don't control it.
- Exposure hands over history, not just files - every secret ever committed, forever.
- No listing needed: the layout is documented, filenames are predictable, tooling reconstructs the repo over plain HTTP.
git --git-dir=... --work-tree=...keeps the workflow and removes the file. Five minutes, no downtime.- Anything ever committed is burned. Rotate it.
We shipped this fix on our own host the day we found it. The full write-up of what else that audit turned up - and the two findings we deliberately left in place - is here.
What else is protected by something you didn't configure?
That's the uncomfortable version of the question. Most infrastructure is safe today for reasons nobody on the team can name - a panel default, an upstream setting, a firewall rule someone added in 2023. An Infrastructure Security Audit is three days, read-only, $2,995 - we test what the server does, not what the config claims, and tell you which of your controls are actually yours. Run the free scanner first - if it comes back clean, we'll tell you so rather than sell you an audit.