Race Conditions in Parallel CI/CD Deploys: What Actually Breaks
Most of my day-to-day is WordPress infrastructure, not CI/CD pipelines. But I’ve already run into this exact class of problem in production, just on a different surface.
Where this started: WP-Cron pileups
A site was running wp cron event run --now on a per-minute system cron. Most runs finished in well under a minute. Occasionally one didn’t, usually when a queued event took longer than expected, and the next minute’s cron fired anyway. Now two wp cron processes were running against the same site at once.
That’s when things went bad. PHP-FPM workers filled up handling both runs, database queries backed up behind each other, and the server’s load climbed. It showed up in htop as a wall of PHP-FPM processes that wouldn’t drain, not a crash, just everything getting slower until the next overlapping cron made it worse.
The fix was a lock, not a smarter cron:
#!/usr/bin/env bash
LOCKFILE="/tmp/wp-cron-sitename.lock"
exec 200>"$LOCKFILE"
flock -n 200 || exit 0 # a previous run is still going, skip this one
wp cron event run --now --path=/var/www/sitename
# crontab, calling the wrapper instead of wp directly
* * * * * /usr/local/bin/wp-cron-wrapper.sh >/dev/null 2>&1
flock -n grabs an exclusive lock on file descriptor 200 without blocking. If another instance already holds it, this run exits immediately instead of piling on. As long as the lock file exists and is held, the next minute’s cron just skips, and the server never has two runs fighting over the same PHP-FPM pool.
That’s a one-line fix. But the underlying problem, two processes assuming exclusive access to shared state with nothing enforcing it, is not specific to WP-Cron. Once I recognized the pattern, I went looking for where else it shows up. CI/CD deploy pipelines are full of it.
The same problem, different surface
Parallel pipelines are sold as a speed win: run tests, builds, and deploys concurrently, ship faster. Fine, until two jobs touch the same shared state at the same time, the same way two cron runs touched the same PHP-FPM pool.
Here are the deploy-pipeline equivalents, and the config that closes each one.
1. Double migration on the same database
Two deploy jobs (a hotfix branch and a scheduled release) both run migrations against the same environment. If the migration tool doesn’t lock, both jobs see “migration not applied” at the same instant and both apply it. Best case: a duplicate-column error kills one job. Worst case: a non-idempotent migration runs twice and corrupts data.
Fix: wrap the migration in a Postgres advisory lock. Same idea as the cron lock file, just backed by the database instead of the filesystem.
-- acquire before running migrations, release after
SELECT pg_advisory_lock(918273);
-- ... run migration here ...
SELECT pg_advisory_unlock(918273);
A second job calling pg_advisory_lock(918273) blocks until the first releases it, instead of racing it.
2. Symlink swap race in atomic deploys
Classic release-directory pattern: build into releases/<timestamp>, then flip a current symlink to point at it. A single swap is atomic on the same filesystem. The race happens when two deploy jobs run the swap back to back. Job A points current at release-104. Job B, mid-flight, points it at release-103, which finished building slightly earlier but got scheduled later. You’ve now rolled backward without anyone noticing.
Fix: lock the swap step itself, and use mv -T so the symlink replace is a single atomic rename.
#!/usr/bin/env bash
set -euo pipefail
LOCKFILE="/var/lock/deploy-production.lock"
exec 200>"$LOCKFILE"
flock -n 200 || { echo "Deploy already in progress, exiting."; exit 1; }
RELEASE_DIR="/var/www/releases/$(date +%s)"
mkdir -p "$RELEASE_DIR"
# ... build/copy artifacts into $RELEASE_DIR ...
ln -sfn "$RELEASE_DIR" /var/www/current_tmp
mv -T /var/www/current_tmp /var/www/current
Same flock -n pattern as the WP-Cron wrapper, just guarding a symlink swap instead of a wp invocation.
3. Two runners fighting over the same environment
If your CI lets two workflow runs targeting the same environment execute simultaneously, whichever finishes last wins, regardless of which one is actually newer or correct. This is the pipeline-config version of not having a lock file at all.
Fix: serialize per environment, not per branch.
GitHub Actions:
jobs:
deploy:
concurrency:
group: deploy-production
cancel-in-progress: false
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
GitLab CI:
deploy:
stage: deploy
resource_group: production
script:
- ./deploy.sh
Jenkins (declarative, with the Lockable Resources plugin):
stage('Deploy') {
steps {
lock('production-deploy') {
sh './deploy.sh'
}
}
}
All three queue the second job instead of letting it run alongside the first, the same outcome flock -n gives the cron wrapper.
4. Blue-green cutover racing its own health check
Health check passes, traffic cutover starts, but a second deploy triggered in parallel (someone re-ran the pipeline, or a webhook fired twice) starts its own cutover before the first one finishes registering with the load balancer. Two cutovers interleave and traffic bounces between old and new instances mid-swap.
Fix: the cutover is a critical section. Gate it behind the same lock, and don’t let the health check pass the gate until the lock is held.
exec 200>/var/lock/cutover-production.lock
flock -n 200 || { echo "Cutover already in progress, exiting."; exit 1; }
wait_for_health_check "$NEW_TARGET" || exit 1
switch_load_balancer_target "$NEW_TARGET"
The lock scope has to cover both the health check and the actual switch, or two jobs can both pass the check and still race the switch itself.
5. Shared build cache corruption
Two parallel build jobs write to the same cache key (npm/yarn, Docker layers, Go modules) at the same time. Corrupted or partially-written cache doesn’t fail loudly, it just produces a build that’s subtly wrong or flaky later.
Fix: scope the cache key so concurrent jobs never share a write target.
# GitHub Actions example
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ github.ref_name }}-${{ hashFiles('package-lock.json') }}
Keying on branch name plus a lockfile hash means two branches building at once never write to the same cache entry.
The actual rule
Every case above, cron or pipeline, is the same root cause: two processes assumed exclusive access to shared state and nothing enforced it. The fix is never “make it faster,” it’s “make the critical section explicit and lock it.” I learned that on a WordPress server fighting itself over per-minute cron jobs. It applies just as directly to a deploy pipeline fighting itself over a database migration or a symlink.