Back to Blog
DevOps & Deployment

WordPress Environment Variables and Secrets Management for Modern Deployments

Marcus Chen
53 min read

WordPress was born in an era when a single wp-config.php file sitting on a shared hosting account was all you needed. You’d hard-code your database credentials, salts, and maybe a debug flag, then forget about it. That approach still works for a personal blog on a $5/month host. But if you’re running WordPress across staging, production, and review environments, deploying through CI/CD pipelines, or orchestrating containers with Kubernetes, hard-coded secrets become a liability fast.

This article covers the full spectrum of environment variable and secrets management strategies for WordPress, from simple .env files to HashiCorp Vault integrations. The goal is practical: real configuration patterns you can adapt to your own infrastructure, with enough context to understand the trade-offs.

PHP’s Native Environment Variable Functions

Before reaching for any library, understand what PHP gives you out of the box. Three functions handle environment variable reading, and they behave differently in ways that matter.

getenv('DB_HOST') reads from the actual OS-level environment. It pulls values set by the shell, Docker, systemd, or whatever process manager launched PHP. This is the most portable option and works identically whether PHP runs as Apache mod_php, PHP-FPM, or CLI.

$_ENV['DB_HOST'] is a superglobal populated at PHP startup, but only if the variables_order directive in php.ini includes “E”. Many production PHP installations strip this out for performance, which means $_ENV can be empty even when getenv() works fine. Never rely on $_ENV as your sole method.

$_SERVER['DB_HOST'] gets populated from both environment variables and web server variables. Apache’s SetEnv and Nginx’s fastcgi_param both write into this superglobal. It’s useful but muddied by the fact that it mixes environment data with HTTP request data.

Here’s a reliable helper that checks all three sources in priority order:

function wpkite_env( string $key, $default = null ) {
    // 1. Real environment variable (most reliable)
    $value = getenv( $key );
    if ( $value !== false ) {
        return $value;
    }

    // 2. $_ENV superglobal (depends on variables_order)
    if ( isset( $_ENV[ $key ] ) ) {
        return $_ENV[ $key ];
    }

    // 3. $_SERVER (catches SetEnv / fastcgi_param)
    if ( isset( $_SERVER[ $key ] ) ) {
        return $_SERVER[ $key ];
    }

    return $default;
}

This function becomes the foundation for everything that follows. You call wpkite_env('DB_HOST', 'localhost') and get the right value regardless of how PHP was invoked.

One important detail: getenv() returns strings. Always. The string "false" is truthy in PHP. If you’re reading boolean flags like WP_DEBUG, you need explicit casting:

function wpkite_env_bool( string $key, bool $default = false ): bool {
    $value = wpkite_env( $key );
    if ( $value === null ) {
        return $default;
    }
    return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
}

// Usage:
define( 'WP_DEBUG', wpkite_env_bool( 'WP_DEBUG', false ) );

filter_var with FILTER_VALIDATE_BOOLEAN handles “true”, “1”, “yes”, “on” as true, and “false”, “0”, “no”, “off” as false. This eliminates the entire class of bugs where WP_DEBUG=false in your .env file actually enables debug mode because the string “false” is truthy.

The phpdotenv Approach

Vlucas’s phpdotenv library has become the standard for loading .env files in PHP. It reads key-value pairs from a .env file and injects them into getenv(), $_ENV, and $_SERVER, making them accessible through the same native functions.

Install it via Composer:

composer require vlucas/phpdotenv

Then load it early in wp-config.php, before any define() calls:

require_once __DIR__ . '/vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable( __DIR__ );
$dotenv->load();

// Now environment variables are available
define( 'DB_NAME', wpkite_env( 'DB_NAME' ) );
define( 'DB_USER', wpkite_env( 'DB_USER' ) );
define( 'DB_PASSWORD', wpkite_env( 'DB_PASSWORD' ) );
define( 'DB_HOST', wpkite_env( 'DB_HOST', 'localhost' ) );

The createImmutable method is critical. It means that if an environment variable already exists (set by Docker, Kubernetes, or the system), the .env file will not overwrite it. This gives you the correct precedence: real environment variables win, .env files serve as defaults for local development.

If you use createMutable instead, the .env file overwrites existing variables. That’s almost never what you want in production, but it can be useful in testing scenarios where you need to force specific values.

phpdotenv also supports validation, which catches misconfiguration before WordPress tries to connect to a nonexistent database:

$dotenv->required([
    'DB_NAME',
    'DB_USER',
    'DB_PASSWORD',
])->notEmpty();

$dotenv->required('WP_ENV')->allowedValues([
    'development',
    'staging',
    'production',
]);

$dotenv->ifPresent('DB_PORT')->isInteger();

This validation runs at boot time and throws a clear exception if something’s missing. Compare that to the default WordPress behavior, where a missing DB_PASSWORD produces a cryptic “Error establishing a database connection” message that sends you hunting through config files.

Your .env file lives in the project root and should never be committed to version control:

# .env
DB_NAME=wpkite_dev
DB_USER=root
DB_PASSWORD=secret
DB_HOST=127.0.0.1
DB_PORT=3306

WP_ENV=development
WP_HOME=https://wpkite.com
WP_SITEURL=https://wpkite.com

WP_DEBUG=true
WP_DEBUG_LOG=true
WP_DEBUG_DISPLAY=true

AUTH_KEY='put-your-unique-phrase-here'
SECURE_AUTH_KEY='put-your-unique-phrase-here'
LOGGED_IN_KEY='put-your-unique-phrase-here'
NONCE_KEY='put-your-unique-phrase-here'
AUTH_SALT='put-your-unique-phrase-here'
SECURE_AUTH_SALT='put-your-unique-phrase-here'
LOGGED_IN_SALT='put-your-unique-phrase-here'
NONCE_SALT='put-your-unique-phrase-here'

Provide a .env.example file (committed to the repo) that documents every variable without real values. New developers clone the repo, copy .env.example to .env, fill in their local values, and start working immediately. No reading wiki pages to figure out what WordPress expects.

Multi-Environment wp-config.php Patterns

The default WordPress wp-config.php is a flat file with no concept of environments. Every tutorial tells you to edit it directly. That works until you have more than one environment, at which point you start maintaining separate config files per server or doing fragile hostname detection.

A better pattern splits wp-config.php into a base configuration and environment-specific overrides. Here’s the structure:

config/
├── environments/
│   ├── development.php
│   ├── staging.php
│   └── production.php
├── application.php       # Shared config, reads env vars
wp-config.php             # Thin loader

The entry point, wp-config.php, becomes a thin dispatcher:

<?php
// wp-config.php - Thin loader, do not put config here

require_once __DIR__ . '/vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable( __DIR__ );
if ( file_exists( __DIR__ . '/.env' ) ) {
    $dotenv->load();
}

// Load shared configuration
require_once __DIR__ . '/config/application.php';

// Load environment-specific overrides
$environment = wpkite_env( 'WP_ENV', 'production' );
$env_config  = __DIR__ . '/config/environments/' . $environment . '.php';

if ( file_exists( $env_config ) ) {
    require_once $env_config;
}

// WordPress bootstrap
require_once ABSPATH . 'wp-settings.php';

Notice the default: if WP_ENV isn’t set, it falls back to 'production'. This is a deliberate safety measure. Forgetting to set an environment variable should result in the most restrictive configuration, not the most permissive one. You never want debug mode accidentally enabled in production because someone forgot to export WP_ENV.

The shared application.php contains everything common to all environments:

<?php
// config/application.php

