WooCommerce Deployment: Safe Database Migrations and Staging

PHP, Tutorials, and Wordpress

WooCommerce Deployment: Safe Database Migrations and Staging

WooCommerce is the most widely deployed e-commerce platform on the web — W3Techs puts it at 47.6% of all e-commerce systems it surveys as of October 2026. Deploying it is not the same job as deploying WordPress.

A WordPress brochure site that breaks on deploy is embarrassing. A WooCommerce store that breaks on deploy loses orders, or — worse — quietly corrupts the rows that are the orders. This guide covers the three places WooCommerce deployments actually go wrong: serialized data in database migrations, payment gateway credentials leaking across environments, and zero downtime that isn't.

If you just want the mechanical setup — which directory to deploy to, what build command to run — that lives in our WooCommerce deployment guide. This article is about the parts that destroy data when you get them wrong.

Why WooCommerce Needs Special Attention

Standard WordPress deployment workflows break down on stores for reasons that have nothing to do with code:

  • Order data lives in custom tables. WooCommerce's own documentation states that from version 8.2 (October 2023), High-Performance Order Storage (HPOS) is enabled by default for new installations — orders live in wp_wc_orders, wp_wc_orders_meta, wp_wc_order_addresses, and wp_wc_order_operational_data rather than wp_posts/wp_postmeta. Tooling that assumes the old layout silently misses them.
  • Payment gateway credentials are stored in the database, not in config. Copy a production database to staging and you have copied live API keys along with it.
  • Serialized PHP is everywhere — plugin settings, widget data, gateway configuration. A naive find-and-replace corrupts it.
  • Plugin licensing is often bound to a specific domain, so a staging clone can deactivate the production license.
  • Product image URLs break when environments change hostname.

If you're new to WordPress deployment generally, start with installing WordPress on a VPS and deploying WordPress from local to production with Git, then come back here for the store-specific parts.

Environment Architecture for WooCommerce

flowchart LR
    subgraph "Development"
        DEV[Local Dev]
        DEV_DB[(Dev Database)]
    end

    subgraph "Staging"
        STG[Staging Site]
        STG_DB[(Staging DB)]
    end

    subgraph "Production"
        PROD[Live Store]
        PROD_DB[(Production DB)]
    end

    DEV --> |Code Only| STG
    STG --> |Code Only| PROD
    PROD_DB -.-> |Sanitized Copy| STG_DB

Key principle: code flows up, data flows down — and data is sanitized on the way down, before the site is reachable.

Sanitized is doing a lot of work in that sentence, and most WooCommerce guides never define it. For a store, sanitizing a production database dump before it becomes a usable staging site means, at minimum:

  1. Replacing production URLs (serialization-aware — see below).
  2. Overwriting every payment gateway's stored API keys with test credentials, or blanking them.
  3. Putting the gateway into test mode.
  4. Disabling outbound email so customers don't receive duplicate order notifications from staging.
  5. Deactivating domain-locked license keys so the clone doesn't invalidate production's activation.

Steps 2 and 3 are the ones that cost real money if skipped. Do all five in a single scripted import, not by hand.

Directory Structure for Deployment

woocommerce-store/
├── wp-content/
│   ├── themes/
│   │   └── your-theme/
│   ├── plugins/
│   │   ├── your-custom-plugin/
│   │   └── .gitkeep
│   ├── mu-plugins/
│   │   └── environment-config.php
│   └── uploads/           # Excluded from Git — see "Product Images"
├── wp-config.php          # Loader only — contains NO literal secrets
├── scripts/
│   ├── deploy.sh
│   └── sanitize-staging-db.sh
└── .gitignore

One rule that is not optional: wp-config.php in Git must contain zero literal secrets. No database password, no salts, no gateway keys. It should require a machine-specific file that DeployHQ writes at deploy time (covered under Payment Gateway Configuration below). If your wp-config.php currently has real credentials in it and it's in Git, those credentials are compromised — rotate them, don't just delete the line.

