A .env file holds the settings your app reads at start-up: database URLs, API keys, webhook secrets. It is meant to stay on your machine or in your host's settings screen. A .git folder holds the full history of your code, including every file that was ever committed and later deleted. Neither should be reachable from a browser.
When they are, anyone who asks for https://yourapp.com/.env gets the file back. Automated tools ask that question of millions of sites every day, so an exposed file tends to be read within hours, not weeks.
How it happens
- The whole project folder is the web root. Common on shared hosting and simple VPS setups: the server is pointed at the folder you cloned, so everything in it, including
.envand.git, is served as a static file. - A build step copies too much. A script copies the project root into
dist/orpublic/instead of only the built assets, and the env file goes along for the ride. - The env file sits in the public folder. A tool or tutorial put
.envnext toindex.html, and the host publishes that folder as is. - Deploying by upload. Dragging a folder into an FTP client or a host's upload screen sends hidden files too, even though your file browser does not show them.
Platforms that build from git, such as Vercel and Netlify, rarely publish .git, but they publish whatever ends up in the output folder. An env file copied there by a build script is still public.
Test your own site
Run these against a site you own. Each should return a 404 or 403, never a 200 with content.
curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.env
curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.env.production
curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.git/HEAD
curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.DS_Store
Some apps answer every unknown path with the homepage and a 200, which can look like a hit when it is not. If you see a 200, look at the body: a real env file has lines like DATABASE_URL=..., and a real .git/HEAD starts with ref: refs/heads/.
curl -s https://yourapp.com/.git/HEAD | head -c 80
The owner view of our free check runs the same requests and reads the answers this way, so a homepage fallback is not reported as a Fix.
Fix it on your platform
The lasting fix is to keep these files out of the published folder. Blocking the paths at the server is a good second layer.
Vercel
Keep secrets in Project Settings, Environment Variables, not in files under public/. Check what your build copies into the output, and add a .vercelignore so local env files are never uploaded by the CLI:
# .vercelignore
.env
.env.*
.git
Netlify
Set variables in Site configuration, Environment variables. Make sure your publish directory is the build output (for example dist), not the project root, in netlify.toml:
[build]
command = "npm run build"
publish = "dist"
Cloudflare Pages
Use the project's Variables and Secrets settings, and set the build output directory to the built folder only. If you deploy with Wrangler, point it at that folder and never at the repository root:
npx wrangler pages deploy dist
nginx or Apache on a server
Point the web root at the built folder, then deny dotfiles as a backstop. The .well-known exception keeps certificate renewal and security.txt working.
# nginx, inside the server block
location ~ /\.(?!well-known) {
deny all;
return 404;
}
# Apache, .htaccess in the web root
RedirectMatch 404 /\.(?!well-known)
Then add the files to .gitignore so they never reach the repository in the first place:
.env
.env.*
!.env.example
If the file was public: rotate
Removing the file stops new reads. It does not undo the ones that already happened, and you have no reliable way to know whether anyone fetched it. Treat every value in the file as known to others and replace it.
- Make a list of every key, password and secret in the file.
- Create a new value for each one with the provider: a new database password, a new Stripe secret key, a new Supabase service role key, a new API key for each AI provider.
- Put the new values in your host's environment settings and redeploy.
- Revoke the old values, once the app is working with the new ones.
- Look at the provider dashboards for usage you do not recognize in the time the file was public: new database users, Stripe refunds, AI usage spikes.
If .git was public, the same applies to every secret that was ever committed, even ones deleted years ago, because the history holds them. Search it with git log -p | grep -iE "key|secret|password" and rotate what you find.
Source maps are a quieter cousin of this problem. They publish your original source next to the bundle, comments and all. They are rarely dangerous on their own, but they make any key that slipped into the frontend much easier to spot. Turn them off for production unless you upload them privately to an error tracker.
If you want a second pair of eyes after a rotation, the Ship Safe Check reviews keys, storage, database rules and auth settings by hand.