// Database
define( 'DB_NAME', wpkite_env( 'DB_NAME' ) );
define( 'DB_USER', wpkite_env( 'DB_USER' ) );
define( 'DB_PASSWORD', wpkite_env( 'DB_PASSWORD' ) );
define( 'DB_HOST', wpkite_env( 'DB_HOST', 'localhost' ) );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );

$table_prefix = wpkite_env( 'DB_PREFIX', 'wp_' );

// Authentication keys and salts
define( 'AUTH_KEY', wpkite_env( 'AUTH_KEY' ) );
define( 'SECURE_AUTH_KEY', wpkite_env( 'SECURE_AUTH_KEY' ) );
define( 'LOGGED_IN_KEY', wpkite_env( 'LOGGED_IN_KEY' ) );
define( 'NONCE_KEY', wpkite_env( 'NONCE_KEY' ) );
define( 'AUTH_SALT', wpkite_env( 'AUTH_SALT' ) );
define( 'SECURE_AUTH_SALT', wpkite_env( 'SECURE_AUTH_SALT' ) );
define( 'LOGGED_IN_SALT', wpkite_env( 'LOGGED_IN_SALT' ) );
define( 'NONCE_SALT', wpkite_env( 'NONCE_SALT' ) );

// URLs
define( 'WP_HOME', wpkite_env( 'WP_HOME' ) );
define( 'WP_SITEURL', wpkite_env( 'WP_SITEURL' ) );

// Security defaults
define( 'DISALLOW_FILE_EDIT', true );
define( 'FORCE_SSL_ADMIN', true );

if ( ! defined( 'ABSPATH' ) ) {
    define( 'ABSPATH', __DIR__ . '/../' );
}

Then each environment file overrides only what it needs:

<?php
// config/environments/development.php

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', true );
define( 'SCRIPT_DEBUG', true );
define( 'SAVEQUERIES', true );
define( 'DISALLOW_FILE_EDIT', false );
<?php
// config/environments/staging.php

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
define( 'DISALLOW_FILE_MODS', true );
<?php
// config/environments/production.php

define( 'WP_DEBUG', false );
define( 'WP_DEBUG_LOG', false );
define( 'WP_DEBUG_DISPLAY', false );
define( 'DISALLOW_FILE_MODS', true );
define( 'WP_CACHE', true );
define( 'COMPRESS_CSS', true );
define( 'COMPRESS_SCRIPTS', true );
define( 'CONCATENATE_SCRIPTS', true );

This pattern means your wp-config.php can be safely committed to version control. It contains no secrets. Every sensitive value comes from environment variables or the .env file (which is gitignored). Different environments get different behavior automatically based on a single WP_ENV variable.

Docker and docker-compose Environment Variables

Docker is now the standard local development environment for WordPress teams, and increasingly used in production too. Understanding how Docker passes environment variables to containers is essential.

The simplest method is the environment key in docker-compose.yml:

version: '3.8'

services:
  wordpress:
    image: wordpress:6.4-php8.2-apache
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wp_user
      WORDPRESS_DB_PASSWORD: wp_pass_123
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DEBUG: "true"
    volumes:
      - ./wp-content:/var/www/html/wp-content
    depends_on:
      - db

  db:
    image: mariadb:10.11
    environment:
      MYSQL_ROOT_PASSWORD: root_pass_456
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wp_user
      MYSQL_PASSWORD: wp_pass_123
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

This works but has a problem: passwords are in plain text in a file that’s often committed to version control. The env_file directive is slightly better:

services:
  wordpress:
    image: wordpress:6.4-php8.2-apache
    env_file:
      - .env
      - .env.wordpress
    ports:
      - "8080:80"

This loads variables from external files that you can gitignore. But the variables still end up as plain-text environment variables inside the container, visible through docker inspect and /proc/[pid]/environ.

Docker Secrets

Docker Swarm introduced secrets as a way to pass sensitive data to containers without exposing it in environment variables. The secret is stored encrypted in the Swarm raft log and mounted as a file inside the container at /run/secrets/<secret_name>.

version: '3.8'

services:
  wordpress:
    image: wordpress:6.4-php8.2-apache
    secrets:
      - db_password
      - db_user
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER_FILE: /run/secrets/db_user
      WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
      WORDPRESS_DB_NAME: wordpress

secrets:
  db_password:
    external: true
  db_user:
    external: true

The official WordPress Docker image supports the _FILE suffix convention. When you set WORDPRESS_DB_PASSWORD_FILE instead of WORDPRESS_DB_PASSWORD, the entrypoint script reads the secret from that file path. This is handled by the docker-entrypoint.sh script built into the official image.

For custom WordPress Docker images or when you need this pattern in your own code, here’s the helper function the official image uses, adapted for general WordPress use:

function wpkite_env_docker( string $key, $default = null ) {
    // Check for _FILE variant first (Docker secrets)
    $file_key = $key . '_FILE';
    $file_path = getenv( $file_key );

    if ( $file_path !== false && is_readable( $file_path ) ) {
        return rtrim( file_get_contents( $file_path ), "\r\n" );
    }

    // Fall back to standard environment variable
    return wpkite_env( $key, $default );
}

// Usage in wp-config.php:
define( 'DB_PASSWORD', wpkite_env_docker( 'DB_PASSWORD' ) );
define( 'DB_USER', wpkite_env_docker( 'DB_USER' ) );

The rtrim call is important because many secret management tools append a newline to secret files. Without trimming, your database password would include a trailing newline character and authentication would fail silently.

For local development with Docker Compose (not Swarm), you can simulate secrets using file-based secrets:

secrets:
  db_password:
    file: ./secrets/db_password.txt
  db_user:
    file: ./secrets/db_user.txt

Each file contains just the secret value, no key=value format, no quotes, just the raw string. Add the secrets/ directory to your .gitignore.

Kubernetes: ConfigMaps, Secrets, and WordPress Pods

Running WordPress on Kubernetes is increasingly common for organizations that need horizontal scaling, automated failover, or unified infrastructure management. Kubernetes has native primitives for both configuration and secrets that map well to WordPress needs.

ConfigMaps for Non-Sensitive Configuration

A ConfigMap stores non-sensitive key-value pairs and can be injected into pods as environment variables or mounted as files.

apiVersion: v1
kind: ConfigMap
metadata:
  name: wordpress-config
  namespace: wpkite
data:
  WP_ENV: "production"
  DB_HOST: "mysql-primary.wpkite.svc.cluster.local"
  DB_NAME: "wpkite_prod"
  DB_CHARSET: "utf8mb4"
  WP_HOME: "https://wpkite.com"
  WP_SITEURL: "https://wpkite.com"
  WP_MEMORY_LIMIT: "256M"
  WP_MAX_MEMORY_LIMIT: "512M"
  PHP_MEMORY_LIMIT: "512M"
  PHP_MAX_EXECUTION_TIME: "300"

Secrets for Sensitive Data

Kubernetes Secrets are base64-encoded (not encrypted by default, though you can enable encryption at rest with an EncryptionConfiguration). They’re stored in etcd and accessible only to pods that explicitly reference them.

apiVersion: v1
kind: Secret
metadata:
  name: wordpress-secrets
  namespace: wpkite
type: Opaque
data:
  DB_USER: d3BraXRlX3VzZXI=           # base64 of "wpkite_user"
  DB_PASSWORD: c3VwM3JzM2NyM3Q=       # base64 of "sup3rs3cr3t"
  AUTH_KEY: eW91ci1hdXRoLWtleS1oZXJl  # base64 encoded
  SECURE_AUTH_KEY: eW91ci1zZWN1cmUtYXV0aC1rZXk=
  LOGGED_IN_KEY: eW91ci1sb2dnZWQtaW4ta2V5
  NONCE_KEY: eW91ci1ub25jZS1rZXk=
  AUTH_SALT: eW91ci1hdXRoLXNhbHQ=
  SECURE_AUTH_SALT: eW91ci1zZWN1cmUtYXV0aC1zYWx0
  LOGGED_IN_SALT: eW91ci1sb2dnZWQtaW4tc2FsdA==
  NONCE_SALT: eW91ci1ub25jZS1zYWx0