Essential .gitignore for WooCommerce

# WordPress core (managed separately via WP-CLI or Composer)
/wp-admin/
/wp-includes/
/wp-*.php
!/wp-config.php
/index.php
/license.txt
/readme.html

# Uploads (too large — use a shared directory or object storage)
/wp-content/uploads/

# Machine-specific configuration written at deploy time
wp-config-local.php
.env

# Cache and temporary files
/wp-content/cache/
/wp-content/upgrade/
/wp-content/wflogs/

# Premium plugins (domain-locked licences)
/wp-content/plugins/woocommerce-subscriptions/
/wp-content/plugins/woocommerce-memberships/

# Database dumps — these contain customer PII and gateway keys
*.sql
*.sql.gz

# IDE and OS
.idea/
.vscode/
.DS_Store

Note the *.sql entry. On a WooCommerce store a database dump is not just a big file — it's customer names, addresses, email addresses, and stored gateway metadata. Treat an accidentally committed .sql from a store as a data breach, not a housekeeping mistake.


Database Migration: The Part That Corrupts Stores

This is the section to read twice.

Never run a raw SQL find-and-replace

WordPress and WooCommerce store PHP-serialized arrays throughout the database. A serialized string carries its own byte length:

s:21:"https://example.com/x";

The 21 is the length of the string that follows. Run a plain SQL replace and the text changes but the prefix doesn't:

-- DO NOT DO THIS. This corrupts every serialized value it touches.
UPDATE wp_options SET option_value = REPLACE(option_value, 'example.com', 'staging.example.com');

You now have s:21:"https://staging.example.com/x"; — a length prefix claiming 21 bytes for a 29-byte string. PHP's unserialize() returns false on that row. Depending on where it happened you lose a plugin's entire settings array, a gateway's configuration, or a shipping zone. The same applies to running sed over a mysqldump.

The failure is silent at replace time and only surfaces later, which is what makes it dangerous.

Use WP-CLI's search-replace, with the right flags

WP-CLI's search-replace unserializes, replaces, and re-serializes — recalculating length prefixes correctly. Per the upstream documentation it intelligently handles PHP serialized data, and does not change primary key values.

The flags matter more than most articles admit:

# 1. ALWAYS dry-run first and read the report.
wp search-replace 'https://store.example.com' 'https://staging.store.example.com' \
    --all-tables-with-prefix \
    --skip-columns=guid \
    --report-changed-only \
    --dry-run \
    --path=/var/www/html

# 2. Only when the dry-run report looks right, drop --dry-run.
wp search-replace 'https://store.example.com' 'https://staging.store.example.com' \
    --all-tables-with-prefix \
    --skip-columns=guid \
    --report-changed-only \
    --path=/var/www/html

Flag by flag:

  • --all-tables-with-prefix is required on a WooCommerce store. By default search-replace only walks tables registered on the $wpdb object. WooCommerce registers just five of its tables there — woocommerce_payment_tokenmeta, woocommerce_order_itemmeta, wc_product_meta_lookup, wc_tax_rate_classes and wc_reserved_stock. The HPOS order tables, woocommerce_sessions, woocommerce_order_items and every third-party plugin table are not in that set. A bare wp search-replace leaves stale URLs in your order records.
  • --skip-columns=guid — the guid column is a permanent identifier, not a URL to be followed. Rewriting it can make feed readers treat every post as new. Always skip it.
  • --dry-run runs the whole operation and reports what it would change without writing. On a live store this is not optional.
  • --precise forces PHP-based replacement instead of letting WP-CLI use SQL where it can. Upstream describes it as more thorough, but slower. It is worth adding for large or unusual-collation databases. It is not what makes the command serialization-safe — the default already is. Don't reach for --precise believing it's a correctness fix for serialized data.
  • --all-tables (without -with-prefix) hits every table in the database regardless of prefix and overrides the other table flags. On a shared database hosting more than one application, that is a foot-gun. Prefer --all-tables-with-prefix.

