wordpress · Article
Deploying WordPress from GitLab CI/CD
Promoting a staging database to production without corrupting serialised data, cutting DNS over through the Cloudflare API, and putting both back when the smoke test fails.

Most CI/CD guides assume your application is stateless. WordPress is the opposite. Half the site lives in a database, the site URL is baked into serialised PHP inside that database, and plugins keep writing to disk while you deploy over them.
So a WordPress release is three deployments, not one: code, database, DNS. They have to happen in that order. Any one of them can take the site down on its own.
The usual answer to this is a migration plugin. I'd argue against it, for a structural reason rather than a taste one: a plugin that runs inside the site it is migrating cannot roll back the site it just broke. The thing that performs the change has to outlive the change. That means the pipeline.
What belongs in git
One decision makes everything downstream easier.
Tracked: themes, must-use plugins, custom code, composer.json,
composer.lock, and the deploy scripts themselves.
Not tracked: wp-content/uploads, secrets, WordPress core, and anything a
plugin writes at runtime.
Pin core and plugins as Composer dependencies. The build then installs a known set of versions instead of whatever the server accumulated since the last time anyone looked at it.
Uploads are user data, not code. Keep them on object storage or rsync them
separately, and never let a deploy delete them. rsync --delete pointed at
wp-content has ended more sites than any bad plugin.
The pipeline
Six stages, each doing one thing.
stages: [build, test, deploy, migrate, cutover, verify]default: image: wordpress:cli-php8.3 interruptible: true retry: max: 2 when: [runner_system_failure, stuck_or_timeout_failure]workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHvariables: STAGING_HOST: deploy@staging.example.com PROD_HOST: deploy@example.com WP_PATH: /var/www/example.com/current# Short-lived OIDC token instead of a long-lived secret in project settings..oidc: &oidc id_tokens: VAULT_ID_TOKEN: aud: https://vault.example.combuild: stage: build script: - composer install --no-dev --prefer-dist --optimize-autoloader - npm ci && npm run build artifacts: paths: [wp-content/themes/acme/dist, vendor] expire_in: 1 weeklint: stage: test needs: [build] script: - vendor/bin/phpcs --standard=WordPress wp-content/themes/acme - vendor/bin/phpstan analyse --memory-limit=1Gdeploy:production: stage: deploy <<: *oidc needs: [build, lint] # Serialises every job in this group: a second pipeline queues instead of # interleaving with a half-finished release. resource_group: production environment: name: production url: https://example.com rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH script: - ./ci/ssh-agent.sh # Note the trailing slash and the exclude. Uploads are never touched. - rsync -az --delete --exclude 'wp-content/uploads' ./ "$PROD_HOST:$WP_PATH/" - ssh "$PROD_HOST" "cd $WP_PATH && wp core verify-checksums"migrate:database: stage: migrate <<: *oidc needs: [deploy:production] resource_group: production rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual allow_failure: false script: - ./ci/migrate-db.sh artifacts: paths: [backups/] when: always expire_in: 30 dayscutover:dns: stage: cutover needs: [migrate:database] resource_group: production rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual allow_failure: false script: - ./ci/cloudflare-cutover.shverify: stage: verify needs: [cutover:dns] script: - ./ci/smoke-test.shneeds: turns the stage list into a DAG, so lint starts the moment build
finishes instead of waiting on every other job in that stage.
interruptible: true lets a newer pipeline cancel an older one. Fine for build
and test. Not fine halfway through a database import, which is what
resource_group: production handles: the second pipeline queues behind the
first rather than interleaving with it.
Migrating the database
This is where WordPress deploys go wrong, and the reason fits on one line:
a:2:{s:4:"logo";s:27:"https://staging.example.com";}That s:27: is a byte count, not decoration. Run sed over the dump and you
get 19 bytes still claiming to be 27. unserialize() returns false. The
option evaporates. Widgets disappear, theme settings reset, and nothing in the
log explains it, so the site looks like someone reverted a year of work while
you stand there reading a green pipeline.
Never sed a WordPress SQL dump. wp search-replace unserialises each value,
replaces inside it, and re-serialises with corrected byte counts. Run it with
--dry-run first: it reports how many rows in each table it would touch, and
a number far larger than you expected is worth understanding before it is
permanent.
#!/usr/bin/env bashset -euo pipefailSTAMP="$(date -u +%Y%m%dT%H%M%SZ)"mkdir -p backups# 1. Snapshot production FIRST. This file is the rollback; if anything below# fails, it is the only way back.ssh "$PROD_HOST" "cd $WP_PATH && wp db export - --add-drop-table" \ | gzip > "backups/prod-${STAMP}.sql.gz"# 2. Export staging.ssh "$STAGING_HOST" "cd $WP_PATH && wp db export - --add-drop-table" \ | gzip > "backups/staging-${STAMP}.sql.gz"# 3. Import it over production.gunzip -c "backups/staging-${STAMP}.sql.gz" \ | ssh "$PROD_HOST" "cd $WP_PATH && wp db import -"# 4. Rewrite the domain. --precise runs the replace through PHP instead of# MySQL so serialised values are handled; --skip-columns=guid because a# GUID is a permanent identifier, not a URL, and feed readers key off it.ssh "$PROD_HOST" "cd $WP_PATH && \ wp search-replace 'staging.example.com' 'example.com' \ --all-tables \ --precise \ --recurse-objects \ --skip-columns=guid \ --report-changed-only"# 5. Staging leftovers that must not reach production.ssh "$PROD_HOST" "cd $WP_PATH && \ wp option update blog_public 1 && \ wp cache flush && \ wp rewrite flush --hard"# 6. Prove it worked before the pipeline moves on.ssh "$PROD_HOST" "cd $WP_PATH && wp option get home" | grep -qx 'https://example.com'Step 5 is the easy one to skip. A staging site normally runs with
blog_public set to 0, which emits noindex on every page. Copy that database
to production without resetting it and you have just delisted the site you were
launching.
Check the direction of travel. Pushing a staging database over production overwrites every post, comment and order created since staging was cloned. That is correct for a launch or a rebuild. It is destructive for a site that is already live and taking orders, where the database flows the other way, production down to staging, and only code moves up. Decide which of the two you are doing before you wire any of this up.
Cutting DNS over with the Cloudflare API
Two calls: find the record, patch it. A third to purge the edge cache.
#!/usr/bin/env bashset -euo pipefailAPI="https://api.cloudflare.com/client/v4"AUTH=(-H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json")# Cloudflare answers 200 with success:false for some failures, so the exit code# alone is not enough. Every response gets checked.cf() { curl -sS --fail-with-body "${AUTH[@]}" "$@" | tee /dev/stderr | jq -e '.success' >/dev/null; }# Record ids are not stable across environments; look it up by name and type.RECORD_ID="$(curl -sS "${AUTH[@]}" \ "${API}/zones/${CF_ZONE_ID}/dns_records?type=A&name=example.com" \ | jq -er '.result[0].id')"# ttl 1 means "automatic". While proxied it is academic: Cloudflare answers# from the edge, so the change takes effect as soon as this returns.cf -X PATCH "${API}/zones/${CF_ZONE_ID}/dns_records/${RECORD_ID}" \ --data "$(jq -nc --arg ip "$PROD_IP" '{ type: "A", name: "example.com", content: $ip, ttl: 1, proxied: true, comment: "cutover \($ENV.CI_COMMIT_SHORT_SHA)" }')"cf -X POST "${API}/zones/${CF_ZONE_ID}/purge_cache" \ --data '{"purge_everything": true}'Use a scoped API token, not the global API key. This job needs exactly
Zone:DNS:Edit and Zone:Cache Purge on one zone. The global key can delete
every zone on the account and cannot be narrowed at all, which makes it the
single worst secret to hand a CI runner. Check the token at
GET /client/v4/user/tokens/verify before you depend on it.
If the record is not proxied, the orange cloud switched off, then TTL is back in charge of how fast the change spreads. Drop it to 300 seconds a day ahead of the cutover so resolvers everywhere have expired their cached answer by the time you flip it, then put it back once you are happy.
Verify, then undo
A deploy you did not verify is a deploy your customers verify for you.
#!/usr/bin/env bashset -euo pipefailBASE="https://example.com"# Status, final URL after redirects, and a string only the new build renders.code="$(curl -sS -o /tmp/home.html -w '%{http_code}' -L "${BASE}/?cb=${CI_JOB_ID}")"[[ "$code" == "200" ]] || { echo "homepage returned ${code}"; exit 1; }grep -q 'data-build="'"${CI_COMMIT_SHORT_SHA}"'"' /tmp/home.html# A logged-out page should never be served with a session cookie attached.curl -sSI "${BASE}/" | grep -qi 'wordpress_logged_in' && { echo "session leak"; exit 1; }# The database really is pointing at production.ssh "$PROD_HOST" "cd $WP_PATH && wp option get siteurl" | grep -qx "$BASE"Note what the second check is for. A 200 tells you the web server is running. It does not tell you the new theme deployed, which is why the test greps for a build SHA the previous release could not have rendered.
Rollback is those steps in reverse, and it only works because step 1 of the
migration ran before anything else: restore backups/prod-*.sql.gz, PATCH the
A record back to the previous IP, purge again.
Orchestrating it with n8n
GitLab CI is very good at code. It is clumsier at the parts of a release that are neither code nor tests: waiting on a human, fanning out notifications, retrying one failed HTTP call without re-running a pipeline, and showing someone non-technical where the release actually stopped.
That is a fair job for n8n. The pipeline still builds and tests. n8n owns the release itself.
The load-bearing node is Wait. Set it to resume on a webhook and it hands back a URL to put in the approval message. The workflow then parks indefinitely at no cost until someone taps Approve. No polling. No runner sitting on the clock waiting for a person to come back from lunch.
Keep every secret in n8n's credential store, never in the workflow itself. Exported JSON references credentials by id rather than embedding them, which is exactly why the file above can sit in a public repository and still be worth reading.
Before you run it
- Snapshot before you touch anything. A backup taken after the import is a backup of the problem.
wp search-replace, neversed. Serialised data carries byte counts.- Pass
--skip-columns=guid, or you re-deliver the entire archive to every feed subscriber you have. - Scope the Cloudflare token to one zone.
- Check
.successon Cloudflare responses, not only the HTTP status. - Lower the DNS TTL a day early when the record is not proxied.
- Set
resource_groupso two releases cannot interleave. - Exclude uploads from rsync, and never pair
--deletewith a path that contains them. - Smoke-test a string only the new build renders.
The first one is the only one that matters if you get the rest wrong.
- wordpress
- gitlab-ci
- cloudflare
- n8n
- devops
Like what you see?
Let's build something great together.