Never commit Secret manifests with real values. Use kubectl create secret imperatively, or seal them with Bitnami’s Sealed Secrets or Mozilla SOPS before committing.

Injecting Into WordPress Pods

Here’s a WordPress Deployment that pulls from both ConfigMap and Secret:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: wordpress
  namespace: wpkite
spec:
  replicas: 3
  selector:
    matchLabels:
      app: wordpress
  template:
    metadata:
      labels:
        app: wordpress
    spec:
      containers:
        - name: wordpress
          image: wpkite/wordpress:6.4-custom
          ports:
            - containerPort: 80
          envFrom:
            - configMapRef:
                name: wordpress-config
            - secretRef:
                name: wordpress-secrets
          volumeMounts:
            - name: wp-content
              mountPath: /var/www/html/wp-content
            - name: php-config
              mountPath: /usr/local/etc/php/conf.d/custom.ini
              subPath: custom.ini
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /wp-login.php
              port: 80
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /wp-login.php
              port: 80
            initialDelaySeconds: 5
            periodSeconds: 5
      volumes:
        - name: wp-content
          persistentVolumeClaim:
            claimName: wp-content-pvc
        - name: php-config
          configMap:
            name: php-config

The envFrom directive injects all keys from the ConfigMap and Secret as environment variables. Inside the container, your wp-config.php reads them with getenv() exactly as it would in any other environment. No WordPress-specific changes needed.

An alternative approach mounts secrets as files instead of environment variables. This is more secure because environment variables can leak through /proc, crash dumps, and child processes, while file-based secrets can have strict filesystem permissions:

spec:
  containers:
    - name: wordpress
      volumeMounts:
        - name: db-credentials
          mountPath: /etc/wordpress/secrets
          readOnly: true
  volumes:
    - name: db-credentials
      secret:
        secretName: wordpress-secrets
        items:
          - key: DB_PASSWORD
            path: db-password
          - key: DB_USER
            path: db-user

Then in wp-config.php, use the file-reading helper we built earlier:

define( 'DB_PASSWORD', wpkite_env_docker( 'DB_PASSWORD' ) );
// This checks DB_PASSWORD_FILE first, then falls back to DB_PASSWORD env var

// Or read directly:
define( 'DB_PASSWORD', rtrim(
    file_get_contents( '/etc/wordpress/secrets/db-password' ),
    "\r\n"
) );

HashiCorp Vault Integration

HashiCorp Vault is the industry standard for centralized secrets management. It provides dynamic secrets (credentials generated on demand with automatic expiration), secret rotation, audit logging, and fine-grained access policies. Integrating it with WordPress requires a bit more work than simple environment variables, but the security gains are substantial.

The Vault Agent Sidecar Pattern

The cleanest integration uses Vault Agent as a sidecar container (or init container) that authenticates with Vault, fetches secrets, and renders them as files that WordPress can read. The WordPress container itself never talks to Vault directly and doesn’t need the Vault SDK.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: wordpress
  annotations:
    vault.hashicorp.com/agent-inject: "true"
    vault.hashicorp.com/role: "wordpress-prod"
    vault.hashicorp.com/agent-inject-secret-db-creds: "database/creds/wordpress-role"
    vault.hashicorp.com/agent-inject-template-db-creds: |
      {{- with secret "database/creds/wordpress-role" -}}
      DB_USER={{ .Data.username }}
      DB_PASSWORD={{ .Data.password }}
      {{- end -}}

This renders a file at /vault/secrets/db-creds containing the dynamic credentials. Vault generates a new MySQL user/password pair for this specific pod, and those credentials automatically expire when the Vault lease ends (typically 1 hour, configurable). If the pod is compromised, the attacker gets credentials that stop working shortly after.

To read the rendered file in wp-config.php:

$vault_secrets_path = '/vault/secrets/db-creds';