For the wider WP-CLI command set — db export/import, multisite, plugin and cache operations — see the WP-CLI cheatsheet linked above.

Back up before every migration, and verify the backup

A backup you didn't verify is a hope, not a backup. The failure mode that catches people is a wp db export that exits 0 but writes a near-empty dump because the credentials pointed at the wrong database.

#!/bin/bash
set -euo pipefail

SITE_PATH="/var/www/html"
BACKUP="/var/backups/wc-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"
TABLE_PREFIX="$(wp config get table_prefix --path="$SITE_PATH")"

wp db export - --add-drop-table --path="$SITE_PATH" | gzip -9 > "$BACKUP"

# Verify the dump is real: it must contain a known orders table.
if ! gunzip -c "$BACKUP" | grep -q "CREATE TABLE .*${TABLE_PREFIX}wc_orders"; then
    echo "FATAL: backup does not contain the orders table — aborting." >&2
    exit 1
fi

echo "Verified backup: $BACKUP"

Run this as a pre-deploy step on any deployment that touches the database. Note the WordPress-specific constraint: the database lives outside the release directory, so schema changes are durable. Flipping a symlink back does not undo wp core update-db. File rollback and database rollback are two separate operations, and only one of them is one click.

For the general pattern, see our guide to database migration strategies for zero-downtime deployments.


Payment Gateway Configuration

WooCommerce stores gateway settings — including live API keys — as a serialized option row in the database. That single fact drives everything in this section.

The rule: credentials never enter Git

Don't put keys in wp-config.php, don't put them in a committed .env, and don't put them in a theme. Instead, have DeployHQ write a machine-specific file at deploy time.

DeployHQ Config Files exist for exactly this: files uploaded during a deployment but never kept in your repository, and targetable at a specific server or server group. Create one config file per environment, all with the same path — /var/www/html/wp-config-local.php — and point each at the matching server:

<?php
// wp-config-local.php — written by DeployHQ, never committed.

define('WP_ENVIRONMENT', 'staging');

define('DB_NAME', 'wc_staging');
define('DB_USER', 'wc_staging');
define('DB_PASSWORD', '...');

// Stripe TEST credentials. A staging site must never hold live keys.
define('WC_STRIPE_TEST_PUBLISHABLE_KEY', 'pk_test_...');
define('WC_STRIPE_TEST_SECRET_KEY',      'sk_test_...');

Then wp-config.php — the version-controlled one — just loads it:

<?php
// wp-config.php (in Git, contains no secrets)
require_once __DIR__ . '/wp-config-local.php';

// ... rest of standard wp-config.php ...
require_once ABSPATH . 'wp-settings.php';

For values your build needs rather than your server, use DeployHQ environment variables, which are encrypted at rest, can be locked so the value can never be read back, and are scoped per server or server group.

One thing to be precise about: DeployHQ environment variables are available to your build commands and deployment, not to PHP-FPM at runtime. An mu-plugin calling getenv('WP_ENVIRONMENT') will get nothing unless your PHP-FPM pool, web server, or shell profile also sets it. This is why the example above defines a constant in a deploy-written config file rather than relying on getenv() — the constant is guaranteed to be there when WordPress boots.

Setting this up now? Separating staging credentials from production is worth doing before you automate anything else. Compare DeployHQ plans to see which tier covers the number of servers and environments your store needs.

Force test mode outside production

Because settings live in the database, the reliable hook is WordPress's own option_ filter on the gateway's option row:

<?php
// wp-content/mu-plugins/payment-gateway-safety.php
if (!defined('ABSPATH')) exit;

