Deployment & Migrations

What belongs in version control, how environment types work, and why moving a database needs search-replace rather than SQL.

Syntaxwp search-replace 'https://old.example' 'https://new.example' --all-tables-with-prefix --dry-run

A 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

Example · php
<?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.

Example · bash
# 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.

Example · text
# .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.

Example · php
<?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

  • Be the first to comment on this lesson.