if ( file_exists( $vault_secrets_path ) ) {
    $lines = file( $vault_secrets_path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
    foreach ( $lines as $line ) {
        if ( strpos( $line, '=' ) !== false ) {
            list( $key, $value ) = explode( '=', $line, 2 );
            putenv( "$key=$value" );
        }
    }
}

define( 'DB_USER', wpkite_env( 'DB_USER' ) );
define( 'DB_PASSWORD', wpkite_env( 'DB_PASSWORD' ) );

Direct Vault API Integration

For environments where the sidecar pattern isn’t available, you can query Vault directly from PHP. This is more complex and introduces a runtime dependency on Vault availability:

class WPKite_Vault_Client {
    private string $vault_addr;
    private string $vault_token;
    private array $cache = [];

    public function __construct() {
        $this->vault_addr  = getenv( 'VAULT_ADDR' ) ?: 'https://vault.internal:8200';
        $this->vault_token = $this->get_token();
    }

    private function get_token(): string {
        // Prefer Kubernetes auth method
        $jwt_path = '/var/run/secrets/kubernetes.io/serviceaccount/token';
        if ( file_exists( $jwt_path ) ) {
            return $this->kubernetes_login( file_get_contents( $jwt_path ) );
        }

        // Fall back to token from environment
        $token = getenv( 'VAULT_TOKEN' );
        if ( $token !== false ) {
            return $token;
        }

        // Fall back to token file
        $token_file = getenv( 'VAULT_TOKEN_FILE' ) ?: '/etc/vault/token';
        if ( file_exists( $token_file ) ) {
            return rtrim( file_get_contents( $token_file ), "\r\n" );
        }

        throw new RuntimeException( 'No Vault authentication method available' );
    }

    private function kubernetes_login( string $jwt ): string {
        $role = getenv( 'VAULT_ROLE' ) ?: 'wordpress';
        $response = $this->request( 'POST', '/v1/auth/kubernetes/login', [
            'role' => $role,
            'jwt'  => $jwt,
        ]);
        return $response['auth']['client_token'];
    }

    public function get_secret( string $path, string $key = null ) {
        if ( ! isset( $this->cache[ $path ] ) ) {
            $response = $this->request( 'GET', '/v1/' . $path );
            $this->cache[ $path ] = $response['data']['data'] ?? $response['data'];
        }

        if ( $key !== null ) {
            return $this->cache[ $path ][ $key ] ?? null;
        }

        return $this->cache[ $path ];
    }

    private function request( string $method, string $endpoint, array $data = null ): array {
        $url = rtrim( $this->vault_addr, '/' ) . $endpoint;

        $options = [
            'http' => [
                'method'  => $method,
                'header'  => "X-Vault-Token: {$this->vault_token}\r\nContent-Type: application/json\r\n",
                'timeout' => 5,
            ],
        ];

        if ( $data !== null ) {
            $options['http']['content'] = json_encode( $data );
        }

        $context  = stream_context_create( $options );
        $response = file_get_contents( $url, false, $context );

        if ( $response === false ) {
            throw new RuntimeException( "Vault request failed: $method $endpoint" );
        }

        return json_decode( $response, true );
    }
}

// Usage in wp-config.php:
try {
    $vault = new WPKite_Vault_Client();
    $db_creds = $vault->get_secret( 'secret/data/wordpress/database' );

    define( 'DB_USER', $db_creds['username'] );
    define( 'DB_PASSWORD', $db_creds['password'] );
    define( 'DB_HOST', $db_creds['host'] );
    define( 'DB_NAME', $db_creds['name'] );
} catch ( RuntimeException $e ) {
    error_log( 'Vault error: ' . $e->getMessage() );
    // Fall back to environment variables
    define( 'DB_USER', wpkite_env( 'DB_USER' ) );
    define( 'DB_PASSWORD', wpkite_env( 'DB_PASSWORD' ) );
    define( 'DB_HOST', wpkite_env( 'DB_HOST', 'localhost' ) );
    define( 'DB_NAME', wpkite_env( 'DB_NAME' ) );
}

The try/catch with environment variable fallback is important for resilience. If Vault is temporarily unavailable during a pod restart, WordPress can still start using cached or environment-provided credentials.

AWS Secrets Manager Integration

For AWS-hosted WordPress deployments, AWS Secrets Manager provides a managed alternative to Vault. It integrates natively with IAM for access control and supports automatic rotation of RDS credentials.

// Requires: composer require aws/aws-sdk-php

use Aws\SecretsManager\SecretsManagerClient;

function wpkite_get_aws_secret( string $secret_name ): array {
    static $cache = [];

    if ( isset( $cache[ $secret_name ] ) ) {
        return $cache[ $secret_name ];
    }

    $client = new SecretsManagerClient([
        'region'  => wpkite_env( 'AWS_REGION', 'us-east-1' ),
        'version' => '2017-10-17',
        // Uses IAM role if on EC2/ECS/EKS, or AWS_ACCESS_KEY_ID env vars
    ]);

    $result = $client->getSecretValue([
        'SecretId' => $secret_name,
    ]);

    $secret = json_decode( $result['SecretString'], true );
    $cache[ $secret_name ] = $secret;

    return $secret;
}

// Usage:
$db_secret = wpkite_get_aws_secret( 'wpkite/prod/database' );
define( 'DB_NAME', $db_secret['dbname'] );
define( 'DB_USER', $db_secret['username'] );
define( 'DB_PASSWORD', $db_secret['password'] );
define( 'DB_HOST', $db_secret['host'] . ':' . $db_secret['port'] );

The static cache prevents multiple API calls during a single request. WordPress loads wp-config.php once per request, so this isn’t a major concern, but plugins that call DB_PASSWORD detection functions could trigger multiple reads without caching.

Security: Where Environment Variables Leak

Environment variables are better than hard-coded passwords in config files, but they’re not inherently secure. Understanding where they leak helps you build appropriate safeguards.

phpinfo() Exposure

The phpinfo() function displays every environment variable in a formatted HTML page. A single stray <?php phpinfo(); ?> file in your webroot exposes every secret. This happens more often than you’d think, because developers create info.php for debugging and forget to remove it.

Mitigations:

# .htaccess - Block phpinfo files
<FilesMatch "^(phpinfo|info|test|debug)\.php$">
    Require all denied
</FilesMatch>
# nginx - Block phpinfo files
location ~* /(phpinfo|info|test|debug)\.php$ {
    deny all;
    return 404;
}

Better yet, disable the function entirely in php.ini:

disable_functions = phpinfo, exec, passthru, shell_exec, system, proc_open, popen

Error Log Exposure

When WP_DEBUG_LOG is enabled, PHP errors and WordPress debug output go to wp-content/debug.log. If a database connection fails, the error message might include the connection string with credentials. If a plugin throws an exception while processing a secret, the stack trace might include the secret value in a function argument.

Block direct access to log files:

# .htaccess
<FilesMatch "\.(log|sql|bak)$">
    Require all denied
</FilesMatch>

In production, send logs to a centralized logging system (CloudWatch, Datadog, ELK) instead of writing to the filesystem. Set the log path to a location outside the webroot:

define( 'WP_DEBUG_LOG', '/var/log/wordpress/debug.log' );

Process List Exposure

On shared hosting or multi-tenant systems, other users can see environment variables passed to processes through /proc/[pid]/environ (on Linux) or ps eww (on some systems). Environment variables set via Docker’s -e flag are visible through docker inspect.

This is why file-based secrets (Docker Secrets, Kubernetes Secret volumes, Vault agent files) are more secure than environment variables for truly sensitive data. The secret never appears in the process table.

Version Control Accidents

The most common secret leak is simply committing a .env file or a wp-config.php with real passwords. Once pushed to a remote repository, even deleting the file in a subsequent commit doesn’t remove it from history. The secret is in the git log forever (unless you rewrite history).

Prevention:

# .gitignore
.env
.env.*
!.env.example
wp-config.php
secrets/
*.pem
*.key

Use a pre-commit hook to scan for secrets. Tools like detect-secrets, gitleaks, and trufflehog can be added to your CI pipeline:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.4.0
    hooks:
      - id: detect-secrets
        args: ['--baseline', '.secrets.baseline']

Child Process Inheritance

Every process spawned by PHP inherits its environment variables. If WordPress calls exec() or proc_open() to run a system command (some plugins do this for image processing, PDF generation, etc.), the child process has access to DB_PASSWORD and every other environment variable. If that child process crashes and generates a core dump, the dump contains your secrets.

File-based secrets sidestep this issue because they’re read into PHP variables, not passed through the process environment. A child process spawned by PHP won’t have access to a PHP variable’s value unless you explicitly pass it.

Rotating Database Credentials Without Downtime

Credential rotation is one of the hardest operational problems in any application, and WordPress makes it harder because it defines credentials as PHP constants via define(). Constants can’t be changed at runtime. Once WordPress boots, it uses whatever DB_USER and DB_PASSWORD were set at startup for the entire request lifecycle.

Here’s a zero-downtime rotation strategy for WordPress running on Kubernetes or any multi-replica setup.

The Dual-Credential Pattern

The idea is to always have two valid sets of credentials: the current one and the previous one. During rotation, you create the new credential, update the secret, and then revoke the old one after all pods have restarted.

Step 1: Create both users in MySQL:

-- Initial setup: two users, same permissions
CREATE USER 'wpkite_a'@'%' IDENTIFIED BY 'password_a_v1';
CREATE USER 'wpkite_b'@'%' IDENTIFIED BY 'password_b_v1';
GRANT ALL PRIVILEGES ON wpkite_prod.* TO 'wpkite_a'@'%';
GRANT ALL PRIVILEGES ON wpkite_prod.* TO 'wpkite_b'@'%';
FLUSH PRIVILEGES;

Step 2: WordPress reads the “active” credential set:

// wp-config.php
$active_set = wpkite_env( 'DB_ACTIVE_CREDENTIAL_SET', 'a' );

define( 'DB_USER', wpkite_env( 'DB_USER_' . strtoupper( $active_set ) ) );
define( 'DB_PASSWORD', wpkite_env( 'DB_PASSWORD_' . strtoupper( $active_set ) ) );

Step 3: To rotate, change the password for the inactive set, then switch:

#!/bin/bash
# rotate-db-credentials.sh

CURRENT_SET=$(kubectl get secret wordpress-secrets -o jsonpath='{.data.DB_ACTIVE_CREDENTIAL_SET}' | base64 -d)

if [ "$CURRENT_SET" = "a" ]; then
    NEW_SET="b"
else
    NEW_SET="a"
fi

NEW_PASSWORD=$(openssl rand -base64 32)

# Update MySQL password for the new set
mysql -h "$DB_HOST" -u admin -p"$ADMIN_PASSWORD" -e \
    "ALTER USER 'wpkite_${NEW_SET}'@'%' IDENTIFIED BY '${NEW_PASSWORD}'; FLUSH PRIVILEGES;"

# Update Kubernetes secret
KEY="DB_PASSWORD_$(echo $NEW_SET | tr '[:lower:]' '[:upper:]')"
kubectl patch secret wordpress-secrets -p \
    "{\"data\":{\"${KEY}\":\"$(echo -n $NEW_PASSWORD | base64)\",\"DB_ACTIVE_CREDENTIAL_SET\":\"$(echo -n $NEW_SET | base64)\"}}"

# Rolling restart to pick up new credentials
kubectl rollout restart deployment/wordpress
kubectl rollout status deployment/wordpress --timeout=300s

echo "Rotated to credential set $NEW_SET"

This script can run as a Kubernetes CronJob on a regular schedule. During the rolling restart, some pods use the old credentials while new pods use the new ones. Both sets of credentials are valid, so there’s no downtime.

Vault Dynamic Credentials

HashiCorp Vault’s database secrets engine solves this more elegantly. Instead of managing two static users, Vault creates ephemeral database users on demand:

# Enable the database secrets engine
vault secrets enable database

# Configure MySQL connection
vault write database/config/wpkite-mysql \
    plugin_name=mysql-database-plugin \
    connection_url="{{username}}:{{password}}@tcp(mysql-primary:3306)/" \
    allowed_roles="wordpress-role" \
    username="vault_admin" \
    password="vault_admin_password"

# Create a role that generates short-lived credentials
vault write database/roles/wordpress-role \
    db_name=wpkite-mysql \
    creation_statements="CREATE USER '{{name}}'@'%' IDENTIFIED BY '{{password}}'; \
        GRANT SELECT, INSERT, UPDATE, DELETE ON wpkite_prod.* TO '{{name}}'@'%';" \
    revocation_statements="DROP USER '{{name}}'@'%';" \
    default_ttl="1h" \
    max_ttl="24h"

With this configuration, every WordPress pod gets a unique MySQL user that automatically expires. Vault Agent renews the lease and updates the credentials file before expiration. If a pod is compromised, the credentials expire within an hour even if you don’t detect the breach.

The trade-off is complexity. You need Vault infrastructure, the agent sidecar, and monitoring to ensure lease renewals don’t fail. For most WordPress deployments, the dual-credential static rotation is sufficient. Reserve Vault dynamic credentials for high-security environments where audit and automatic expiration are requirements.

The Bedrock/Roots Approach

The Bedrock project by Roots is the most widely adopted opinionated WordPress boilerplate that treats environment configuration as a first-class concern. Understanding its approach is valuable even if you don’t adopt Bedrock wholesale, because it demonstrates patterns you can apply to any WordPress project.

Bedrock restructures the WordPress directory layout:

project/
├── config/
│   ├── application.php      # Main config, reads .env
│   └── environments/
│       ├── development.php
│       ├── staging.php
│       └── production.php
├── web/                      # Document root (not project root!)
│   ├── app/                  # wp-content equivalent
│   │   ├── mu-plugins/
│   │   ├── plugins/
│   │   └── themes/
│   ├── wp/                   # WordPress core (via Composer)
│   ├── index.php
│   └── wp-config.php         # Thin loader
├── vendor/
├── .env
├── .env.example
├── composer.json
└── composer.lock

Key decisions in this layout:

WordPress core is a Composer dependency. It lives in web/wp/ and is never edited. Updates happen through composer update, same as any PHP dependency. This is critical for reproducible deployments: your composer.lock file pins the exact WordPress version.

The document root is web/, not the project root. This means .env, composer.json, vendor/, and config/ are above the webroot and not directly accessible via HTTP. Even if your web server is misconfigured, a browser can’t request https://example.com/.env.

Configuration uses the phpdotenv library and environment-specific files. This is the same pattern we covered earlier, but Bedrock provides it out of the box with sensible defaults.

Bedrock’s config/application.php uses the env() helper function from the oscarotero/env package, which provides type-aware environment variable reading:

use function Env\env;

// env() automatically converts types:
// "true" -> true (boolean)
// "123" -> 123 (integer)
// "null" -> null

define( 'WP_DEBUG', env( 'WP_DEBUG' ) ?? false );
define( 'WP_HOME', env( 'WP_HOME' ) );
define( 'DB_NAME', env( 'DB_NAME' ) );

$table_prefix = env( 'DB_PREFIX' ) ?? 'wp_';

This env() function solves the boolean casting problem we discussed earlier. The string “true” in a .env file becomes the PHP boolean true, not the string "true". It’s a small thing that prevents a surprisingly common class of configuration bugs.

If you want Bedrock-style configuration without adopting the full Bedrock structure, you can add these Composer packages to any WordPress project:

composer require vlucas/phpdotenv oscarotero/env

Then restructure your wp-config.php to use them. You get 90% of the benefit without changing your directory layout or workflow.

CI/CD Pipeline Secrets

Your deployment pipeline needs access to secrets too: database credentials for running migrations, SSH keys for deploying to servers, API keys for cache purging. How you manage these secrets depends on your CI/CD platform.

GitHub Actions

GitHub Actions stores secrets at the repository or organization level. They’re encrypted at rest, masked in logs, and available as environment variables in workflow runs.

# .github/workflows/deploy.yml
name: Deploy WordPress
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Install Composer dependencies
        run: composer install --no-dev --optimize-autoloader

      - name: Build assets
        working-directory: wp-content/themes/wpkite
        run: |
          npm ci
          npm run build

      - name: Generate .env for deployment
        run: |
          cat > .env.production << EOF
          DB_NAME=${{ secrets.PROD_DB_NAME }}
          DB_USER=${{ secrets.PROD_DB_USER }}
          DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}
          DB_HOST=${{ secrets.PROD_DB_HOST }}
          WP_ENV=production
          WP_HOME=${{ secrets.PROD_WP_HOME }}
          WP_SITEURL=${{ secrets.PROD_WP_SITEURL }}
          AUTH_KEY=${{ secrets.AUTH_KEY }}
          SECURE_AUTH_KEY=${{ secrets.SECURE_AUTH_KEY }}
          LOGGED_IN_KEY=${{ secrets.LOGGED_IN_KEY }}
          NONCE_KEY=${{ secrets.NONCE_KEY }}
          AUTH_SALT=${{ secrets.AUTH_SALT }}
          SECURE_AUTH_SALT=${{ secrets.SECURE_AUTH_SALT }}
          LOGGED_IN_SALT=${{ secrets.LOGGED_IN_SALT }}
          NONCE_SALT=${{ secrets.NONCE_SALT }}
          EOF

      - name: Deploy via rsync
        uses: burnett01/[email protected]
        with:
          switches: -avzr --delete --exclude='.git' --exclude='node_modules'
          path: ./
          remote_path: /var/www/wpkite/
          remote_host: ${{ secrets.DEPLOY_HOST }}
          remote_user: ${{ secrets.DEPLOY_USER }}
          remote_key: ${{ secrets.DEPLOY_SSH_KEY }}

      - name: Post-deploy commands
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /var/www/wpkite
            mv .env.production .env
            wp cache flush
            wp rewrite flush