// WooCommerce Stripe stores its settings in the `woocommerce_stripe_settings` option.
add_filter('option_woocommerce_stripe_settings', function ($settings) {
    if (!is_array($settings) || WP_ENVIRONMENT === 'production') {
        return $settings;
    }

    $settings['testmode']                = 'yes';
    $settings['test_publishable_key']    = defined('WC_STRIPE_TEST_PUBLISHABLE_KEY') ? WC_STRIPE_TEST_PUBLISHABLE_KEY : '';
    $settings['test_secret_key']         = defined('WC_STRIPE_TEST_SECRET_KEY') ? WC_STRIPE_TEST_SECRET_KEY : '';

    // Blank the live keys so nothing can fall back to them.
    $settings['publishable_key']         = '';
    $settings['secret_key']              = '';

    return $settings;
}, 10, 1);

Two caveats worth stating plainly:

  • Gateway option keys vary between extensions. woocommerce_stripe_settings is correct for the official WooCommerce Stripe Gateway; PayPal, Klarna, Mollie and others each use their own option name and field names. Check the option row on a scratch install before trusting a filter in production.
  • A runtime filter is a seatbelt, not a fix. The live keys are still sitting in the staging database where a plugin, a direct query, or a webhook replay could reach them. The filter is the second line of defence. The first is overwriting those rows at import time.

Overwrite the keys at import, not at render

Put this in the same script that imports a production dump into staging, before the site is reachable:

#!/bin/bash
# scripts/sanitize-staging-db.sh — runs immediately after importing a production dump.
set -euo pipefail
SITE_PATH="/var/www/staging"

# Refuse to run anywhere but staging.
ENV="$(wp eval 'echo defined("WP_ENVIRONMENT") ? WP_ENVIRONMENT : "unknown";' --path="$SITE_PATH")"
if [ "$ENV" != "staging" ]; then
    echo "FATAL: WP_ENVIRONMENT is '$ENV', refusing to sanitize." >&2
    exit 1
fi

# 1. Strip live gateway credentials from the stored option.
wp option patch update woocommerce_stripe_settings secret_key ''      --path="$SITE_PATH"
wp option patch update woocommerce_stripe_settings publishable_key '' --path="$SITE_PATH"
wp option patch update woocommerce_stripe_settings testmode 'yes'     --path="$SITE_PATH"

# 2. Clear stored payment tokens (they reference live customer objects at the gateway).
PREFIX="$(wp db prefix --path="$SITE_PATH")"
wp db query "TRUNCATE TABLE ${PREFIX}woocommerce_payment_tokens" --path="$SITE_PATH"
wp db query "TRUNCATE TABLE ${PREFIX}woocommerce_payment_tokenmeta" --path="$SITE_PATH"

# 3. Rewrite URLs, serialization-safe (dry-run this the first time you write it).
wp search-replace 'https://store.example.com' 'https://staging.store.example.com' \
    --all-tables-with-prefix --skip-columns=guid --path="$SITE_PATH"

echo "Staging database sanitized."

wp option patch update edits a single key inside a serialized option without rewriting the whole array — which is exactly what you want here, and what a raw SQL update cannot do safely.

The environment guard at the top is the important line. Every destructive maintenance script for a store should refuse to run unless it can positively confirm it is not on production.


Environment-Aware Configuration

With WP_ENVIRONMENT defined by the deploy-written config file, the rest is straightforward:

<?php
// wp-content/mu-plugins/environment-config.php
if (!defined('ABSPATH')) exit;

if (!defined('WP_ENVIRONMENT')) {
    // Fail closed: if we can't tell, assume production and stay conservative.
    define('WP_ENVIRONMENT', 'production');
}

switch (WP_ENVIRONMENT) {
    case 'development':
        define('WP_DEBUG', true);
        define('WP_DEBUG_LOG', true);
        define('SCRIPT_DEBUG', true);
        break;

    case 'staging':
        define('WP_DEBUG', true);
        define('WP_DEBUG_LOG', true);
        define('DISALLOW_FILE_MODS', true);
        // Block all outbound mail — customers must never hear from staging.
        add_filter('pre_wp_mail', '__return_false');
        break;

    case 'production':
    default:
        define('WP_DEBUG', false);
        define('WP_DEBUG_LOG', false);
        define('DISALLOW_FILE_MODS', true);
        define('WP_CACHE', true);
        break;
}

