Back to Blog

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.

PublishedJuly 27, 2026
Read9 min read
AuthorYordan Kamenarov
Cover reading 'Shipping WordPress from a pipeline', with the steps build, test, migrate, cutover and verify.

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.

.gitlab-ci.ymlyaml
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.sh

needs: 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:

wp_options, theme_mods rowphp
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.

ci/migrate-db.shbash
#!/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.

ci/cloudflare-cutover.shbash
#!/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.

ci/smoke-test.shbash
#!/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.

WordPress Release: DB Migration + Cloudflare Cutover
1. Gate the triggerGitLab posts every pipeline event. Drop anything that is not main with status: success before doing real work.
2. Human approvalThe Wait node pauses on a resume URL. Nothing touches production until someone taps Approve.
3. Database migrationSnapshot production FIRST — that snapshot is the rollback. Then export staging, import, and search-replace the domain with WP-CLI so serialised data stays valid.
4. DNS cutoverLook the record up by name, PATCH it, then purge. A scoped Zone:DNS:Edit token — never the global API key.
5. Verify, or undoA deploy that is not verified is a deploy you will hear about from a customer. Fail the smoke test and the same run restores the snapshot and repoints DNS.
GitLab Pipeline Hook
Main + Passed?
Ask For Go-Ahead
Wait For Approval
Snapshot Production DB
Export Staging DB
Import + Search-Replace
Find DNS Record
Point A Record At Prod
Purge Cloudflare Cache
Smoke Test Homepage
Healthy?
Announce Release
Roll Back DB + DNS
Page On-Call

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

  1. Snapshot before you touch anything. A backup taken after the import is a backup of the problem.
  2. wp search-replace, never sed. Serialised data carries byte counts.
  3. Pass --skip-columns=guid, or you re-deliver the entire archive to every feed subscriber you have.
  4. Scope the Cloudflare token to one zone.
  5. Check .success on Cloudflare responses, not only the HTTP status.
  6. Lower the DNS TTL a day early when the record is not proxied.
  7. Set resource_group so two releases cannot interleave.
  8. Exclude uploads from rsync, and never pair --delete with a path that contains them.
  9. Smoke-test a string only the new build renders.

The first one is the only one that matters if you get the rest wrong.

Like what you see?

Let's build something great together.

Get in Touch