Database audit fix

How to Stop .env and Config Files Being Served From the Web Root

Config files exposed in the web root are credential-bearing files such as .env or wp-config.php sitting inside a served tree, where one handler misconfiguration returns them as plain text. Move them above the document root.

Hiren KalariyaLast reviewed: Aug 30, 2026Check config-files-in-web-root
Medium
severity
No
needs root or sudo
2
results it can return
1 of 4
checks in this audit

Every threshold on this page is transcribed from the database-security-credential-storage audit script that CtrlOps runs, and a build check fails if the two ever disagree. See all 4 Credential Storage checks, or how the audit runs.

Medium severityNo sudo needed

What this check reads

The check runs one find across three fixed web roots and counts what comes back:

find /var/www /srv/www /usr/share/nginx/html -maxdepth 4 -type f \
  \( -name '.env' -o -name 'wp-config.php' -o -name 'config.php' -o -name 'settings.php' -o -name 'database.yml' \) | head -20

Nothing is read out of the files themselves: carrying one of those five names, inside one of those three trees, at a depth of 4 or less, is the entire test. A count of zero is the only PASS, and one match or more is a WARN, in which case the message quotes the first two paths (head -2) along with the total.

When it applies

Always runs - it is a filesystem scan, not gated on any database or web server being detected, and needs no root. Scope is fixed and narrow: exactly three roots (/var/www, /srv/www, /usr/share/nginx/html), a maximum depth of 4, five filename patterns, and only the first 20 matches (head -20). Sites served from /home/*/public_html, /opt or any other root are never scanned, so PASS means "nothing found in those three trees", not "no exposed config anywhere". Files the audit user cannot traverse to are silently skipped.

What each result means

Result thresholds this check applies
ResultWhenWhat it means
PASSNo matching filesNothing matched in the three scanned roots, so a site served from a path outside them was never looked at.
WARNOne or more foundAt least one credential-bearing config file sits inside a served tree, and the message names the first two plus the total count.

Why it matters

Credential-bearing files inside a document root are one misconfiguration away from being served as plain text: a PHP handler outage serves wp-config.php raw; a missing dotfile deny rule serves .env. OWASP Top 10 A05:2021 Security Misconfiguration; OWASP Secrets Management Cheat Sheet; WSTG-CONF-04 "Review Old Backup and Unreferenced Files". Best practice is to keep config outside the web root and load it by path, or use environment variables injected by the service manager.

Why it fails, and when it is wrong

  • WordPress requires wp-config.php next to or one level above wp-content. Moving it one directory above the document root is supported by WordPress and resolves the finding.
  • Laravel, Symfony and Node projects often have .env at the project root while the real document root is public/. The file is then not served, but it is still within /var/www/<app> and will be reported. This is a WARN, not FAIL, for that reason. Verify with curl -I https://host/.env (expect 403/404) and the Hidden File Exposure web check.
  • Only three common roots are searched, to a depth of 4. Sites under /home/*/public_html or /opt are not scanned.
  • Files are only readable by the audit user if permissions allow; unreadable files are silently skipped (2>/dev/null).

How to fix it

Find the files

Start from the full list rather than the two paths (head -2) the finding quotes, by re-running the scan the check performs:

find /var/www /srv/www /usr/share/nginx/html -maxdepth 4 -type f \
  \( -name '.env' -o -name 'wp-config.php' -o -name 'config.php' -o -name 'settings.php' -o -name 'database.yml' \) 2>/dev/null

Move what can move

Move the file above the document root and point the app at it. WordPress looks for wp-config.php in the directory above the web root with no configuration change at all, so placing it in the parent directory resolves the finding on its own. For Laravel, Symfony and Node projects the web root should be public/, which leaves the project-root .env outside the served tree.

Block what cannot move

For anything that has to stay where it is, add an explicit deny so that even a misconfigured handler cannot serve it:

location ~ /\.(?!well-known) { deny all; }
location ~* (wp-config\.php|config\.php|database\.yml)$ { deny all; }
<FilesMatch "^(\.env|wp-config\.php|database\.yml)$">
  Require all denied
</FilesMatch>

A deny rule depends on the config staying correct through every future change, so treat it as the second layer. Better still, prefer environment variables or a secrets manager for database credentials, which leaves no file on disk to serve.

Verify from outside

Request the file over HTTP from another machine and confirm the server hands back a refusal rather than the contents:

curl -sS -o /dev/null -w '%{http_code}\n' https://your-host/.env
# expected: 403 or 404

Verify the fix

# Reproduce the exact scan the check performs:
find /var/www /srv/www /usr/share/nginx/html -maxdepth 4 -type f \
  \( -name '.env' -o -name 'wp-config.php' -o -name 'config.php' -o -name 'settings.php' -o -name 'database.yml' \) 2>/dev/null
# expected after the fix: no output

# And confirm the file is not actually served (the finding that really matters):
curl -sS -o /dev/null -w '%{http_code}\n' https://your-host/.env
# expected: 403 or 404

Debugging

Most often a framework .env at the project root while the document root is public/. The file is inside /var/www/<app> so it is reported, even though it is not served. Confirm with the curl above; if it returns 403/404 this is a defence-in-depth WARN, not an exposure. That is exactly why the check is WARN and not FAIL.

Your web root is outside the three scanned paths, deeper than 4 levels, or you have more than 20 matches and the extras were truncated by head -20. Re-run the find above against your real root: find /path/to/root -maxdepth 6 -type f -name '.env' -o -name 'wp-config.php'.

The message lists only the first two (head -2) plus a total count. Run the find command to see the full list.

The scan runs as the audit user with 2>/dev/null, so permission-denied directories vanish silently. Re-run the find under sudo to compare counts.

Sources

How the script reads this

Next

Re-run the Credential Storage audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 4 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.

All 4 Credential Storage fixes
Audit your fleet

Find every one of these on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals