# Directory listings and exposed files: .env, .git and backups

> An "Index of /" page or a readable .env file hands database passwords and source code to the first scanner that asks. What leaks, how the report probes for it, and the server rules that close it.

Updated 2026-09-25 · Security · HTML version: https://getreport.app/guides/directory-listings-and-exposed-files

Your web server answers questions nobody meant to ask. "What is in `/wp-content/uploads/2024/`?" gets a neat file list if the folder has no index page. "What is in `/.env`?" gets the database password if the deployment script copied it into the web root. Automated scanners ask exactly these questions of every site on the internet, several times a day. This guide shows what leaks, how the report checks for it, and the two or three server lines that end it. Budget half an hour, plus the time to rotate any secret that was already visible.

## Quick answer

- Open `https://yoursite.com/.env` and `https://yoursite.com/.git/HEAD`. Anything other than a 403 or 404 is an emergency.
- Turn off directory listings: `Options -Indexes` on Apache, `autoindex off;` on nginx (the default).
- Block every path that starts with a dot, except `/.well-known/`, at the web server.
- Keep `.env`, backups and the `.git` folder above the web root, and deploy with an exclude list.
- If `.env` or `.git` was ever readable, rotate every credential in it today. Assume it was copied.
- Re-run the [security headers checker](https://getreport.app/tools/security-headers): both findings should pass.

## Why exposed files matter

A directory listing is an auto-generated page the server produces when a folder has no `index.html` or `index.php` and listing is switched on. It shows every file in the folder with sizes and dates: the uploads someone forgot to unpublish, `backup-2024-03.zip`, `db.sql`, `wp-config.php.bak` left by an editor, `.DS_Store` files from a Mac that reveal the names of files in other folders. None of these is linked from anywhere, and nobody needed a link.

The exposed `.env` file is worse, and more common than it should be. Laravel, Symfony, Node and Python projects keep their secrets in `.env`: database host and password, API keys for the payment provider, the mail server login, the application key that signs sessions. When the project's root is also the web root, or when a deploy script copies the whole folder, the file is one request away. A public `.git` folder is the same problem from the other side: with `.git/HEAD` readable, off-the-shelf tools download the entire repository, including every secret ever committed and later "removed".

The cost is not theoretical. Scanners request `/.env` on every hostname they can find, and a hit is sold or used within hours: database dumped, cards skimmed, the mail account used for spam until the provider closes it. The fix is a few lines; the clean-up after a leak is weeks.

## How getReport checks it

> **Free tool:** [Security headers checker](https://getreport.app/tools/security-headers): Check HSTS, CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy and cookie flags on any site. Free, no signup, with a fix for every missing header.

The security module of every report sends a fixed, short set of extra requests to the site. They are the same requests any visitor could make, and there is no brute-forcing of file names:

- `GET /.env` and `GET /.git/HEAD`, reading at most 4 KB of each. A 200 alone is not enough: the `.env` body has to contain `NAME=value` lines and the `.git/HEAD` body has to look like a Git ref (`ref: refs/heads/main` or a 40-character hash). A site that answers 200 with its home page for every unknown address is therefore not flagged.
- Up to three folders that the page itself loads assets from (the directories of the first `<img>`, `<script>` and stylesheet URLs on the same host, for example `/wp-content/uploads/2024/05/` or `/assets/js/`). Each is requested and the first 8 KB is checked for an "Index of" title or heading, the signature of Apache's and nginx's listing pages.

That is the whole list. Backup archives, `wp-config.php.bak`, `phpinfo.php` and `debug.log` are not probed, because guessing file names on other people's servers is not something a free tool should do at scale. Step 4 below shows how to check those yourself in a minute.

> **Check: No sensitive files are readable.** /.env holds database passwords and API keys; /.git exposes your source code and every secret ever committed. Both are the first things an automated scanner asks for.
>
> 1. Block dotfiles at the web server (nginx: location ~ /\. { deny all; }; Apache: RedirectMatch 404 /\..*$).
> 2. Move .env outside the web root and rotate every credential in it; assume the file has already been copied.

> **Check: No directory shows a file listing.** An auto-generated "Index of /" page shows every file in the folder, including backups, uploads and configuration files nobody ever linked to.
>
> 1. Apache: Options -Indexes in .htaccess; nginx: autoindex off; or put an empty index.html in the folder.

![The exposed files finding opened: the title says sensitive files are readable, the evidence line names /.env as the probe that answered with credential-like content, and the fix list starts with blocking dotfiles at the server and rotating every credential](https://getreport.app/guides/img/directory-listings-and-exposed-files/exposed.webp "The finding names the path that answered; the file's contents are never shown or stored.")

The WordPress Doctor adds a related check when the page reveals WordPress. It requests the `staging.`, `dev.` and `test.` subdomains, and flags a copy that answers 200 without `noindex`, because a forgotten staging copy usually runs older code and often still has its listings and debug files enabled:

> **Check: No public staging copy found.** A staging or dev copy that Google can index competes with the live site for its own content and often runs older, unpatched code. Anyone can find it.
>
> 1. Put the staging site behind HTTP authentication or an IP allow-list, or delete it when the launch is done.
> 2. At minimum switch on "Discourage search engines" there and add a noindex X-Robots-Tag header.

## Step by step

### 1. Look before you change anything

Open each of these in a private browser window, replacing the host:

```text
https://yoursite.com/.env
https://yoursite.com/.git/HEAD
https://yoursite.com/.git/config
https://yoursite.com/wp-content/uploads/
https://yoursite.com/wp-content/debug.log
https://yoursite.com/backup.zip
https://yoursite.com/.DS_Store
```

Write down which ones answer with content. A 403 ("Forbidden") and a 404 are both fine. A blank page for `.env` can still be a hit (the browser hides text it cannot format), so check with `curl -s https://yoursite.com/.env | head` if you can.

### 2. If `.env` or `.git` was readable, rotate first

Treat the secrets as burned. Before touching the server configuration, in this order:

1. Change the database password and update the application's copy.
2. Regenerate every API key listed in the file: payment provider, mail service, cloud storage, maps, CRM. Each provider has a "roll key" button.
3. Regenerate the application key (`php artisan key:generate` in Laravel; the equivalent in your framework). This logs everyone out, which is what you want.
4. Check the mail provider's sent log and the database for anything you did not do. Our guide on [recognising a hacked site](https://getreport.app/guides/is-my-website-hacked) covers the wider clean-up.

For a public `.git` folder, also rotate anything that was ever committed and later deleted; history keeps it. Then decide whether the source code itself being public is a problem (licence keys, internal hostnames) and act on that.

### 3. Turn off directory listings

**Apache** (`.htaccess` in the web root, or the `<Directory>` block in the virtual host):

```apache
# .htaccess – no auto-generated file lists anywhere below this folder
Options -Indexes
```

If the host has disabled `.htaccess` overrides, the same line goes in the virtual host's `<Directory /var/www/example/public>` block, followed by a reload.

**nginx** does not list directories unless `autoindex on;` was added somewhere. Search the configuration for it and remove it, or override:

```nginx
# /etc/nginx/sites-available/example.conf, inside the server block
autoindex off;
```

**Caddy**'s `file_server` only lists directories when you write `file_server browse`; remove `browse`.

The fallback that works everywhere, including shared hosts with no configuration access: an empty `index.html` in every folder that must not be listed. WordPress ships one in `wp-content/`, `wp-content/plugins/` and `wp-content/themes/`, but not in `wp-content/uploads/`, so that folder is the one to check.

### 4. Block dot-paths at the server, keep `.well-known`

Every file or folder whose name starts with a dot is configuration: `.env`, `.git`, `.htaccess`, `.DS_Store`, `.svn`, `.idea`. None of them should ever be served. The one exception is `/.well-known/`, which browsers, certificate authorities and `security.txt` rely on.

**nginx**:

```nginx
# inside the server block, before other location blocks
location ~ /\.(?!well-known/) {
    return 404;
}
```

**Apache** (`.htaccess` or virtual host, Apache 2.4):

```apache
# Files starting with a dot: .env, .DS_Store, .htpasswd …
<FilesMatch "^\.">
    Require all denied
</FilesMatch>

# Folders starting with a dot and everything inside them: .git/HEAD, .svn/…
RedirectMatch 404 "/\.(?!well-known/)"
```

`FilesMatch` alone does not cover `.git/HEAD`, because that file's own name has no dot; the `RedirectMatch` line covers the folder. Answering 404 rather than 403 gives a scanner one less hint that the file exists.

**Caddy**:

```caddy
example.com {
    @dotfiles {
        path /.*
        not path /.well-known/*
    }
    respond @dotfiles 404
    root * /var/www/example/public
    file_server
}
```

### 5. Keep secrets above the web root

Server rules are the safety net; the real fix is that the file is not there to serve. Frameworks already expect this: Laravel and Symfony serve from `public/` with `.env` one level up, Next.js never puts `.env` in `public/`, and WordPress will read `wp-config.php` from the folder above the web root if it is not in the root itself. Move the file, then make sure the deployment does not bring it back.

For rsync-based deploys, exclude the files that must never leave your machine:

```bash
# deploy.sh – copy the built site without secrets or history
rsync -az --delete \
  --exclude='.git/' \
  --exclude='.env' \
  --exclude='.env.*' \
  --exclude='*.sql' \
  --exclude='*.zip' \
  --exclude='.DS_Store' \
  ./ deploy@server:/var/www/example/
```

With Git-based deploys, `git archive` produces a tree without the `.git` folder, and a `.gitattributes` line `.env export-ignore` keeps environment files out of the archive. With Docker, list the same paths in `.dockerignore`.

### 6. Move backups out of reach

Backup plugins and hosting panels sometimes write archives into the web root or into `wp-content/uploads/`. Configure them to write outside the web root or straight to remote storage (S3, R2, Google Drive), delete the archives that are already there, and check that the backup folder itself is not listable. A `db.sql` at the root is the single most damaging file a listing can reveal.

### 7. WordPress specifics

- **`wp-content/uploads/` listing.** Add an empty `index.html` to `wp-content/uploads/` or apply step 3 site-wide. The upload folders for each month (`uploads/2025/09/`) inherit the server rule.
- **`wp-content/debug.log`.** When `WP_DEBUG_LOG` is `true`, WordPress writes PHP errors, including file paths and sometimes query fragments, to a file under `wp-content/` that is publicly readable. On production set `WP_DEBUG` to `false`, or point the log elsewhere:

```php
// wp-config.php – debugging on production, if you must, goes outside the web root
define('WP_DEBUG', true);
define('WP_DEBUG_DISPLAY', false);
define('WP_DEBUG_LOG', '/var/log/wordpress/debug.log');
```

- **`wp-config.php.bak`, `wp-config.php~`, `wp-config.old`.** Editors leave them, and PHP does not run them, so the server sends the raw file with the database password inside. Delete them, and add a rule that denies `wp-config` variants:

```apache
# .htaccess – refuse every wp-config variant, including backups
<FilesMatch "^wp-config\.php.*$">
    Require all denied
</FilesMatch>
```

- **Staging copies.** The report's staging finding tells you when `staging.yoursite.com` is public. Put it behind HTTP authentication or delete it; a staging copy is usually where the debug log and the listings are.

## Verify

- `curl -sI https://yoursite.com/.env` and `curl -sI https://yoursite.com/.git/HEAD` print `404` or `403`.
- `curl -sI https://yoursite.com/.well-known/security.txt` still works if you have one; the dot rule must not catch it.
- `curl -s https://yoursite.com/wp-content/uploads/ | head` returns your theme's 404 page or an empty response, not `<title>Index of`.
- Re-run the report: "No sensitive files are readable" and "No directory shows a file listing" both pass, and the WordPress Doctor shows no public staging copy.
- Every secret from a leaked file has been rotated and the old values no longer work.

## Common mistakes

- **Blocking dotfiles including `.well-known`.** Symptom: Let's Encrypt renewals fail and `security.txt` returns 404. Fix: add the `(?!well-known/)` exclusion.
- **Fixing the server and keeping the old password.** Symptom: the file is gone, the database is still dumped a month later. Fix: rotate everything the file contained, today.
- **`Options -Indexes` in `.htaccess` on a host that ignores overrides.** Symptom: the line is there, the listing is too. Fix: check `AllowOverride` in the virtual host, or use the empty `index.html` fallback.
- **A 200 for everything.** Symptom: `/.env` returns the home page and looks fine, but every unknown URL is a soft 404 that search engines index. Fix: return real 404s; the report's custom 404 finding covers this.
- **Deploying the whole project folder.** Symptom: `.git` reappears after every release. Fix: an exclude list in the deploy script or a build step that produces a clean tree.
