Skip to content
CH SCShop classhosting · advanced · ~240 min · 8 steps

How to build a real deployment pipeline for a CMS site

From cowboy FTP to git-push-to-production: atomic releases, build steps in CI, database updates in the right order, health checks, and a rollback you've actually rehearsed.

August 10, 2026 · by Dane Petersen

The difference between a site you maintain confidently and one you touch with dread is usually the deploy. If shipping a change means FTPing files and holding your breath, every update inherits that dread — which is how updates stop happening. A real pipeline makes deploying boring: push to a branch, the same steps run in the same order every time, and if something’s wrong you’re back on the previous release in one command. This lesson builds that pipeline for a Drupal or WordPress site on a plain server with GitHub Actions — and the shape transfers to GitLab, Bitbucket, or any host. (If you’re on Pantheon or a similar managed platform, they’ve built half of this for you — this lesson is for everyone paying for a server instead.)

Prerequisites are real here: the site’s code in git with a sane .gitignore, SSH access, and the discipline to stop editing files on the server once this exists — the pipeline only protects what flows through it.

Draw the line between code, content, and config

Everything on the server belongs to exactly one regime. Code (core, modules/plugins, themes, your customizations) flows forward only: laptop → git → CI → server, never edited in place. Content (database, uploads) flows backward only: production → staging/local for testing, never the reverse outside a launch. Configuration is the tricky middle: Drupal has a real answer (config export — config is code); WordPress stores config in the database, so document the settings that matter and treat plugin-state changes as production events. Most deployment horror stories are one of these arrows pointed the wrong direction — write the three regimes down for your site before building anything.

Structure the server for atomic releases

Uploading files into a live docroot means visitors ride along mid-copy — half-old, half-new, briefly broken. The fix is the releases-and-symlink layout:

/var/www/site/
  releases/
    20260810-1432/     # each deploy is a complete copy
    20260809-0910/
  shared/
    files/             # uploads live OUTSIDE releases
    .env               # secrets likewise
  current -> releases/20260810-1432

The web server’s docroot points at current, which is a symlink. Each release directory holds a full copy of the code with symlinks into shared/ for uploads and secrets. Deploying = build the new release directory completely, then flip one symlink — atomic, and instantly reversible. Set it up once by hand so you understand it; the pipeline automates it forever after.

Put the build where it belongs: in CI, not on the server

Production servers should receive built artifacts, not run composer and npm under load. The GitHub Actions skeleton (.github/workflows/deploy.yml):

name: Deploy
on:
  push:
    branches: [main]
concurrency: production   # two pushes never deploy simultaneously
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: |
          composer install --no-dev --optimize-autoloader
          # npm ci && npm run build   # if the theme has a build step
      - name: Ship the release
        run: |
          RELEASE=$(date +%Y%m%d-%H%M%S)
          rsync -az --delete --exclude='.git' ./ deploy@SERVER:/var/www/site/releases/$RELEASE/
          ssh deploy@SERVER "ln -sfn /var/www/site/shared/files /var/www/site/releases/$RELEASE/web/sites/default/files"
          echo "RELEASE=$RELEASE" >> $GITHUB_ENV

Create a dedicated deploy user on the server with an SSH key held in GitHub’s repo secrets — never your personal key. The concurrency line matters more than it looks: overlapping deploys corrupt release directories in ways that take an evening to understand.

Sequence the database work correctly

The step everyone gets wrong. Code that expects the new schema must not serve requests before the update runs — but the update can’t run before the new code exists. The safe order: new release fully in place → safety snapshot → flip the symlink → run updates → clear caches. Appended to the workflow:

      - name: Activate and update
        run: |
          ssh deploy@SERVER "
            cd /var/www/site &&
            mysqldump SITE_DB | gzip > pre-deploy-$RELEASE.sql.gz &&
            ln -sfn releases/$RELEASE current &&
            cd current && drush updatedb -y && drush cache:rebuild
          "

(WordPress: wp core update-db and your cache flush.) For most sites this window is milliseconds of riskless overlap. If a specific migration is genuinely breaking, that deploy gets a maintenance-mode wrap — drush state:set system.maintenance_mode 1 before the flip, off after — which is a deliberate decision per deploy, not a default.

Add the health check that answers “did it work?”

A pipeline that ends with “probably fine” hasn’t ended:

      - name: Health check
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://example.com/)
          BODY=$(curl -s https://example.com/ | grep -c "FOOTER_MARKER" || true)
          if [ "$STATUS" != "200" ] || [ "$BODY" -lt 1 ]; then
            echo "Health check failed (status $STATUS)"; exit 1
          fi

Check the status code and a content marker — a 200 that serves a white-screen error page is the classic false pass (a footer string is a fine marker). Failure exits red, and the previous release still exists, which brings us to the part that makes the whole pipeline trustworthy.

Rehearse the rollback before you need it

Rollback is the feature you’re really building; everything else is scaffolding around it:

ssh deploy@SERVER "cd /var/www/site && ln -sfn releases/PREVIOUS current && cd current && drush cache:rebuild"

One symlink flip and you’re on the prior code. The nuance is the database: schema changes don’t un-run, which is why every deploy takes that pre-deploy snapshot — a code rollback plus that dump restores the exact pre-deploy world when it comes to it. Now rehearse: deploy a trivial change, roll it back on purpose, confirm the site, roll forward again. Ten minutes, once — the difference between a rollback plan and a rollback guess at 5 p.m. on a Friday. Keep the last five releases and prune the rest in a cron.

Wire the daily habits that keep it honest

The pipeline works; now protect it. Protect main so nothing lands without a pull request (even solo — future-you reviews present-you). Add the checks you already know to CI so a broken build never reaches the server (linting, config validation, the update sequence tested against a staging copy). Alert on deploy failures somewhere you actually look. And enforce the founding rule socially: the day someone edits a file directly on the server, the pipeline stops being the truth — our own infrastructure doc exists because taping the rig is part of the rig.

Verify the whole loop end to end

The graduation exercise: make a visible one-line change, push it, watch CI build → ship → flip → update → health-check, confirm it live, then roll it back and confirm that too. When that loop is boring, you’ve built the thing — deploys stop being events and start being Tuesday. If your team wants this built for them, with the monitoring wired into someone else’s morning instead of yours — you know where the flare goes.

That's the lesson. Back to the shop for more — or if this is the chore your organization never gets to,that's literally what we're for.