GitHub Actions automatically masks secret values in log output. If a step prints ${{ secrets.PROD_DB_PASSWORD }}, the logs show *** instead. But be aware this masking is based on exact string matching. If a secret is transformed (base64-encoded, URL-encoded, or split across multiple lines), the masking won't catch it.

Use GitHub Environments for environment-specific secrets. You can configure the "production" environment to require manual approval before deployment, adding a human gate before secrets are used:

jobs:
  deploy-staging:
    environment: staging
    # Uses staging secrets automatically

  deploy-production:
    environment: production
    needs: deploy-staging
    # Requires manual approval (configured in GitHub UI)
    # Uses production secrets automatically

GitLab CI

GitLab CI/CD variables work similarly but offer more granular controls. Variables can be scoped to specific environments, protected branches, or masked in logs.

# .gitlab-ci.yml
stages:
  - build
  - deploy

variables:
  COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"

build:
  stage: build
  image: composer:2
  cache:
    key: composer
    paths:
      - .composer-cache/
  script:
    - composer install --no-dev --optimize-autoloader
  artifacts:
    paths:
      - vendor/
    expire_in: 1 hour

deploy_production:
  stage: deploy
  image: alpine:latest
  environment:
    name: production
    url: https://wpkite.com
  only:
    - main
  before_script:
    - apk add --no-cache openssh-client rsync
    - eval $(ssh-agent -s)
    - echo "$DEPLOY_SSH_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
  script:
    - |
      cat > .env << EOF
      DB_NAME=${PROD_DB_NAME}
      DB_USER=${PROD_DB_USER}
      DB_PASSWORD=${PROD_DB_PASSWORD}
      DB_HOST=${PROD_DB_HOST}
      WP_ENV=production
      WP_HOME=${PROD_WP_HOME}
      WP_SITEURL=${PROD_WP_SITEURL}
      EOF
    - rsync -avzr --delete
        --exclude='.git'
        --exclude='node_modules'
        --exclude='.gitlab-ci.yml'
        ./ deployer@${DEPLOY_HOST}:/var/www/wpkite/
    - ssh deployer@${DEPLOY_HOST} "cd /var/www/wpkite && wp cache flush"