Note pre_wp_mail returning false short-circuits WordPress's mailer entirely. That is deliberately blunt: on a store clone, a stray wp_mail() means a real customer gets a duplicate your order has shipped notice.

Add a visual marker so nobody mistakes staging for production:

add_action('admin_bar_menu', function ($admin_bar) {
    if (WP_ENVIRONMENT !== 'production') {
        $admin_bar->add_node([
            'id'     => 'environment-warning',
            'title'  => '<span style="background:#d63638;padding:0 10px;color:#fff;font-weight:600;">'
                      . esc_html(strtoupper(WP_ENVIRONMENT)) . '</span>',
            'parent' => 'top-secondary',
        ]);
    }
}, 999);

Build Pipeline for WooCommerce

Deploy your custom theme and plugins; let Composer or WP-CLI manage WooCommerce and core. If you use Composer with wpackagist, pin against the versions current at time of writing (WordPress 7.1.x, WooCommerce 11.1.x) rather than copying stale constraints from older tutorials:

{
    "name": "yourcompany/woocommerce-store",
    "require": {
        "php": ">=8.1",
        "wpackagist-plugin/woocommerce": "^11.1",
        "wpackagist-plugin/woocommerce-gateway-stripe": "^11.0",
        "wpackagist-plugin/wordpress-seo": "^28.6"
    },
    "repositories": [
        { "type": "composer", "url": "https://wpackagist.org" }
    ],
    "extra": {
        "installer-paths": {
            "wp-content/plugins/{$name}/": ["type:wordpress-plugin"],
            "wp-content/themes/{$name}/": ["type:wordpress-theme"]
        }
    }
}

On versions: WooCommerce 11.1 declares a minimum of PHP 7.4 and WordPress 7.0, but 7.4 has been end-of-life for years. Require PHP 8.1 or newer in your own composer.json so the constraint is enforced at build time rather than discovered in production. Check the current floor on the WooCommerce plugin page before pinning — it moves.

Build commands in DeployHQ run in an isolated container before anything reaches your servers:

composer install --no-dev --optimize-autoloader --no-interaction
cd wp-content/themes/your-theme && npm ci && npm run build

More on structuring this in our guide to build pipelines in DeployHQ.


Zero Downtime: What It Does and Doesn't Cover

This is where a lot of WooCommerce advice overstates things, so be precise about the mechanism.

DeployHQ's zero-downtime deployments are atomic filesystem releases. Each deployment uploads into a new timestamped directory under releases/, and only when the transfer has fully completed does the current symlink flip to point at it:

/var/www/store/
├── releases/
│   ├── 20260915103000/
│   └── 20260930141200/   <- new release
├── shared/
└── current -> releases/20260930141200

If the deployment fails partway through, the symlink never moves and the store keeps serving the previous release, untouched. See the zero-downtime setup docs for the full layout.

Three things this deliberately does not do, and you should plan around each:

  1. It does not restart PHP-FPM or any other process. With OPcache enabled and opcache.validate_timestamps off, PHP may keep serving bytecode from the old path after the symlink moves. Either leave timestamp validation on, or add an SSH command to reload PHP-FPM after deploy.
  2. It does not roll back the database. wp core update-db and wp wc update alter schema in place. One-click rollback restores the file tree; restoring data is a separate wp db import of the backup you verified earlier.
  3. It does not preserve runtime-written files by itself — that's what the shared/ directory is for, covered next.

Broader background in our post on zero-downtime deployments.

Product Images and Uploads

wp-content/uploads must not be in Git and must not live inside a release directory — a symlink flip would orphan every image customers uploaded since the last deploy.

