We put a three-commit repo behind Express 4's stock express.static() and pointed git-dumper at it. The second commit had added .env.production with a Stripe key. The third deleted it and added a .gitignore. About 70 HTTP requests later we had the whole history on disk, and git show HEAD~1:.env.production printed the key.
The same server returned 404 for /.env. It was happily refusing the file everyone knows to protect while handing out the folder that contains every version of it.
TL;DR
Request /.git/HEAD on your live site. If you get ref: refs/heads/..., anyone can rebuild your repo, including deleted files. Block every path containing /.git at the web server (one line in nginx, Apache or Express), or better, stop deploying the folder at all. Then rotate every secret that was ever committed, not just the ones in your current code.
Why a public .git folder is worse than a public .env
A .env leak gives away today's secrets. A .git leak gives away every file at every commit. That's old API keys someone "removed", a database URL from before you moved hosts, and the .env an AI agent committed on day one before anyone added a .gitignore.
There's a second leak in .git/config itself. If the repo was cloned with a token in the URL, which is a common way to pull a private repo onto a VPS, the token sits there in plain text. We ran git remote add origin https://x-access-token:ghp_...@github.com/acme/app.git with git 2.43 and the file kept it verbatim:
[remote "origin"]
url = https://x-access-token:ghp_FAKE123@github.com/acme/app.git
fetch = +refs/heads/*:refs/remotes/origin/*
That token usually has access to more than this one repo.
Directory listing being off doesn't save you
Most guides tell you to check /.git/ in a browser. On a stock nginx 1.24 serving a folder with a repo in it, here's what came back:
| Request | Status | Body |
|---|---|---|
/.git/ | 403 | nginx Forbidden page |
/.git/HEAD | 200 | ref: refs/heads/master |
/.git/config | 200 | [core] repositoryformatversion = 0 ... |
The 403 looks reassuring. It only means nginx won't list the folder. Every file inside is still served by name, and git's file names are predictable. The git-dumper README spells out its fallback for exactly this case: read .git/HEAD, .git/logs/HEAD, .git/config, .git/packed-refs and .git/index, collect object hashes from them, download each object, then follow each commit to its parents. That's how our test dump worked with no listing at all.
Step 1: Confirm it from outside
Request HEAD and config. Use the real domain, not localhost:
curl -s https://yoursite.com/.git/HEAD
curl -s https://yoursite.com/.git/config | head -5
Read the body, not the status code. A single-page app often answers every path with 200 and its homepage HTML. That's not a leak. A leak looks like ref: refs/heads/main from HEAD, or a [core] block from config.
Check every hostname. The www and bare domain can point at different servers, and staging boxes are where git clone into the web root happens most. Run the same two requests against each.
This is what our scanner does too. It requests /.git/HEAD and /.git/config, compares each response to a request for a random path that can't exist (so a catch-all homepage doesn't count), and only reports a finding when HEAD contains ref: or config contains [core]. Either one shows up as Exposed: /.git/HEAD or Exposed: /.git/config, rated Critical. One edge: if your server's checkout is on a detached HEAD, that file holds a bare commit hash with no ref:, so only the config check fires.
Step 2: Block it at the server
Block the whole folder, not the two files a scanner asks for. git-dumper goes straight to /.git/objects/....
nginx
Put this inside the server block, above your other location blocks:
location ~ /\.git {
deny all;
}
We tested it on nginx 1.24: /.git/HEAD, /.git/config, /.git/ and /.git/objects/info/packs all switched from served to 403, and normal pages still loaded. Position matters because nginx checks regex locations in the order they appear and stops at the first match, and a prefix location marked ^~ skips regex checks entirely. Reload with nginx -t && nginx -s reload, then rerun the curl.
Prefer a 404 so the response doesn't confirm the folder exists? Use return 404; in place of deny all;.
Apache
In the vhost config or a root .htaccess:
RedirectMatch 404 /\.git
Apache's mod_alias docs allow any status here; for codes outside 300-399 you leave the target URL off, which turns it into a plain 404. It works in .htaccess as long as AllowOverride includes FileInfo.
Express
This one surprised us. Express 4.22.3 with app.use(express.static(projectDir)) served /.git/HEAD and /.git/config with 200 and returned 404 for /.env. Express 5.3.0 with the same line returned 404 for all three. The difference is the bundled serve-static: the 1.x README says its default "will not ignore the files within a directory that begins with a dot", and .git/HEAD is exactly that. Version 2.x (Express 5) defaults to plain ignore, which covers dot directories too.
AI-generated Express servers often do express.static(__dirname) or express.static('.'). The real fix is to serve a public/ folder instead. If you can't move things today, be explicit:
app.use(express.static("public", { dotfiles: "deny" }));
Docker
COPY . . copies .git into the image unless your .dockerignore excludes it. If that image serves files from the copied folder (an nginx image with your repo in /usr/share/nginx/html is the classic), the repo goes live. Add this to .dockerignore:
.git
.env*
Vercel and Netlify
Vercel lists .git in the files its CLI never uploads. Netlify's docs say only the publish directory is deployed, so the thing to check there is a publish directory set to the repo root. Neither platform documents every case, which is why the curl in Step 1 is the real test, not the platform name.
Step 3: Stop deploying the folder at all
Blocking is the patch. The cause is a deploy that puts the repo in the web root. If your server does git pull inside /var/www/html, change it to pull somewhere else and copy only the build output (dist/, build/, public/) into the served folder. A server that has no .git in its document root can't leak one, whatever the config says next year.
Step 4: Work out what leaked, then rotate it
Assume someone already downloaded it. Unless you have access logs going back to the day the folder went live, you can't prove nobody did. Clone your repo (the full history, not a shallow copy) and list every file it has ever held:
git log --all --pretty=format: --name-only | sort -u
git log --all --diff-filter=D --name-only --pretty=format:"%h %ad %s" --date=short
On our test repo the second command printed a1a41bc 2026-11-04 remove env, add gitignore followed by .env.production. That's your rotation list: every .env*, config, key file or credentials JSON that appears in either output. Then run a secret scanner over the full history to catch keys pasted into ordinary source files; our git secrets guide covers gitleaks and detect-secrets.
Rotate first. GitHub's own guidance is to revoke or rotate the secret, and notes that once rotated it "can no longer be used for access, and that may be sufficient." Rewriting history doesn't help here anyway: whoever dumped your .git folder has their own copy. Also rotate any token sitting in .git/config's remote URL, then switch that server to a deploy key or credential helper so the next clone doesn't put one there.
Don't skip a key because it's "old". Removing a key from your code doesn't revoke it at the provider. If you can't prove a credential was revoked, treat it as live.
How to rotate API keys walks through the order to do it in without breaking production.
How do I know if my .git directory is exposed?
Request https://yoursite.com/.git/HEAD. If the response is a short line starting with ref: refs/heads/, your repo is public. Also try /.git/config and look for a [core] section. A 403 or 404 on both, with a body that is not your normal homepage, means it's blocked.
My server says 403 on /.git/, am I safe?
No. A 403 on the folder usually just means directory listing is off. In our nginx test the folder returned 403 while /.git/HEAD and /.git/config returned 200 with their real contents. Tools like git-dumper don't need a listing; they read HEAD, config and the index and fetch objects by hash.
Is deleting the .git folder from the server enough?
It stops new downloads, but anyone who already pulled it keeps a full copy of your history. Treat every secret that was ever committed to that repo as exposed and rotate it, including files you deleted in later commits.
Do Vercel and Netlify deploys expose .git?
Rarely. Vercel's CLI never uploads .git, it's on Vercel's default ignore list. Netlify only deploys the publish directory, so the risk is a publish directory set to the repo root. The usual culprits are a VPS or shared host where someone ran git clone into the web root, a Docker image built with COPY . . and no .dockerignore, or an Express 4 app serving its project folder.
Does .gitignore protect files in an exposed .git directory?
Only files that were never committed. A gitignored .env that was never added isn't in .git at all. But if it was committed once and then ignored, the old commit still holds it and git-dumper will restore it.
Our free scan requests /.git/HEAD and /.git/config on your live site, alongside .env files, backups and other files that should never answer.