GitLab's "protected" and "masked" variable options add extra safety. A protected variable is only available to pipelines running on protected branches (usually main). A masked variable is hidden in job logs. Always enable both for credentials.

Avoiding Secret Sprawl in CI/CD

As your WordPress deployment grows, you'll accumulate dozens of CI/CD secrets: database credentials, API keys, SSH keys, Stripe keys, SMTP passwords, CDN tokens. Each platform (GitHub, GitLab, CircleCI) manages these independently, creating multiple sources of truth.

A better pattern is to store secrets in a single system (Vault, AWS Secrets Manager, or even a dedicated secrets management SaaS) and have your CI/CD pipeline fetch them at runtime:

# GitHub Actions with Vault
- name: Fetch secrets from Vault
  uses: hashicorp/vault-action@v2
  with:
    url: https://vault.wpkite.com
    method: jwt
    role: github-actions-wordpress
    secrets: |
      secret/data/wordpress/database username | DB_USER ;
      secret/data/wordpress/database password | DB_PASSWORD ;
      secret/data/wordpress/database host | DB_HOST ;
      secret/data/wordpress/database name | DB_NAME ;
      secret/data/wordpress/keys auth_key | AUTH_KEY ;
      secret/data/wordpress/keys secure_auth_key | SECURE_AUTH_KEY

This approach means you rotate a secret in Vault once, and every pipeline automatically picks up the new value on the next run. No need to update secrets in three different CI platforms.

Building a Universal Config Abstraction Layer

After working through all these patterns, the core problem becomes clear: WordPress needs configuration values, and those values come from different sources depending on the environment. A .env file in development, Kubernetes secrets in staging, Vault in production. The goal is a single configuration interface that abstracts the source.

Here's a production-ready configuration class that implements this abstraction:

<?php
/**
 * WPKite Config - Universal configuration abstraction for WordPress.
 *
 * Reads from multiple sources in priority order:
 * 1. Vault (if configured)
 * 2. File-based secrets (Docker/K8s)
 * 3. Environment variables
 * 4. .env file (via phpdotenv)
 * 5. Defaults
 */
class WPKite_Config {

    private static ?self $instance = null;
    private array $cache = [];
    private array $sources = [];
    private bool $initialized = false;

    private function __construct() {}