Option 1 — DeployHQ shared directory (simplest). Configure wp-content/uploads as a shared path. It lives in shared/ and is symlinked into each release, so it persists across deployments automatically.

Option 2 — Object storage. Offload media to S3 or a compatible bucket so no server holds the canonical copy. WP Offload Media reads a constant, which keeps the keys out of the database:

// Set in wp-config-local.php, written by DeployHQ per environment.
define('AS3CF_SETTINGS', serialize([
    'provider'          => 'aws',
    'access-key-id'     => AWS_ACCESS_KEY_ID,
    'secret-access-key' => AWS_SECRET_ACCESS_KEY,
    'bucket'            => S3_BUCKET,
    'region'            => S3_REGION,
    'copy-to-s3'        => true,
    'serve-from-s3'     => true,
]));

Option 3 — rsync a copy down to staging. Workable, but note the direction and rehearse it:

# Production -> staging. --delete is destructive on the DESTINATION.
# Reverse these two arguments by accident and you wipe production's media library.
rsync -avz --delete --dry-run \
    production-server:/var/www/store/shared/uploads/ \
    /var/www/staging/shared/uploads/

Run with --dry-run first, every time. There is no undo on --delete.


Plugin Updates on a Live Store

flowchart TD
    A[Plugin Update Available] --> B{Critical Security?}
    B -->|Yes| C[Restore prod DB to staging, sanitize, test]
    B -->|No| D[Schedule for next release window]
    C --> E{Checkout completes with test card?}
    E -->|Yes| F[Verified backup, then deploy to production]
    E -->|No| G[Report to plugin author, hold]
    F --> H[Monitor failed-order rate]
    H --> I{Order failures above baseline?}
    I -->|Yes| J[Rollback files + restore DB]
    I -->|No| K[Done]

The acceptance test that matters for a store is not does the site load — it's can a customer complete checkout. Test that on sanitized staging with a gateway test card before any plugin update reaches production.

#!/bin/bash
# scripts/update-plugins.sh — staging only.
set -euo pipefail
SITE_PATH="/var/www/staging"
WC_USER=1   # a user ID with manage_woocommerce capability

wp plugin update --all --path="$SITE_PATH"
wp core update-db --path="$SITE_PATH"
wp wc update --path="$SITE_PATH"

# WooCommerce's REST-backed CLI commands authenticate as a WordPress user.
# Without --user they return HTTP 401 and WP-CLI exits non-zero.
wp wc shop_order list --user="$WC_USER" --per_page=1 --path="$SITE_PATH"
wp wc product list    --user="$WC_USER" --format=count --path="$SITE_PATH"

That --user flag is the single most common reason a wp wc ... step fails in CI. The commands are generated from the WooCommerce REST API and run through permission checks, so they need a user with the right capability — WooCommerce's own error message tells you to include the --user flag with an account that has permissions for this action.

Managing this across many client stores rather than one? The fleet patterns in managing WordPress releases across 40+ client sites apply directly, and DeployHQ for agencies covers the multi-project and permissions side.


Deployment Script

Pulling it together. Note that maintenance mode is held only across the schema-change window, not the whole deploy — the file swap itself is atomic and needs no maintenance mode at all.

#!/bin/bash
# scripts/deploy.sh — run as a post-deploy SSH command.
set -euo pipefail

SITE_PATH="/var/www/store/current"
SITE_URL="${SITE_URL:?SITE_URL must be set as a DeployHQ environment variable}"
WC_USER="${WC_USER:?WC_USER must be set to a user ID with manage_woocommerce}"
PHP_FPM_SERVICE="${PHP_FPM_SERVICE:?PHP_FPM_SERVICE must name your PHP-FPM systemd unit}"
MAINTENANCE_FILE="$SITE_PATH/.maintenance"

disable_maintenance() { rm -f "$MAINTENANCE_FILE"; }
trap disable_maintenance EXIT

echo "=== WooCommerce deployment ==="

