For: anyone running Laravel-style apps (where only public/ should be exposed) on shared hosting with Apache/.htaccess. Distilled from a real incident (→ .env exposed to the world) — for securing your own deployment.
This site's view: this isn't 'one person's mistake'
Early shared-hosting culture put everything directly under public_html. Laravel (which expects only public/ exposed) flowed into that, and a dangerous layout became the de-facto standard — even vendors' own docs put projects inside public_html. So the fix is process, not vigilance: change your deployment default.
Get the placement right (diagram)
✗ Dangerous (body at the public root)
public_html/ ├─ .env ← readable! ├─ .git/ vendor/ └─ public/
✓ Safe (body outside, only public)
app-laravel/ ← outside web root ├─ .env .git/ ← unreachable └─ public/ → required from public_html
Which fix fits your environment
"Keep .env off the web" looks different depending on the server type and your permissions. Find your row first, then go through the steps below.
| Environment | Permanent fix | First aid | Watch out for |
|---|---|---|---|
| Shared hosting (with SSH) | Move the app body out of the public folder and leave only a small index.php there (Step 2) | Deny in .htaccess | Making the public folder itself a symlink returns 500 in some environments |
| Shared hosting (FTP only) | Re-upload the app body one level above the public folder (public_html etc.) with your FTP client | Deny in .htaccess | Most shared hosts let you write above the public folder; if yours doesn't, ask support |
| Control panel lets you set the public folder | Point the public folder straight at app-laravel/public | Not needed (still check) | After switching, delete any copy of the app left in the old public folder |
| VPS (Apache) | Point the VirtualHost DocumentRoot at public/ | Deny rules in the server config | With AllowOverride None (the Apache 2.4 default), .htaccess is not read |
| VPS (Nginx) | Point root at public/ and deny files starting with a dot | Deny rules in the server config | Nginx does not read .htaccess |
The goal is the same everywhere: no .env, .git, vendor/ or storage/ inside the public folder. Laravel's official docs also warn that moving index.php to the project root and serving from there exposes many sensitive configuration files to the public internet.
Step 1: First aid (fast, reversible)
Add a deny block at the top of the web root's .htaccess (a defensive config example). Normal pages are unaffected.
# === SECURITY BLOCK ===
RedirectMatch 404 (?i)/\.(env|git)(\..*)?(/|$)
<FilesMatch "(?i)\.(sql|sql\.gz|bak|old|swp|save|orig|tgz)$|^credentials\.json$|\.bk[._]">
Require all denied
</FilesMatch>
<FilesMatch "(?i)^(phpinfo|info)\.php$">
Require all denied
</FilesMatch>
# if Laravel internals are visible at /<app>/storage/... etc.
RedirectMatch 403 (?i)^/[^/]+/(storage|bootstrap/cache|config|database|resources|routes|tests|vendor)(/|$)
# === END ===First aid isn't the goal
A blacklist always misses something — composer.lock, or any new filename. Treat it as buying time until Step 2.
Step 2: Restructure (the permanent fix)
Move the app body outside the web root and leave a tiny entry point that just loads it.
# 1. Move the body outside the web root (same filesystem → mv is instant, atomic)
mv ~/example/public_html/app ~/example/app-laravel
# 2. Put only a minimal entry in the web root
mkdir -p ~/example/public_html/sub
cat > ~/example/public_html/sub/index.php <<'PHP'
<?php require __DIR__ . '/../../app-laravel/public/index.php';
PHP
# 3. Copy Laravel public/.htaccess, symlink static assets
# 4. Clear config cache
rm -f ~/example/app-laravel/bootstrap/cache/{config,services,packages,routes-v7}.phpA lesson from running this site: bootstrap-redirect over symlink
Making public_html itself a symlink to public/ can return 500 on a registered subdomain's docroot in some environments (it doesn't follow). Keeping the docroot a real directory and require-ing by absolute path from a small index.php (the "bootstrap-redirect" shape) is more stable on scope-constrained shared hosting. Also, opcache can memorize a failed load and keep returning 500 — last resort is to change the entry filename (entry.php).
Step 3: Rotate keys (assume they were seen)
If there was any exposure window, treat the .env keys as already seen and replace them. Order: external API / OAuth secrets → encryption keys → mail → DB. See the .env glossary. Re-issuing an OAuth secret can invalidate refresh_tokens, so have a re-authorization flow ready first.
Step 4: Self-check (make it a habit)
Finally, confirm nothing is actually exposed on your own site (only against domains you own).
# A 200 with a body means it's exposed. 403/404 is okay for now.
curl -sI https://your-domain/.env | head -1
curl -sI https://your-domain/.git/config | head -1Checking this on every deploy catches placement mistakes early.
How to check your own setup (in more detail)
Beyond the two lines in Step 4, these checks catch more of what usually slips through. Run them only against your own server and your own domains.
Look at the content, not just the status code
Some apps answer any unknown URL with the home page and a 200, so the status alone tells you nothing. Print just the start, e.g. curl -s https://your-domain/.env | head -c 120, and see whether config lines like APP_KEY= or DB_PASSWORD= appear. For .git/HEAD, a line starting with ref: refs/heads/ means it's exposed.
Check every app, not just one
Repeat the check for each app on a subdomain or subdirectory, not only the main domain. Placement mistakes tend to be copied when you add apps, so fixing one doesn't mean the others are fixed.
Search the public folder on the server
With SSH, look for risky files directly, e.g. find ~/public_html -maxdepth 4 \( -name ".env*" -o -name ".git" -o -name "*.sql" -o -name "*.zip" \). No output is the result you want. With FTP only, turn on "show hidden files" in your FTP client and review the listing.
Confirm .htaccess actually takes effect
Put a harmless dummy file with no secrets (e.g. check.bak containing just test) in the public folder and open it by URL. You should get 403. If it opens, .htaccess isn't being read, so check the server type (Nginx, for example) or its settings. Delete the dummy file when you're done.
Make sure debug mode is off
In .env, APP_ENV should be production and APP_DEBUG should be false. Laravel's docs say that APP_DEBUG=true in production risks exposing sensitive configuration values to your users. With SSH, php artisan about --only=environment shows the same.
Common mistakes and how to fix them
- Deleting .env but leaving .git: an exposed
.gitcan let someone rebuild past commits, including an old.envor keys. Move.gitout of the public folder too, and re-issue any secret that was ever committed. - Leaving copies like
.env.bakor.env.oldin the public folder: a copy with real values is just as sensitive as.env. Move it out or delete it. Keep only placeholder values in.env.example. - Uploading the whole project as a ZIP and leaving it after unpacking: the ZIP contains
.env. Delete it right after you unpack. - Running production with
APP_DEBUG=true: error pages can reveal configuration values. Always set it tofalsein production. - Assuming you're safe because you wrote
.htaccess: Nginx andAllowOverride Nonesetups never read it. Use the dummy-file check above to confirm. - Swapping
APP_KEYabruptly: per Laravel's docs, changing the key logs out every signed-in user and makes data encrypted with the old key undecryptable. On Laravel 11 and later, put the old key inAPP_PREVIOUS_KEYSbefore switching.
A real example: exposed .env files collected at scale
In August 2024, a security company's research team reported an operation that automatically collected .env files exposed on web servers, used the cloud credentials inside to copy data out, and then demanded ransom. According to the report, about 110,000 domains were targeted and more than 90,000 environment variables were collected, of which about 7,000 were cloud-service credentials and about 1,500 were social-media account credentials.
The report attributes the cause not to a cloud-side vulnerability but to misconfiguration by the victim organizations, which had exposed their .env files by mistake. Two lessons follow:
- Exposed
.envfiles aren't found by chance; programs search for them at scale - One
.envcan spread the damage to other services such as cloud storage and email
Don't assume a small site won't be found. An anonymized case of the same placement mistake is in The .env exposed to the whole world.
Read next
- Incident: The .env exposed to the whole world
- Glossary: What is .env
- Basics: What's dangerous about .env and API keys
- When the provider is breached: what to do when your web host is compromised (the case your own settings cannot prevent)
- Checking for intrusion: how to check if your website or server was hacked (where to look if your .env turns out to have been public)
Sources
- Laravel docs, "Deployment" (point requests at
public/index.php, never serve from the project root,APP_DEBUG=falsein production): laravel.com - Laravel docs, "Configuration" (don't commit
.envto source control; Debug Mode): laravel.com - Laravel docs, "Encryption" (rotating
APP_KEYandAPP_PREVIOUS_KEYS): laravel.com - Apache HTTP Server 2.4, "core - AllowOverride" (
.htaccessis not read when set toNone; the default isNone): httpd.apache.org - Apache HTTP Server 2.4, ".htaccess files" (when to use them, and caveats): httpd.apache.org
- OWASP, "WSTG-CONF-04 Review Old Backup and Unreferenced Files for Sensitive Information": owasp.org
- Unit 42 (Palo Alto Networks), "Leaked Environment Variables Allow Large-Scale Extortion Operation in Cloud Environments" (August 15, 2024): unit42.paloaltonetworks.com