Deployment & Migrations
What belongs in version control, how environment types work, and why moving a database needs search-replace rather than SQL.
wp search-replace 'https://old.example' 'https://new.example' --all-tables-with-prefix --dry-runA WordPress install is three things with different lifecycles — code, configuration and content — and deployments go wrong when they are treated as one.
What to version control
Track your own code: the themes and plugins under wp-content/. Do not track wp-content/uploads/, which is content and belongs in object storage or an rsync step. Do not commit a wp-config.php that holds credentials; commit a version that reads them from the environment. Core is a dependency — either exclude it and install it with wp core download or Composer, or vendor it deliberately and never edit it. Nothing you deploy should be editable from the admin; DISALLOW_FILE_MODS makes that explicit.
Environment types
wp_get_environment_type(), added in WordPress 5.5, returns local, development, staging or production. It reads the WP_ENVIRONMENT_TYPE environment variable or constant, and anything missing or invalid becomes production — failing towards the cautious setting. Gate debug output, sandbox payment keys and outbound mail on it rather than on hostname checks.
Search-replace on serialized data
Site URLs are stored throughout the database, including inside serialized arrays in wp_options and wp_postmeta. A serialized string records its own byte length — s:23:"https://staging.example" — so a SQL REPLACE() or a text-editor pass over a dump leaves a length that no longer matches, and PHP returns false when unserializing it. wp search-replace unserializes each value, substitutes, and reserializes: the automated form of that longhand. Run it with --dry-run first.
Example
<?php
// wp-content/mu-plugins/00-environment.php
// Must-use plugins load before ordinary plugins and cannot be deactivated from
// the admin, which is what you want for environment guards.
add_action( 'muplugins_loaded', function (): void {
// wp_get_environment_type(): string (WordPress 5.5)
// Returns local | development | staging | production. Reads the
// WP_ENVIRONMENT_TYPE env var or constant; anything invalid or unset
// becomes 'production', so the default is the careful one.
switch ( wp_get_environment_type() ) {
case 'production':
// Nothing to suppress here.
break;
case 'staging':
// Returning a non-null value short-circuits wp_mail() and returns
// that value instead. Filter added in WordPress 5.7.
add_filter( 'pre_wp_mail', '__return_false' );
// Keep the copy out of search results without editing the option,
// so the next database sync cannot undo it.
add_filter( 'pre_option_blog_public', fn() => '0' );
break;
default: // local, development
add_filter( 'pre_wp_mail', '__return_false' );
break;
}
} );When to use it
- You need a copy of production on staging with working URLs and no customer email.
- A new developer must get the site running locally without receiving the production credentials.
- A deploy has to update code without letting anyone install plugins from the admin.
- Widget and theme settings vanished after a colleague moved a site with a SQL find-and-replace.
More examples
Pulling production down to staging
The order is the point: import first, search-replace second. wp search-replace handles PHP serialized data and leaves primary keys alone, which is exactly what a hand-rolled sed pass cannot do. --dry-run before every run is cheap insurance on a database you cannot re-export.
# On production: export, then transfer.
wp db export - | gzip > prod.sql.gz
# On staging: replace the database, then fix the URLs IN the database —
# never in the .sql file, where serialized lengths cannot be corrected.
wp db reset --yes
gunzip < prod.sql.gz | wp db import -
# Always dry-run first: it reports exactly what would change, per table.
wp search-replace 'https://example.com' 'https://staging.example.com' \
--all-tables-with-prefix --dry-run
# Then for real. --all-tables-with-prefix covers plugin tables that are not
# registered on $wpdb; --precise forces PHP instead of SQL (slower, thorough).
wp search-replace 'https://example.com' 'https://staging.example.com' \
--all-tables-with-prefix --report-changed-only
# Uploads are content, not database rows — sync them separately.
rsync -az prod:wp-content/uploads/ wp-content/uploads/What the repository holds
The decision each line encodes is whether the file is something you author, something a machine can reproduce, or something users created. Only the first belongs in git. The plugins directory is the genuinely contested one: pin versions in a manifest if your host allows a build step, and commit the directory if it does not.
# .gitignore at the WordPress root
# Core is a dependency, installed by `wp core download` or Composer.
/wp-admin/
/wp-includes/
/index.php
/wp-*.php
!/wp-config-sample.php
# Secrets. Commit a template that reads getenv(), not the real file.
/wp-config.php
# Content, not code. Sync it, back it up, do not version it.
/wp-content/uploads/
/wp-content/upgrade/
/wp-content/languages/
# Drop-ins written by a caching plugin at runtime.
/wp-content/advanced-cache.php
/wp-content/object-cache.php
# Third-party plugins, IF you install them by version with wp-cli or Composer.
# Commit them instead if you patch them or need the exact bytes reproducible.
/wp-content/plugins/*
!/wp-content/plugins/acme-*/Config that reads its secrets from the environment
WP_HOME and WP_SITEURL as constants are the belt to search-replace's braces: even if a sync brings the wrong URLs in the options table, the site serves the right ones. Note the salts loop — mismatched salts across load-balanced web servers produce the classic "logged out on every second request" bug.
<?php
// wp-config.php, committed. No credentials in the repository.
define( 'WP_ENVIRONMENT_TYPE', getenv( 'WP_ENVIRONMENT_TYPE' ) ?: 'production' );
define( 'DB_NAME', getenv( 'DB_NAME' ) );
define( 'DB_USER', getenv( 'DB_USER' ) );
define( 'DB_PASSWORD', getenv( 'DB_PASSWORD' ) );
define( 'DB_HOST', getenv( 'DB_HOST' ) ?: 'localhost' );
// Deployed code is immutable: no plugin or theme installs, no file editor.
define( 'DISALLOW_FILE_MODS', true );
// Let the environment set the URLs so a database sync cannot drag the old
// domain along. These constants override the siteurl and home options.
if ( getenv( 'WP_HOME' ) ) {
define( 'WP_HOME', getenv( 'WP_HOME' ) );
define( 'WP_SITEURL', getenv( 'WP_SITEURL' ) ?: getenv( 'WP_HOME' ) );
}
// Salts come from the environment too, and must match across web nodes or
// every logged-in session breaks on each request.
foreach ( array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' ) as $acme_salt ) {
define( $acme_salt, getenv( $acme_salt ) );
}
Discussion