    public static function getInstance(): self {
        if ( self::$instance === null ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Initialize all configuration sources.
     * Call this once, early in wp-config.php.
     */
    public function init( string $root_path ): void {
        if ( $this->initialized ) {
            return;
        }

        // Source 1: .env file (lowest priority, loaded first)
        $this->init_dotenv( $root_path );

        // Source 2: File-based secrets
        $secrets_dir = getenv( 'SECRETS_DIR' ) ?: '/run/secrets';
        if ( is_dir( $secrets_dir ) ) {
            $this->sources[] = new WPKite_FileSecretSource( $secrets_dir );
        }

        // Source 3: Vault (highest priority)
        $vault_addr = getenv( 'VAULT_ADDR' );
        if ( $vault_addr ) {
            try {
                $this->sources[] = new WPKite_VaultSource( $vault_addr );
            } catch ( \Exception $e ) {
                error_log( 'WPKite Config: Vault unavailable, falling back. ' . $e->getMessage() );
            }
        }

        $this->initialized = true;
    }

    private function init_dotenv( string $root_path ): void {
        if ( ! class_exists( 'Dotenv\\Dotenv' ) ) {
            return;
        }

        $dotenv = \Dotenv\Dotenv::createImmutable( $root_path );
        if ( file_exists( $root_path . '/.env' ) ) {
            $dotenv->safeLoad();
        }
    }

    /**
     * Get a configuration value.
     */
    public function get( string $key, $default = null ) {
        if ( isset( $this->cache[ $key ] ) ) {
            return $this->cache[ $key ];
        }

        // Check sources in reverse order (highest priority last in array)
        foreach ( array_reverse( $this->sources ) as $source ) {
            $value = $source->get( $key );
            if ( $value !== null ) {
                $this->cache[ $key ] = $value;
                return $value;
            }
        }

        // Fall back to environment variable
        $value = getenv( $key );
        if ( $value !== false ) {
            $this->cache[ $key ] = $value;
            return $value;
        }

        // Check _FILE variant
        $file_path = getenv( $key . '_FILE' );
        if ( $file_path !== false && is_readable( $file_path ) ) {
            $value = rtrim( file_get_contents( $file_path ), "\r\n" );
            $this->cache[ $key ] = $value;
            return $value;
        }

        return $default;
    }

    /**
     * Get a boolean configuration value.
     */
    public function getBool( string $key, bool $default = false ): bool {
        $value = $this->get( $key );
        if ( $value === null ) {
            return $default;
        }
        return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
    }

    /**
     * Get an integer configuration value.
     */
    public function getInt( string $key, int $default = 0 ): int {
        $value = $this->get( $key );
        if ( $value === null ) {
            return $default;
        }
        return (int) $value;
    }

    /**
     * Require a set of configuration keys.
     * Throws an exception if any are missing.
     */
    public function require( array $keys ): void {
        $missing = [];
        foreach ( $keys as $key ) {
            if ( $this->get( $key ) === null ) {
                $missing[] = $key;
            }
        }
        if ( ! empty( $missing ) ) {
            throw new \RuntimeException(
                'Missing required configuration: ' . implode( ', ', $missing )
            );
        }
    }
}

/**
 * File-based secret source (Docker Secrets, Kubernetes Secret volumes).
 */
class WPKite_FileSecretSource {
    private string $directory;
    private array $cache = [];

    public function __construct( string $directory ) {
        $this->directory = rtrim( $directory, '/' );
    }

    public function get( string $key ): ?string {
        if ( isset( $this->cache[ $key ] ) ) {
            return $this->cache[ $key ];
        }

        // Try exact key name as filename
        $file = $this->directory . '/' . $key;
        if ( is_readable( $file ) ) {
            $this->cache[ $key ] = rtrim( file_get_contents( $file ), "\r\n" );
            return $this->cache[ $key ];
        }

        // Try lowercase with hyphens (k8s convention: DB_PASSWORD -> db-password)
        $k8s_name = str_replace( '_', '-', strtolower( $key ) );
        $file = $this->directory . '/' . $k8s_name;
        if ( is_readable( $file ) ) {
            $this->cache[ $key ] = rtrim( file_get_contents( $file ), "\r\n" );
            return $this->cache[ $key ];
        }

        return null;
    }
}

/**
 * HashiCorp Vault source.
 */
class WPKite_VaultSource {
    private string $addr;
    private string $token;
    private array $cache = [];
    private string $secret_path;

    public function __construct( string $addr ) {
        $this->addr = rtrim( $addr, '/' );
        $this->secret_path = getenv( 'VAULT_SECRET_PATH' ) ?: 'secret/data/wordpress';
        $this->token = $this->authenticate();
    }

    private function authenticate(): string {
        // Try Kubernetes auth
        $jwt_path = '/var/run/secrets/kubernetes.io/serviceaccount/token';
        if ( file_exists( $jwt_path ) ) {
            $jwt = file_get_contents( $jwt_path );
            $role = getenv( 'VAULT_ROLE' ) ?: 'wordpress';

            $response = $this->http_request( 'POST', '/v1/auth/kubernetes/login', [
                'role' => $role,
                'jwt'  => $jwt,
            ]);
            return $response['auth']['client_token'];
        }

        // Try AppRole auth
        $role_id = getenv( 'VAULT_ROLE_ID' );
        $secret_id = getenv( 'VAULT_SECRET_ID' );
        if ( $role_id && $secret_id ) {
            $response = $this->http_request( 'POST', '/v1/auth/approle/login', [
                'role_id'   => $role_id,
                'secret_id' => $secret_id,
            ]);
            return $response['auth']['client_token'];
        }

        // Fall back to token
        $token = getenv( 'VAULT_TOKEN' );
        if ( $token ) {
            return $token;
        }

        throw new \RuntimeException( 'No Vault auth method available' );
    }

    public function get( string $key ): ?string {
        if ( empty( $this->cache ) ) {
            $this->load_all_secrets();
        }

        return $this->cache[ $key ] ?? null;
    }

    private function load_all_secrets(): void {
        try {
            $response = $this->http_request( 'GET', '/v1/' . $this->secret_path );
            $data = $response['data']['data'] ?? $response['data'] ?? [];
            $this->cache = array_map( 'strval', $data );
        } catch ( \Exception $e ) {
            error_log( 'WPKite Vault: Failed to load secrets: ' . $e->getMessage() );
            $this->cache = [ '__loaded' => 'true' ]; // Prevent repeated failures
        }
    }

    private function http_request( string $method, string $endpoint, array $data = null ): array {
        $url = $this->addr . $endpoint;
        $headers = "Content-Type: application/json\r\n";

        if ( isset( $this->token ) ) {
            $headers .= "X-Vault-Token: {$this->token}\r\n";
        }

        $options = [
            'http' => [
                'method'          => $method,
                'header'          => $headers,
                'timeout'         => 5,
                'ignore_errors'   => true,
            ],
        ];

        if ( $data !== null ) {
            $options['http']['content'] = json_encode( $data );
        }

        $context  = stream_context_create( $options );
        $response = @file_get_contents( $url, false, $context );

        if ( $response === false ) {
            throw new \RuntimeException( "Vault request failed: $method $endpoint" );
        }

        $decoded = json_decode( $response, true );
        if ( json_last_error() !== JSON_ERROR_NONE ) {
            throw new \RuntimeException( 'Invalid JSON response from Vault' );
        }

        return $decoded;
    }
}

Usage in wp-config.php becomes clean and source-agnostic:

<?php
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/config/class-wpkite-config.php';

$config = WPKite_Config::getInstance();
$config->init( __DIR__ );

// Validate required config exists (fails fast with clear message)
$config->require([
    'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST',
    'WP_HOME', 'WP_SITEURL',
    'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
    'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
]);

// Database
define( 'DB_NAME', $config->get( 'DB_NAME' ) );
define( 'DB_USER', $config->get( 'DB_USER' ) );
define( 'DB_PASSWORD', $config->get( 'DB_PASSWORD' ) );
define( 'DB_HOST', $config->get( 'DB_HOST', 'localhost' ) );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );

$table_prefix = $config->get( 'DB_PREFIX', 'wp_' );

// URLs
define( 'WP_HOME', $config->get( 'WP_HOME' ) );
define( 'WP_SITEURL', $config->get( 'WP_SITEURL' ) );

// Authentication keys
define( 'AUTH_KEY', $config->get( 'AUTH_KEY' ) );
define( 'SECURE_AUTH_KEY', $config->get( 'SECURE_AUTH_KEY' ) );
define( 'LOGGED_IN_KEY', $config->get( 'LOGGED_IN_KEY' ) );
define( 'NONCE_KEY', $config->get( 'NONCE_KEY' ) );
define( 'AUTH_SALT', $config->get( 'AUTH_SALT' ) );
define( 'SECURE_AUTH_SALT', $config->get( 'SECURE_AUTH_SALT' ) );
define( 'LOGGED_IN_SALT', $config->get( 'LOGGED_IN_SALT' ) );
define( 'NONCE_SALT', $config->get( 'NONCE_SALT' ) );

// Debug (with proper boolean casting)
define( 'WP_DEBUG', $config->getBool( 'WP_DEBUG', false ) );
define( 'WP_DEBUG_LOG', $config->getBool( 'WP_DEBUG_LOG', false ) );
define( 'WP_DEBUG_DISPLAY', $config->getBool( 'WP_DEBUG_DISPLAY', false ) );

// Security
define( 'DISALLOW_FILE_EDIT', $config->getBool( 'DISALLOW_FILE_EDIT', true ) );
define( 'FORCE_SSL_ADMIN', $config->getBool( 'FORCE_SSL_ADMIN', true ) );

// Performance
define( 'WP_CACHE', $config->getBool( 'WP_CACHE', false ) );
define( 'WP_MEMORY_LIMIT', $config->get( 'WP_MEMORY_LIMIT', '256M' ) );

if ( ! defined( 'ABSPATH' ) ) {
    define( 'ABSPATH', __DIR__ . '/' );
}

require_once ABSPATH . 'wp-settings.php';

This wp-config.php works identically whether you're running locally with a .env file, in Docker with mounted secrets, on Kubernetes with ConfigMaps and Secrets, or in production with Vault. The WPKite_Config class handles source resolution automatically. Add a new source (GCP Secret Manager, Azure Key Vault, 1Password Connect) by implementing a class with a get() method and registering it.

Testing the Abstraction Layer

One often overlooked benefit of a config abstraction is testability. Instead of relying on actual environment variables in your test suite, you can mock the config source:

class WPKite_ArraySource {
    private array $data;

    public function __construct( array $data ) {
        $this->data = $data;
    }

