Deployment and CI/CD
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
Deploying WordPress moves two things in opposite directions: code (core, plugins, themes, configuration) travels up
from Git to production through automated checks, and content (database and uploads) travels only down from
production. For developers and operators of the Lumen Press site, this page builds a GitHub Actions pipeline for the
lumen-catalog plugin and the lumen theme, deploys it with an atomic symlink swap, and covers database and media
sync, maintenance mode and rollback.
The page was written against WordPress 7.1.3 (released 2026-10-06), PHP 7.4 minimum and 8.3+ recommended,
@wordpress/scripts 37.0.0, @wordpress/env 12.0.0, WPCS 3.4.1 and WP-CLI 2.12.0, and checked on 2026-10-28. On that
day WordPress 7.1.4 shipped as a security-only release (fixes in the upgrade process, the Interactivity API and
several stored XSS issues; no API changes); 6.9.11 is the current 6.9 release. The pipeline and the release pin 7.1.4.
Tests ran against 7.1.4 and 6.9.11 in wp-env; the release script ran on the wordpress:7.1.3-php8.4-apache image
(7.1.4 images were not yet published) serving a 7.1.4 release.
Environments and WP_ENVIRONMENT_TYPE
Lumen Press has the three environments described in
Local Development Environments:
local (wp-env or Docker Compose at lumenpress.test), staging (staging.lumenpress.example, a recent copy of
production on the production stack) and production (lumenpress.example). Each declares what it is with
WP_ENVIRONMENT_TYPE, which wp_get_environment_type() (5.5) returns as local, development, staging or
production. The constant wins over an environment variable of the same name, a missing or unknown value means
production, and development turns WP_DEBUG on when it is not defined.
| Local | Staging | Production | |
|---|---|---|---|
|
|
|
|
Code arrives by |
Working copy, wp-env mappings |
The pipeline, after every push to |
The pipeline, after approval of the staging deploy |
Database |
Disposable, or a sanitised copy |
Sanitised copy of production, refreshed on demand |
Source of truth, never overwritten |
Uploads |
Partial copy or proxied from production |
Copy, or served from production |
Source of truth, backed up |
Mail, indexing, cron |
Suppressed |
Suppressed, |
On |
File modifications |
Allowed |
|
|
Code branches on the environment type, never on the host name. The value belongs in the server configuration or
wp-config.php, never in a plugin, because the result is cached on the first call.
Code and content
| What | Where it lives | How it moves |
|---|---|---|
|
Git |
Pipeline: build, test, deploy |
Third-party plugins and themes, WordPress core |
Pinned versions (Composer or the release script) |
Pipeline, as part of the release |
|
Git ( |
Pipeline |
Database credentials, keys and salts, |
Server only ( |
Never in Git, never in CI logs |
Posts, users, options, orders, comments |
Database |
Down only: production to staging to local |
|
Shared disk or object storage |
Down only, or proxied |
|
Generated |
Built by CI, never committed |
Site Editor changes to templates and global styles live in the database, so a theme change made in production is
exported to files and committed (see
Create Block Theme and Version Control). And
DISALLOW_FILE_MODS removes the installers, editors and automatic updates of plugins, themes and core, so that Git
keeps describing what runs; security releases such as 7.1.4 then arrive only through a deploy (see
Updates and Auto-Updates). Composer-managed core and
plugins are covered in Composer-Based WordPress.
The pipeline
Figure: pull requests run the check jobs; a push to main also packages a release and deploys it to staging, then
to production once a reviewer approves the production environment.
The repository holds wp-content/plugins/lumen-catalog, wp-content/themes/lumen, wp-content/mu-plugins and a
deploy/ folder. The plugin’s package.json pins @wordpress/scripts 37.0.0, @wordpress/env 12.0.0,
@wordpress/e2e-test-utils-playwright 3.1.0 and @playwright/test 1.64.1, and defines the scripts the workflow calls:
build (the wp-scripts build command with --blocks-manifest shown in
block.json and Registration, writing to build/blocks/),
lint:js, lint:style, test:e2e (wp-scripts test-playwright) and wp-env.
The wp-env configuration pins the database and activates the theme after every start. testsEnvironment is
deprecated in wp-env 12 but still defaults to creating a second set of containers on port 8889, so it is switched off;
PHPUnit runs in the cli container, which ships the WordPress test library for the installed version in WP_TESTS_DIR:
{
"core": "https://downloads.wordpress.org/release/wordpress-7.1.4.zip",
"phpVersion": "8.4",
"mariadbVersion": "11.8",
"testsEnvironment": false,
"plugins": [ "." ],
"themes": [ "../../themes/lumen" ],
"config": {
"WP_ENVIRONMENT_TYPE": "local"
},
"lifecycleScripts": {
"afterStart": "wp-env run cli wp theme activate lumen"
}
}
Without afterStart the fresh site had no usable theme ("The theme directory "default" does not exist") and the
end-to-end test failed. phpcs.xml.dist and phpstan.neon.dist are explained in
Coding Standards and Static Analysis, the PHPUnit
bootstrap and Playwright fixtures in Testing.
The CI workflow
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PLUGIN_DIR: wp-content/plugins/lumen-catalog
WP_RELEASE: '7.1.4'
jobs:
blocks:
name: Lint and build blocks
runs-on: ubuntu-24.04
defaults:
run:
working-directory: wp-content/plugins/lumen-catalog
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7.1.0
with:
node-version: 24
cache: npm
cache-dependency-path: wp-content/plugins/lumen-catalog/package-lock.json
- run: npm ci
- run: npm run lint:js
- run: npm run lint:style
- run: npm run build
- uses: actions/upload-artifact@v7.0.2
with:
name: blocks-build
path: wp-content/plugins/lumen-catalog/build/
retention-days: 7
php-quality:
name: PHPCS and PHPStan (${{ matrix.project }})
runs-on: ubuntu-24.04
strategy:
matrix:
project: [plugins/lumen-catalog, themes/lumen]
defaults:
run:
working-directory: wp-content/${{ matrix.project }}
steps:
- uses: actions/checkout@v7.0.1
- uses: shivammathur/setup-php@2.40.0
with:
php-version: '8.4'
tools: composer:v2, cs2pr
coverage: none
- run: composer install --no-interaction --no-progress
- run: vendor/bin/phpcs -q --report=checkstyle | cs2pr
- if: matrix.project == 'plugins/lumen-catalog'
run: vendor/bin/phpstan analyse --memory-limit=1G --no-progress
tests:
name: Tests (PHP ${{ matrix.php }}, WP ${{ matrix.wp }})
needs: blocks
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.4']
wp: ['6.9.11', '7.1.4']
env:
WP_ENV_PHP_VERSION: ${{ matrix.php }}
WP_ENV_CORE: https://downloads.wordpress.org/release/wordpress-${{ matrix.wp }}.zip
defaults:
run:
working-directory: wp-content/plugins/lumen-catalog
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7.1.0
with:
node-version: 24
cache: npm
cache-dependency-path: wp-content/plugins/lumen-catalog/package-lock.json
- uses: shivammathur/setup-php@2.40.0
with:
php-version: '8.4'
tools: composer:v2
coverage: none
- uses: actions/download-artifact@v8.0.2
with:
name: blocks-build
path: wp-content/plugins/lumen-catalog/build/
- run: npm ci
- run: composer install --no-interaction --no-progress
- run: npm run wp-env start
- run: npm run wp-env -- run cli --env-cwd=wp-content/plugins/lumen-catalog vendor/bin/phpunit
- name: Playwright end-to-end tests (production versions only)
if: matrix.php == '8.4' && matrix.wp == '7.1.4'
env:
WP_BASE_URL: http://localhost:8888
run: |
npx playwright install --with-deps chromium
npm run test:e2e
- if: failure()
uses: actions/upload-artifact@v7.0.2
with:
name: playwright-${{ matrix.php }}-${{ matrix.wp }}
path: wp-content/plugins/lumen-catalog/artifacts/
if-no-files-found: ignore
retention-days: 7
package:
name: Assemble the release
needs: [php-quality, tests]
if: github.event_name == 'push'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7.0.1
- uses: shivammathur/setup-php@2.40.0
with:
php-version: '8.4'
tools: composer:v2
coverage: none
- uses: actions/download-artifact@v8.0.2
with:
name: blocks-build
path: ${{ env.PLUGIN_DIR }}/build/
- name: Download WordPress core and check its checksum
run: |
zip="wordpress-${WP_RELEASE}-no-content.zip"
curl -fsSLO "https://downloads.wordpress.org/release/${zip}"
echo "$(curl -fsSL "https://downloads.wordpress.org/release/${zip}.sha1") ${zip}" | sha1sum -c -
unzip -q "${zip}" -d core
- name: Build the release directory
run: |
composer install --working-dir="${PLUGIN_DIR}" --no-dev --optimize-autoloader --no-interaction --no-progress
mv core/wordpress release
mkdir -p release/wp-content/plugins release/wp-content/themes release/wp-content/mu-plugins
rsync -a --exclude-from="${PLUGIN_DIR}/.distignore" "${PLUGIN_DIR}/" release/wp-content/plugins/lumen-catalog/
rsync -a --exclude-from=wp-content/themes/lumen/.distignore wp-content/themes/lumen/ release/wp-content/themes/lumen/
rsync -a wp-content/mu-plugins/ release/wp-content/mu-plugins/
cp deploy/wp-config.php release/wp-config.php
echo "${GITHUB_SHA}" > release/REVISION
- uses: actions/upload-artifact@v7.0.2
with:
name: release
path: release/
include-hidden-files: true
retention-days: 30
staging:
needs: package
uses: ./.github/workflows/deploy.yml
with:
environment: staging
secrets: inherit
production:
needs: staging
uses: ./.github/workflows/deploy.yml
with:
environment: production
secrets: inherit
-
Build once, test the build. Every later job downloads the
blocks-buildartifact, so tests and release use the same JavaScript. It must containbuild/blocks/blocks-manifest.php, or the blocks are never registered (a build without--output-path=build/blockswrote it one folder up and the registration test failed). -
The matrix.
WP_ENV_PHP_VERSIONandWP_ENV_COREoverridephpVersionandcorefrom.wp-env.json. 7.4 and 6.9 are the plugin’s declared minimums; 8.4 and 7.1.4 are what production runs. Both corners were run locally (PHP 7.4.33 with 6.9.11, PHP 8.4.27 with 7.1.4; OK, 2 tests, 3 assertions). 8.4.27 is a release the Hosting Handbook warns about; production should run 8.4.28 or later. -
Composer.
config.platform.php7.4.33resolves packages that load on PHP 7.4 (PHPUnit 9.6) on an 8.4 runner. -
End-to-end. Playwright runs only in the production cell, reusing its site.
WP_BASE_URLpoints the@wordpress/scriptsPlaywright configuration (default 8889, the old tests site) at 8888. The test insertslumen/book-cardtitled "The Lighthouse Ledger", publishes and checks the front end (passed in 3.6 s). -
Hardening. Actions are pinned to release tags (a commit SHA is stronger, since tags can move); the token is read-only.
The workflow and deploy.yml passed actionlint 1.7.12 (including shellcheck). Every step was run locally with the
same commands; the workflow itself was not run on GitHub.
The deploy workflow
Deployment is a reusable workflow called once per environment. Each call names a GitHub environment, which holds that
environment’s secrets and variables and its protection rules: production has a required reviewer, so the run pauses
after staging until someone approves it, and both allow deployments only from main.
name: Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
permissions:
contents: read
jobs:
deploy:
name: Deploy to ${{ inputs.environment }}
runs-on: ubuntu-24.04
environment:
name: ${{ inputs.environment }}
url: ${{ vars.SITE_URL }}
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
RELEASE: r${{ github.run_number }}-${{ github.sha }}
steps:
- uses: actions/download-artifact@v8.0.2
with:
name: release
path: release/
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
SSH_KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "${SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts
printf '%s\n' "${SSH_PRIVATE_KEY}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
- name: Upload the release
run: |
rsync -az --delete -e 'ssh -i ~/.ssh/deploy_key' release/ \
"${DEPLOY_USER}@${DEPLOY_HOST}:/srv/lumenpress/releases/${RELEASE}/"
- name: Activate the release
run: |
ssh -i ~/.ssh/deploy_key "${DEPLOY_USER}@${DEPLOY_HOST}" \
"/srv/lumenpress/bin/activate-release ${RELEASE}"
- name: Smoke test
env:
SITE_URL: ${{ vars.SITE_URL }}
run: curl -fsS -o /dev/null --retry 3 "${SITE_URL}/wp-json/"
Per environment, DEPLOY_SSH_KEY is a secret (a key pair used only for deployments, its public half in the deploy
user’s authorized_keys); DEPLOY_HOST, DEPLOY_USER, DEPLOY_KNOWN_HOSTS (ssh-keyscan output, checked by hand
once) and SITE_URL are variables. secrets: inherit passes the caller’s secrets on, and the job’s environment:
selects whose values apply. Secrets are masked in logs but readable by every step of the job, so pull requests never
run a job with an environment. Expressions reach scripts through env:, never pasted into run:, which keeps them out
of shell parsing. The deploy user owns /srv/lumenpress but has no sudo and no database root access. The release id
combines the run number and the commit SHA, which REVISION in the docroot also records (block that path at the web
server if it should stay private).
Deploying a container image instead
On a container platform (Kubernetes, a managed container service) the release directory becomes an image. The same
package artifact is copied over the official image, which provides Apache, PHP 8.4 and its extensions; because the
docroot is already populated, the image’s entrypoint does not copy its own WordPress over it:
FROM wordpress:7.1.3-php8.4-apache
# Core 7.1.4, plugins, theme and wp-config.php all come from the release directory.
COPY --chown=www-data:www-data release/ /var/www/html/
Source: Docker Hub: wordpress
The image built from the release reported WordPress 7.1.4 and the expected REVISION. The deploy job then uses
docker/login-action@v4.7.0, docker/metadata-action@v6.2.0 and docker/build-push-action@v7.4.0 to push the image
tagged with the commit SHA (see Docker: CI/CD with GitHub Actions
and Containers, Docker and Kubernetes). Three rules differ:
wp-config.php reads environment variables from the platform’s secret store (see
wp-config.php Reference); only
wp-content/uploads is a volume, never all of /var/www/html, or the first image’s code persists in the volume and
hides every later image; and wp core update-db runs once after the rollout (see
Scaling and High Availability).
Releasing that image to a Kubernetes cluster (rolling updates, the migration job, autoscaling and rollback) is covered in Releases, Upgrades and Autoscaling on Kubernetes.
Zero-downtime releases
Copying files over a live site serves a mix of old and new code while the copy runs, and a request that includes one
new and one old file can fatal. Instead, each release is uploaded complete to its own directory and the document root
is a symlink switched in one step. /srv/lumenpress/releases/<id>/ holds one complete docroot per release (core, wp-content code,
wp-config.php); current is a symlink to one of them and is the web server’s document root; shared/ holds
config.php (credentials, keys and salts, WP_HOME, WP_ENVIRONMENT_TYPE) and uploads/; bin/ holds the two
scripts below, and releases.log the activation history. Each release’s wp-config.php comes from Git and holds no secret:
<?php
/**
* wp-config.php shipped in every Lumen Press release.
* Secrets and per-environment values live outside the release, on the server.
*/
require '/srv/lumenpress/shared/config.php'; // DB_*, keys and salts, WP_HOME, WP_SITEURL, WP_ENVIRONMENT_TYPE.
$table_prefix = 'lp_';
// Code only changes through a deployment.
define( 'DISALLOW_FILE_MODS', true );
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
require_once ABSPATH . 'wp-settings.php';
Symlinking wp-config.php from shared/ fails: __DIR__ is the real file’s directory, so ABSPATH would be
shared/. The script the deploy workflow calls:
#!/usr/bin/env bash
# Usage: activate-release <release-id> [--maintenance]
# Points /srv/lumenpress/current at releases/<release-id> with one atomic rename.
set -euo pipefail
base="${LUMEN_BASE:-/srv/lumenpress}"
release="$1"
maintenance="${2:-}"
dir="${base}/releases/${release}"
previous="$(readlink -f "${base}/current")"
wp_bare=(wp --skip-plugins --skip-themes)
[ -f "${dir}/wp-settings.php" ] || { echo "Unknown release: ${release}" >&2; exit 1; }
# Shared, writable state is linked in, never copied.
ln -sfn "${base}/shared/uploads" "${dir}/wp-content/uploads"
# Refuse a release that cannot reach the database (WP-CLI loads no plugins here).
wp --path="${dir}" core is-installed
if [ "${maintenance}" = "--maintenance" ]; then
# .maintenance lives in each release root, so both releases need it.
"${wp_bare[@]}" --path="${previous}" maintenance-mode activate
"${wp_bare[@]}" --path="${dir}" maintenance-mode activate
fi
# Atomic swap: create the new link beside the old one, then rename it over it.
ln -sfn "releases/${release}" "${base}/current.next"
mv -T "${base}/current.next" "${base}/current"
echo "${release}" >> "${base}/releases.log"
# Core database upgrade (no plugins loaded), then one full boot so that plugin
# upgrade routines such as Installer::maybe_upgrade() run now, not on a visitor's request.
wp --path="${base}/current" core update-db
wp --path="${base}/current" plugin list --status=active --field=name
if [ "${maintenance}" = "--maintenance" ]; then
"${wp_bare[@]}" --path="${dir}" maintenance-mode deactivate
"${wp_bare[@]}" --path="${previous}" maintenance-mode deactivate
fi
# Keep the five most recently uploaded releases (and always the active one).
cd "${base}/releases"
# shellcheck disable=SC2012 # release ids are r<run>-<sha>, never odd file names
ls -1t | tail -n +6 | while read -r old; do
[ "${old}" = "${release}" ] || rm -rf -- "${old}"
done
echo "Active release: ${release}"
What was checked, and why each line is there:
-
Atomicity.
ln -sfnover an existing link deletes it and creates a new one, leaving a moment with no document root; renamingcurrent.nextwithmv -T(GNU coreutils) is a single atomicrename(2). The script ran on awordpress:7.1.3-php8.4-apachecontainer (DocumentRoot /srv/lumenpress/current, MariaDB 11.8, the 7.1.4 release from thepackagesteps) while 300 requests hit the home page: all returned 200 and/REVISIONswitched SHA. -
Plugin upgrades. WP-CLI defines
WP_INSTALLINGforcore is-installedandcore update-db, so neither loads plugins. WithInstaller::DB_VERSIONraised to 2 in the new release,lumen_catalog_db_versionstayed 1 aftercore update-db; thewp plugin listboot raised it to 2. Without that line a visitor’s request runsdbDelta(). -
Old code, new schema. Until every PHP worker serves the new release (and always, in a cluster), old code meets the new schema, so schema changes take two releases: add first, remove later ("expand, then contract").
-
Caches. With nginx and PHP-FPM, pass
$realpath_rootinstead of$document_rootinSCRIPT_FILENAMEandDOCUMENT_ROOT, so PHP receives real paths and neither its realpath cache nor OPcache serves the previous release. The shared Redis object cache is not flushed; code that changes the shape of cached values uses new keys.
Maintenance mode
During a core update, WordPress writes .maintenance in ABSPATH containing <?php $upgrading = 1793212345; ?>.
While that file exists and the timestamp is less than ten minutes old, wp_is_maintenance_mode() is true and
wp_maintenance() answers every front-end, admin and REST request with HTTP 503 and Retry-After: 600, using
wp-content/maintenance.php as the page when that drop-in exists. After ten minutes the file is ignored, so a crashed
update cannot lock a site forever. WP-CLI drives the same file:
wp maintenance-mode activate # writes .maintenance; errors if already active unless --force
wp maintenance-mode status # "Maintenance mode is active." / "... is not active."
wp maintenance-mode is-active # exit code 0 when active, 1 when not (for scripts)
wp maintenance-mode deactivate # deletes .maintenance
Each release has its own ABSPATH, so maintenance mode set only in the old release would end at the swap; with
--maintenance the script writes the file into both. During such a run 200 requests returned 120 times 200 and 80
times 503, and no .maintenance was left behind. A migration longer than ten minutes needs a web-server rule instead.
Database and media sync
Refreshing staging is an export, an import, a URL replacement and a sanitising step, run through the WP-CLI aliases
@production and @staging (see WP-CLI):
wp @production db export - --add-drop-table --single-transaction | gzip > lumen-prod.sql.gz
gunzip -c lumen-prod.sql.gz | wp @staging db import -
wp @staging search-replace 'https://lumenpress.example' 'https://staging.lumenpress.example' \
--all-tables-with-prefix --skip-columns=guid --precise --report-changed-only
wp @staging user list --role=subscriber --field=ID | xargs -r wp @staging user delete --reassign=1
wp @staging cache flush
wp @staging rewrite flush
rsync -az --delete deploy@lumenpress.example:/srv/lumenpress/shared/uploads/ /srv/lumenpress/shared/uploads/
-
wp search-replaceunserializes PHP-serialized values (such aslp_catalog_settings), replaces inside them and serializes them again; a SQLREPLACE()corrupts them because serialized strings store their length.--preciseforces that PHP path for every column. A dry run on the test site listed six replacements (three inlp_options, two inlp_posts.post_content, one inlp_users.user_url).--skip-columns=guidkeeps GUIDs stable. Block attributes, escaped slashes and Multisite are covered in Importing, Exporting and Migrating. -
wp db exportrunsmysqldumpormariadb-dump; on the official Apache image, which has no database client, it failed with "mysqldump: No such file or directory". -
Never import a lower environment’s database into production: it erases every order, comment and user created since the copy. Content goes up only deliberately (WXR import, a migration script, editors working in production).
-
A copy of production is a copy of personal data: anonymise it during the refresh (see Privacy and Personal Data) and keep dumps encrypted and short-lived.
-
Instead of copying uploads, staging and local sites can fetch missing files from production at the web server (
try_files $uri @production;with aproxy_passtohttps://lumenpress.example).
Rolling back
A rollback repoints current at the previous release; it needs no build and no upload:
#!/usr/bin/env bash
# Usage: rollback-release
# Points /srv/lumenpress/current back at the release that was active before the current one.
set -euo pipefail
base="${LUMEN_BASE:-/srv/lumenpress}"
current="$(basename "$(readlink "${base}/current")")"
previous="$(grep -vx "${current}" "${base}/releases.log" | tail -n 1)"
[ -n "${previous}" ] && [ -d "${base}/releases/${previous}" ] || { echo "No earlier release to roll back to" >&2; exit 1; }
ln -sfn "releases/${previous}" "${base}/current.next"
mv -T "${base}/current.next" "${base}/current"
echo "${previous}" >> "${base}/releases.log"
echo "Rolled back from ${current} to ${previous}"
On the test container it printed Rolled back from r42-9f8e7d6 to r41-1a2b3c4 and /REVISION returned the old SHA.
The rollback is itself logged, so running it twice goes forward again. Code rollback does not roll back the database.
An "expand" migration leaves the old code working; a destructive one is undone only by restoring the pre-deploy
backup (see Backups and Disaster Recovery). Rolling core back
across a db_version change is also unsafe: wp-admin compares the stored and expected values for equality and asks
for a database "update". After an emergency symlink rollback, revert the commit so main describes production again.
Plugins published to the WordPress.org directory are deployed through SVN instead, as shown in
Publishing to the Plugin Directory.
Where older books and tutorials are outdated
-
None of the three books covers deployment pipelines, environment types or testing in CI. Their advice to make files writable by the web server (666 or 777) contradicts immutable releases and the 644/755 permissions in File Permissions and Filesystem.
-
Moving a site with SQL replacements, still found in tutorials, corrupts serialized options; use
wp search-replace. -
Uploading changed files over FTP into a live site serves mixed releases and leaves no record of what runs.
-
Tutorials that install the test library with
bin/install-wp-tests.shstill work, but wp-env provides it inWP_TESTS_DIR, and wp-env’s separate tests environment is deprecated in 12.0.0.
Common errors and pitfalls
-
Committing secrets: real credentials in
wp-config.php,.envfiles or dumps in the repository. -
Building on the server:
npmandcomposer installon production add toolchains and network access to the web host; build in CI and ship artifacts. -
Missing
vendor/orbuild/: the plugin returns early without its autoloader, and blocks vanish silently withoutblocks-manifest.php; a test asserts the blocks are registered. -
A misspelt
WP_ENVIRONMENT_TYPEreads asproduction, so a staging copy mails real customers. -
Templates customised in the Site Editor keep overriding the deployed theme files; check Appearance > Editor after a theme release.
-
Cron during a deploy: a run of
lp_catalog_sync_stockcan start on the old release and finish on the new one; keep jobs idempotent.