# 1. Verified backup BEFORE any schema change.
./scripts/backup-db.sh

# 2. Maintenance mode for the schema window only.
echo '<?php $upgrading = time(); ?>' > "$MAINTENANCE_FILE"

wp core update-db --path="$SITE_PATH"
wp wc update --path="$SITE_PATH"

disable_maintenance
trap - EXIT

# 3. Caches.
wp cache flush --path="$SITE_PATH"
wp transient delete --all --path="$SITE_PATH"
wp rewrite flush --path="$SITE_PATH"

if wp plugin is-active redis-cache --path="$SITE_PATH" 2>/dev/null; then
    wp redis flush --path="$SITE_PATH"
fi

# 4. Reload PHP-FPM so OPcache picks up the new release path.
#    Substitute your actual service name (php8.2-fpm, php-fpm, etc.).
sudo systemctl reload "$PHP_FPM_SERVICE"

# 5. Smoke test: the storefront and a real product page must return 200.
for path in "/" "/shop/"; do
    code=$(curl -s -o /dev/null -w '%{http_code}' "${SITE_URL}${path}")
    if [ "$code" != "200" ]; then
        echo "FATAL: ${path} returned ${code}" >&2
        exit 1
    fi
done

PRODUCTS=$(wp wc product list --user="$WC_USER" --format=count --path="$SITE_PATH")
echo "Products in catalog: $PRODUCTS"
[ "$PRODUCTS" -gt 0 ] || { echo "WARNING: catalog is empty — verify deployment" >&2; }

echo "=== Deployment complete ==="

The ${VAR:?message} syntax is doing real work there. Under set -u an unset variable aborts the script with an unhelpful message; :? aborts with one that tells you exactly which DeployHQ environment variable is missing.


Monitoring What Actually Matters

Uptime checks tell you the store responded. They don't tell you the store took money. Watch the order pipeline:

<?php
// wp-content/mu-plugins/woocommerce-health-checks.php
if (!defined('ABSPATH')) exit;

add_action('woocommerce_order_status_failed', function ($order_id) {
    if (WP_ENVIRONMENT !== 'production') return;

    $order = wc_get_order($order_id);
    if (!$order) return;

    wp_remote_post(MONITORING_WEBHOOK_URL, [
        'timeout'  => 5,
        'blocking' => false,   // never block checkout on a monitoring call
        'body'     => wp_json_encode([
            'event'    => 'order_failed',
            'order_id' => $order_id,
            'gateway'  => $order->get_payment_method(),
        ]),
    ]);
});

Two details that matter in production: 'blocking' => false so a slow monitoring endpoint can't stall a customer's checkout, and no order total or customer data in the payload — send the order ID and look the rest up in an authenticated context.

The signal to alert on is a change in the failed-order rate after a deploy, not an absolute count. Establish your store's normal baseline before you set a threshold; a rate that's normal for one store is an incident for another.


Pre-Deploy Checklist

  1. Verified database backup taken and confirmed non-empty.
  2. search-replace rehearsed with --dry-run and --all-tables-with-prefix.
  3. No secrets in the repository — gateway keys arrive via config files.
  4. Staging confirmed on gateway test credentials, with live keys blanked in its database.
  5. Outbound email disabled on every non-production environment.
  6. Checkout completed end to end on staging with a test card.
  7. Rollback plan covers both the file tree and the database.
  8. wp wc commands include --user.

For broader WordPress workflow context, see WordPress development in 2026.

Getting Started

Wire the safety rails in before you automate anything: a staging environment with its own database and test gateway keys, config files carrying the credentials, and a verified backup step ahead of every schema change. Once those are in place, automating the deploy itself is the easy part.

Start a free DeployHQ trial and connect your store's repository — atomic releases, per-environment config files, and build pipelines are configured once and then run on every push.


Questions about WooCommerce deployments? Contact support@deployhq.com or follow @deployhq for e-commerce deployment tips.