    public function get( string $key ): ?string {
        return $this->data[ $key ] ?? null;
    }
}

// In your test:
$config = WPKite_Config::getInstance();
$config->addSource( new WPKite_ArraySource([
    'DB_NAME'     => 'test_db',
    'DB_USER'     => 'test_user',
    'DB_PASSWORD' => 'test_pass',
    'DB_HOST'     => '127.0.0.1',
]) );

This makes integration tests deterministic. You're not dependent on the CI runner having the right environment variables, and you're not parsing .env files in a test context where they might not exist.

Platform-Specific Patterns Worth Knowing

Different hosting platforms and cloud providers have their own conventions for passing environment variables to WordPress. Knowing these saves you from fighting the platform.

WP Engine

WP Engine exposes environment-specific variables through their proprietary system. You can't set arbitrary environment variables, but they provide $_SERVER['IS_WPE'] for environment detection and a stage-specific variable. Your wp-config.php checks:

if ( defined( 'WPE_APIKEY' ) ) {
    // Running on WP Engine
    $wpe_env = getenv( 'WPENGINE_ACCOUNT' );
    // Use WP Engine's provided DB credentials (auto-configured)
}

Pantheon

Pantheon exposes database and environment info through $_ENV['PANTHEON_ENVIRONMENT'] and the $_ENV['DB_*'] variables. They also provide a pantheon.yml for some configuration. Their recommended wp-config.php pattern:

if ( isset( $_ENV['PANTHEON_ENVIRONMENT'] ) ) {
    define( 'DB_NAME', $_ENV['DB_NAME'] );
    define( 'DB_USER', $_ENV['DB_USER'] );
    define( 'DB_PASSWORD', $_ENV['DB_PASSWORD'] );
    define( 'DB_HOST', $_ENV['DB_HOST'] . ':' . $_ENV['DB_PORT'] );

    if ( $_ENV['PANTHEON_ENVIRONMENT'] === 'live' ) {
        define( 'WP_DEBUG', false );
    } else {
        define( 'WP_DEBUG', true );
    }
}

Laravel Forge / Ploi / ServerPilot

These server management tools let you define environment variables through their UI, which get written to the web server configuration (Nginx fastcgi_param) or a .env file on the server. The variables are accessible through $_SERVER or getenv() depending on the method used. Since you control the server, the standard phpdotenv approach works perfectly here.

AWS Elastic Beanstalk

Elastic Beanstalk lets you set environment properties through the console, CLI, or .ebextensions config files. They become real environment variables accessible via getenv():

# .ebextensions/env.config
option_settings:
  aws:elasticbeanstalk:application:environment:
    WP_ENV: production
    DB_HOST: wpkite-rds.cluster-abc123.us-east-1.rds.amazonaws.com
    DB_NAME: wpkite_prod
    # Don't put passwords here - use Secrets Manager instead

For RDS credentials on Elastic Beanstalk, the recommended pattern is to use the AWS Secrets Manager integration we covered earlier, with the IAM instance role granting access to the secret.

Practical Recommendations by Environment Size

Not every WordPress site needs Vault. Not every team should roll their own config abstraction. Here are concrete recommendations based on the scale and complexity of your deployment.

Single Site, Single Server

Use phpdotenv with a .env file. Keep .env out of version control. Use the multi-environment wp-config.php pattern with development, staging, and production config files. This is simple, well-understood, and sufficient for the vast majority of WordPress sites.

Estimated setup time: 30 minutes.

Multiple Sites, Docker-Based

Use docker-compose env_file for non-sensitive config and Docker Secrets (or file-based secrets) for credentials. Implement the wpkite_env_docker() helper for the _FILE suffix pattern. Keep a .env.example for documentation.

Estimated setup time: 1-2 hours.

Kubernetes Deployment

Use ConfigMaps for non-sensitive values and Kubernetes Secrets for credentials. Mount secrets as files rather than environment variables for better security. Use Sealed Secrets or SOPS for gitops workflows. Consider external-secrets-operator to sync from a central secret store.

Estimated setup time: 4-8 hours (including RBAC and policies).

Enterprise / High-Security

Deploy HashiCorp Vault with the database secrets engine for dynamic credentials. Use Vault Agent sidecars in Kubernetes. Implement the dual-credential rotation pattern as a fallback. Build or adopt the full config abstraction layer. Add secret scanning to CI/CD pipelines.

Estimated setup time: 2-4 days (including Vault infrastructure).

Common Mistakes and How to Avoid Them

After years of managing WordPress infrastructure, certain configuration mistakes appear repeatedly. Here are the most frequent ones and their fixes.

Mistake 1: Using createMutable with phpdotenv in production. This allows the .env file to overwrite real environment variables set by Docker or Kubernetes. A stale .env file accidentally left on a production server overwrites the correct database credentials with old ones. Always use createImmutable.

Mistake 2: Defaulting to development mode. Code like $env = getenv('WP_ENV') ?: 'development' means a missing WP_ENV variable enables debug mode in production. Default to the most restrictive environment: $env = getenv('WP_ENV') ?: 'production'.

Mistake 3: Storing salts in environment variables without understanding rotation implications. WordPress authentication keys and salts are used to sign cookies. If you change them, every logged-in user gets logged out immediately. This is fine for security rotation, but don't change them as part of routine credential rotation unless you intend to invalidate all sessions.

Mistake 4: Not handling the string "false" correctly. Setting WP_DEBUG=false in a .env file and reading it with getenv('WP_DEBUG') gives you the string "false", which is truthy. Use filter_var() or a typed getter as shown earlier.

Mistake 5: Committing docker-compose.yml with inline passwords. Even for "development" passwords, this sets a bad precedent and trains developers to be careless about secrets in version control. Use env_file or Docker Secrets from the start.

Mistake 6: Not validating configuration at boot time. WordPress's default behavior on a missing DB_PASSWORD is a white screen or a generic database error. Use phpdotenv's required() or the config abstraction's require() method to fail fast with a specific error message.

Mistake 7: Logging secrets in debug output. Plugins that call var_dump($wpdb) or print_r(get_defined_constants()) during debugging will output database credentials to the debug log. In production, disable WP_DEBUG_DISPLAY absolutely. In development, be aware that debug.log contains credentials if you've logged them.

Mistake 8: Ignoring environment variable length limits. Some systems have limits on individual environment variable size. Windows has a 32,767 character limit per variable. Some older shells have a total environment size limit. If you're storing large values (TLS certificates, JSON blobs) as environment variables, use file-based secrets instead.

Putting It All Together

Environment variable and secrets management for WordPress is not a single problem with a single solution. It's a spectrum of practices that scale with your deployment complexity. The progression looks like this:

Start with a .env file and phpdotenv. This immediately gets credentials out of version control and establishes the pattern of reading configuration from the environment. It costs 30 minutes of setup and zero infrastructure.

Add multi-environment config splitting when you have staging and production environments. The wp-config.php dispatcher with environment-specific files ensures debug settings, caching, and other behavior changes automatically based on WP_ENV.

Move to file-based secrets when you containerize. Docker Secrets and Kubernetes Secret volumes keep credentials out of the process environment, reducing the attack surface. The _FILE suffix convention works with the official WordPress Docker image and is easy to implement in custom setups.

Adopt a centralized secret store (Vault, AWS Secrets Manager) when you need audit logging, dynamic credentials, or automatic rotation. This is the most complex option but provides the strongest security guarantees.

Throughout all of these stages, the config abstraction layer lets your wp-config.php remain stable. The same $config->get('DB_PASSWORD') call works whether the value comes from a .env file, a Kubernetes Secret, or Vault. You change the infrastructure without changing the application code.

WordPress may not have been designed for twelve-factor app methodology, but with the right configuration patterns, it can participate fully in modern deployment pipelines. The key insight is that configuration is infrastructure, and like all infrastructure, it should be automated, auditable, and environment-aware.

The code examples in this article are available as standalone files you can drop into any WordPress project. Start with the wpkite_env() helper function, add phpdotenv, split your wp-config.php, and build up from there. Each step makes your deployment more reliable and your secrets more secure, without requiring a complete architecture overhaul.

Share this article

Marcus Chen

Staff engineer with 12 years in WordPress infrastructure. Previously at Automattic and a large media company. Writes about hosting platforms, caching, and deployment pipelines.