To keep secrets out of wp-config.php and out of version control, load them from a .env file with the vlucas/phpdotenv library and read them through a tiny helper. The whole pattern is this: install phpdotenv with Composer, load the .env near the top of wp-config.php, then feed the values into the define() calls WordPress already expects.
require_once __DIR__ . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable( __DIR__ );
$dotenv->load();
function te_env( $key, $default = null ) {
$value = $_ENV[ $key ] ?? getenv( $key );
return false === $value || null === $value ? $default : $value;
}
define( 'DB_NAME', te_env( 'DB_NAME' ) );
define( 'DB_USER', te_env( 'DB_USER' ) );
define( 'DB_PASSWORD', te_env( 'DB_PASSWORD' ) );
define( 'DB_HOST', te_env( 'DB_HOST', 'localhost' ) );Your .env lives next to wp-config.php (or one level up, outside the web root, which is better) and holds the actual values:
DB_NAME=production_db
DB_USER=wp_app
DB_PASSWORD=the-real-password-here
DB_HOST=127.0.0.1That is the technique. The rest of this article is the parts that matter once you ship it: where to install phpdotenv, how createImmutable() behaves, and the three security must-dos that turn this from "nice idea" into "did not just leak your database password to the internet."
Why move secrets out of wp-config.php at all
The default WordPress setup hardcodes everything in wp-config.php: database credentials, the salts, and over time every plugin's API key (define( 'SENDGRID_API_KEY', '...' ), a Stripe secret, an S3 access key). That file then sits in the same directory tree as your code. The moment wp-config.php is tracked in git, or copied into a backup that ends up somewhere readable, or differs per environment and someone commits the wrong copy, the secret is out.
The Twelve-Factor App "Config" guidance is the cleanest statement of the principle: strict separation of config from code, with anything that varies between deploys (and anything secret) held in the environment, not in a file you commit. A .env file is the local, file-backed version of that environment. The same wp-config.php ships everywhere; only the .env differs, and the .env never enters version control.
When does the traditional all-in-wp-config.php approach still make sense? On a single hand-managed site that you never deploy from git, where wp-config.php already lives outside any repository, the env file buys you little. The pattern earns its keep the moment you have more than one environment (local, staging, production), a deploy pipeline that pulls code from git, or secrets that a teammate should never see in plaintext in the repo.
Installing phpdotenv with Composer
phpdotenv is a Composer package. From the directory that holds (or will hold) your composer.json:
composer require vlucas/phpdotenvThat writes the library into vendor/ and an autoloader at vendor/autoload.php. Everything downstream depends on that autoloader being required before you call Dotenv\Dotenv.
Where you put the Composer project depends on how your install is laid out:
- Composer-managed WordPress (Bedrock-style or a custom layout): you already have a root
composer.jsonabove the web root. phpdotenv installs into the existingvendor/, and the autoload is already wired up. - A traditional install you do not want to "Composerize": run
composer requirein the WordPress root (or a directory beside it) purely to vendor the one library, thenrequire_onceits autoloader fromwp-config.php.
Loading it from wp-config.php vs an MU-plugin
There are two places the load can happen, and the right one depends on what you are reading.
Database credentials and salts must load in wp-config.php, because WordPress reads DB_NAME and friends before any plugin runs. The autoload + Dotenv::createImmutable() + define() block goes near the top of wp-config.php, above the require_once ABSPATH . 'wp-settings.php'; line.
Plugin API keys and other non-core secrets (an SMTP key, a payment-gateway secret) do not need to exist that early. For those, a must-use plugin is the tidier home: it loads automatically, survives theme switches, and keeps the secret-reading code out of the database. If you autoload Composer dependencies from an MU-plugin already, that is the natural spot. I walk through that wiring in loading Composer packages from a WordPress must-use plugin. If the .env is already loaded in wp-config.php, the values are in $_ENV by the time the MU-plugin runs, so the plugin just reads them with the same te_env() helper and does not load the .env a second time.
Reading values and feeding the constants
Dotenv\Dotenv::createImmutable( __DIR__ ) builds a loader pointed at the directory holding the .env, and load() parses it. "Immutable" means it will not overwrite a variable that is already set in the real environment, so a value exported by your server or container (a true environment variable) wins over the file. That is usually what you want: the .env is the local default, and the platform's real env vars override it in production if you set them there. If you genuinely need the file to clobber existing env vars, createMutable() exists, but reach for it deliberately.
After load(), every KEY=value line is available three ways: $_ENV['KEY'], $_SERVER['KEY'], and getenv('KEY'). I wrap the read in one small helper so the access pattern (and the default-value handling) lives in exactly one place:
function te_env( $key, $default = null ) {
$value = $_ENV[ $key ] ?? getenv( $key );
if ( false === $value || null === $value ) {
return $default;
}
return $value;
}Then feed those into the define() calls WordPress expects. The constant names are fixed by core (DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, the salts, WP_DEBUG), so they stay exactly as WordPress names them, the helper just supplies the value:
define( 'DB_NAME', te_env( 'DB_NAME' ) );
define( 'DB_USER', te_env( 'DB_USER' ) );
define( 'DB_PASSWORD', te_env( 'DB_PASSWORD' ) );
define( 'DB_HOST', te_env( 'DB_HOST', 'localhost' ) );
define( 'AUTH_KEY', te_env( 'AUTH_KEY' ) );
define( 'SECURE_AUTH_KEY', te_env( 'SECURE_AUTH_KEY' ) );
// ... and the rest of the salts.
define( 'WP_DEBUG', filter_var( te_env( 'WP_DEBUG', 'false' ), FILTER_VALIDATE_BOOLEAN ) );Two details worth flagging. First, everything in a .env is a string, so WP_DEBUG=false reads back as the string "false", which is truthy in PHP. Run boolean-ish values through filter_var( ..., FILTER_VALIDATE_BOOLEAN ) so false, 0, and off all resolve correctly. Second, for plugin secrets you do not have to define() a constant at all, you can read te_env( 'SENDGRID_API_KEY' ) directly wherever the value is used. Constants make sense when WordPress or a plugin specifically looks one up; otherwise the direct read is fine. (If you are wiring up transactional mail, sending WordPress email through SendGrid is a good example of a key that belongs in .env, not in the plugin's settings table.)
A .env for a fuller setup looks like this:
DB_NAME=production_db
DB_USER=wp_app
DB_PASSWORD=the-real-password-here
DB_HOST=127.0.0.1
AUTH_KEY=put-a-unique-salt-here
SECURE_AUTH_KEY=another-unique-salt
# ... the rest of the eight salts
WP_DEBUG=false
SENDGRID_API_KEY=SG.real-key-valueThe security must-dos (do not skip these)
The env-file pattern only improves your security posture if you get these three right. Get them wrong and you have moved the secret from a file that was at least usually outside git into a file that is sitting in your web root and committed to the repo, which is strictly worse.
1. Add .env to .gitignore, always
The entire point is keeping secrets out of version control. Add the file to .gitignore before you ever write a real value into it:
.env
.env.*
!.env.exampleThat ignores .env and any .env.local / .env.production variants, while explicitly un-ignoring .env.example (covered below). If you have already committed a real .env once, the secret is in your git history even after you delete the file, so rotate those credentials, do not just git rm the file and assume you are clean.
2. Keep .env out of the web root, or block HTTP access to it
A .env sitting in the document root is one misconfigured server away from being served as plaintext. Anyone who requests https://yoursite.com/.env and gets a 200 back has your database password, your salts, and every API key in one response. This is a routine target for automated scanners. Treat it the same way you would an exposed .git directory: a directory or file the server should never hand to a visitor.
Two ways to be safe, in order of preference:
- Best: put
.envone level above the web root. If WordPress lives in/var/www/site/public/, put the.envin/var/www/site/and pointDotenv::createImmutable( dirname( __DIR__ ) )(or an explicit path) at it. A file outside the document root cannot be requested over HTTP at all. - If it must stay in the web root, block it at the server. On Apache add a rule to
.htaccessthat denies the file, and on nginx add alocationblock that returns 404 for it:
<Files ".env">
Require all denied
</Files>location ~ /\.env {
deny all;
return 404;
}Do not rely on the server config alone if you can avoid it. The above-the-web-root placement is the one that holds up when someone later swaps Apache for nginx and forgets to port the rule.
3. Commit a .env.example with blank values
Because the real .env is git-ignored, a fresh clone has no idea which keys it needs. Commit a .env.example (which is tracked) listing every key with empty or dummy values:
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_HOST=localhost
AUTH_KEY=
SECURE_AUTH_KEY=
# ... the rest of the salts
WP_DEBUG=false
SENDGRID_API_KEY=Setup on a new machine becomes cp .env.example .env and fill in the blanks. It documents the required config, it never contains a secret, and it is the file the !.env.example line in .gitignore deliberately keeps tracked.
.env vs wp-config constants: when to use which
These are not mutually exclusive, and the right call depends on the site.
| Concern | .env + phpdotenv | Constants hardcoded in wp-config.php |
|---|---|---|
| Secrets in git | Kept out (file ignored) | In the repo if wp-config.php is committed |
| Per-environment config | One codebase, many .env files | Edit the file or maintain copies per environment |
| Multi-environment deploys | Built for it | Painful, error-prone |
| Single hand-managed site, no git | Extra moving part | Perfectly fine, simpler |
| Reading config | Through te_env() / $_ENV | Direct constant reference |
| Dependency | Needs Composer + phpdotenv | None |
Use the env file when secrets must stay out of version control, when config varies per environment, or when a deploy pipeline pulls code from git. Stick with plain wp-config.php constants on a single, hand-managed site that lives outside any repo, where the env file is just one more thing to install. The phpdotenv approach also pairs naturally with the kind of performance and ops work in optimizing a WooCommerce store, where staging and production routinely point at different databases and services.
Verify it works
After wiring it up, confirm three things:
- The site loads. If the database constants are reading correctly from
.env, WordPress connects. An "Error establishing a database connection" usually means a typo in a.envkey name or a stray quote in a value (phpdotenv does handle quoted values, but unbalanced quotes break parsing). - The
.envis not committed. Rungit statusandgit check-ignore .env. Ifgit check-ignore .envprints.env, it is correctly ignored. Ifgit statusshows it as a tracked or staged file, fix.gitignorebefore you commit anything. - The
.envis not reachable over HTTP. Requesthttps://yoursite.com/.envin a browser or withcurl -I https://yoursite.com/.env. A 403 or 404 is what you want. A 200 with your credentials in the body means the file is exposed, stop and fix the placement or server rule immediately, then rotate the leaked secrets.
See also
- Autoload Composer packages from a WordPress must-use plugin: the cleaner home for loading phpdotenv and other Composer dependencies when the secret does not need to exist as early as
wp-config.php - Send WordPress email through SendGrid: a textbook case of an API key that belongs in
.envrather than a plugin's settings table - Clean up wp_head in WordPress: the same "do not hand visitors something the server should never serve" instinct, applied to the default head output
- How to optimize WooCommerce: where per-environment config really earns its keep, since staging and production routinely point at different databases and services
Sources
Authoritative references this article was fact-checked against.
- vlucas/phpdotenv: GitHub repository and READMEgithub.com
- The Twelve-Factor App: Config12factor.net
- Editing wp-config.php: WordPress Developer Referencedeveloper.wordpress.org





