Security Guides

Keeping .env off the public web on shared hosting

Keep .env, .git and vendor unreachable on shared hosting: put the app outside the web root and expose only public/. .htaccess first aid and the restructure.

Published 2026-06-07 Updated 2026-10-06 9 min read
On this page

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
Body outside the docroot, expose only public/. That alone makes .env unreachable.

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.

EnvironmentPermanent fixFirst aidWatch 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 .htaccessMaking 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 clientDeny in .htaccessMost shared hosts let you write above the public folder; if yours doesn't, ask support
Control panel lets you set the public folderPoint the public folder straight at app-laravel/publicNot 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 configWith AllowOverride None (the Apache 2.4 default), .htaccess is not read
VPS (Nginx)Point root at public/ and deny files starting with a dotDeny rules in the server configNginx 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}.php

A 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 -1

Checking 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.

1

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.

2

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.

3

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.

4

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.

5

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 .git can let someone rebuild past commits, including an old .env or keys. Move .git out of the public folder too, and re-issue any secret that was ever committed.
  • Leaving copies like .env.bak or .env.old in 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 to false in production.
  • Assuming you're safe because you wrote .htaccess: Nginx and AllowOverride None setups never read it. Use the dummy-file check above to confirm.
  • Swapping APP_KEY abruptly: 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 in APP_PREVIOUS_KEYS before 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 .env files aren't found by chance; programs search for them at scale
  • One .env can 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.

Sources

  • Laravel docs, "Deployment" (point requests at public/index.php, never serve from the project root, APP_DEBUG=false in production): laravel.com
  • Laravel docs, "Configuration" (don't commit .env to source control; Debug Mode): laravel.com
  • Laravel docs, "Encryption" (rotating APP_KEY and APP_PREVIOUS_KEYS): laravel.com
  • Apache HTTP Server 2.4, "core - AllowOverride" (.htaccess is not read when set to None; the default is None): 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

FAQ

QWhat's the fastest thing I can do right now?
A

Add a deny block to your web root's .htaccess for .env, .git, SQL dumps, credentials.json, etc. It's reversible and doesn't affect normal pages — but it's first aid; the lasting fix is restructuring.

QWhy isn't .htaccess alone enough?
A

A blacklist needs a new rule for every new filename, so something always gets missed. Move the app body outside the web root and .env simply can't be reached, with no rules to maintain.

QWhy not just symlink public/ to expose it?
A

In some environments, re-pointing a registered subdomain's docroot to another tree via symlink doesn't follow and returns 500. In this site's experience, keeping the docroot a real directory and `require`-ing by absolute path from a small index.php (the 'bootstrap-redirect' shape) is more stable.

QHow do I check whether my site's .env is visible from outside?
A

Against a domain you own, open /.env and /.git/HEAD with curl or a browser and look at both the HTTP status and the content. 403 or 404 is fine for now. A 200 that shows config lines like 'APP_KEY=' means it's exposed. If you run several apps on subdomains or subdirectories, check each one. With SSH, also search the public folder on the server for .env or .git.

QWhat if the server runs Nginx, or .htaccess doesn't take effect?
A

Nginx doesn't read .htaccess. On Apache, .htaccess is ignored when AllowOverride is None. In that case, point the web root (root or DocumentRoot) at Laravel's public/ in the server configuration. The Nginx example in Laravel's official docs also includes a rule that denies files starting with a dot (except .well-known).

QIf my .env was exposed, is deleting the file enough?
A

No. If someone fetched it while it was exposed, the keys inside keep working after you delete the file. Re-issue API keys, OAuth secrets, DB and mail passwords and APP_KEY, and check each service's activity for operations you don't recognize. If .git was exposed too, treat any secret that was ever in a past commit as leaked.