Skip to content

Enterprise7 min read

WordPress CI/CD with GitHub Actions: tests, builds and safe deploys

Build a WordPress CI CD GitHub Actions pipeline that tests code, builds assets and Composer dependencies, deploys atomically, and rolls back safely.

By Hamza Ahmad AslamWordPress VIP, Performance & Full-Stack Engineer

Diagram: Repository, then Tests, then Production build, then Release directory, then Production
On this page
  1. Decide what belongs in the repository
  2. Build a WordPress CI CD GitHub Actions pipeline that blocks bad releases
  3. Run PHPCS and tests before merge
  4. Build once and pass the artifact forward
  5. Build production files before the deploy job
  6. Deploy WordPress with GitHub Actions through release directories
  7. Switch the symlink only after the upload succeeds
  8. Replace SSH with the host's deployment interface when needed
  9. Protect deployment secrets, environments and approvals
  10. Keep rollback separate from database rollback
  11. What to do next
  12. Frequently asked questions

Short answer: A WordPress CI CD GitHub Actions pipeline should test every pull request, build one repeatable release artifact, and deploy only code after approval. Keep uploads, secrets, and the database outside the repository, then switch releases with a symlink so rollback does not require copying files over the live site.

Decide what belongs in the repository

Treat the repository as the source for deployable code and the instructions needed to build it. Do not treat Git as a copy of production.

A typical application repository can look like this:

.
├── .github/
│   └── workflows/
│       └── ci-deploy.yml
├── public/
│   └── wp-content/
│       ├── mu-plugins/
│       ├── plugins/
│       └── themes/
│           └── site-theme/
├── tests/
├── composer.json
├── composer.lock
├── package.json
├── package-lock.json
├── phpcs.xml
├── phpunit.xml
└── .nvmrc

This layout is an example. Your document root may have another name, or Composer may place WordPress and its packages differently.

Commit your custom themes, plugins, mu-plugins, build configuration, dependency manifests, lock files, tests, and workflow files. The lock files matter because CI needs to build from the dependency versions reviewed with the change.

Keep runtime data out of Git. That includes production uploads, database dumps, private keys, API credentials, environment files containing secrets, and wp-config.php when it contains environment-specific credentials.

Uploads should live in persistent storage shared by every release. If your site puts uploads somewhere other than the usual wp-content location, apply the same rule to that location.

The database stays where the application runs. Routine deployments must not import or replace it. Schema changes need their own versioned migration path.

Build a WordPress CI CD GitHub Actions pipeline that blocks bad releases

A useful pipeline has separate concerns. Pull requests run checks. CI builds the deployable files. Only an accepted change on the deployment branch can reach production.

GitHub documents the available job, event, permission, environment, dependency, and concurrency fields in its workflow syntax (opens in a new tab).

Run PHPCS and tests before merge

Running PHPCS in GitHub Actions makes coding-standard failures visible before code reaches the deployment branch. Keep the ruleset in the repository so local runs and CI use the same rules.

The maintained PHP_CodeSniffer project (opens in a new tab) supports supplying a ruleset with --standard. The command below also supplies the repository root as the path to inspect.

Tests should fail the workflow through PHPUnit's exit status. This example assumes your committed phpunit.xml and test bootstrap already describe how the suite starts WordPress or isolates the code being tested.

The current WordPress requirements (opens in a new tab) recommend PHP 8.3 or greater, so the example uses PHP 8.3. Your CI should also cover the PHP version that production runs.

Build once and pass the artifact forward

The WordPress CI CD GitHub Actions workflow below has quality, test, build, and deploy jobs. The build job creates the files that the deploy job receives, rather than rebuilding on the production server.

name: CI and deploy

on:
  pull_request:
  push:
    branches:
      - main

permissions:
  contents: read

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
          tools: composer:v2

      - name: Install PHP development dependencies
        run: composer install --no-interaction --no-progress --prefer-dist

      - name: Run PHPCS
        run: vendor/bin/phpcs --standard=phpcs.xml .

  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
          tools: composer:v2

      - name: Install PHP development dependencies
        run: composer install --no-interaction --no-progress --prefer-dist

      - name: Run PHPUnit
        run: vendor/bin/phpunit

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
          tools: composer:v2

      - uses: actions/setup-node@v7
        with:
          node-version-file: '.nvmrc'
          package-manager-cache: false

      - name: Install production PHP dependencies
        run: composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader

      - name: Install JavaScript dependencies
        run: npm ci

      - name: Build front-end assets
        run: npm run build

      - name: Assemble release
        run: |
          mkdir -p release
          cp -a public release/public
          cp -a vendor release/vendor

      - uses: actions/upload-artifact@v7
        with:
          name: wordpress-release-${{ github.sha }}
          path: release/

  deploy:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: [quality, tests, build]
    runs-on: ubuntu-latest
    environment: production
    concurrency: production

    env:
      DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
      DEPLOY_USER: ${{ vars.DEPLOY_USER }}
      DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
      DEPLOY_ROOT: ${{ vars.DEPLOY_ROOT }}

    steps:
      - uses: actions/download-artifact@v8
        with:
          name: wordpress-release-${{ github.sha }}
          path: release

      - name: Configure SSH
        run: |
          mkdir -p "$HOME/.ssh"
          printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > "$HOME/.ssh/id_ed25519"
          printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > "$HOME/.ssh/known_hosts"
          chmod 600 "$HOME/.ssh/id_ed25519"

      - name: Upload and switch release
        run: |
          set -eu

          RELEASE="$DEPLOY_ROOT/releases/$GITHUB_SHA"
          TARGET="$DEPLOY_USER@$DEPLOY_HOST"

          ssh -i "$HOME/.ssh/id_ed25519" -p "$DEPLOY_PORT" \
            -o StrictHostKeyChecking=yes \
            "$TARGET" "mkdir -p '$RELEASE'"

          rsync -az --delete \
            -e "ssh -i $HOME/.ssh/id_ed25519 -p $DEPLOY_PORT -o StrictHostKeyChecking=yes" \
            release/ "$TARGET:$RELEASE/"

          ssh -i "$HOME/.ssh/id_ed25519" -p "$DEPLOY_PORT" \
            -o StrictHostKeyChecking=yes \
            "$TARGET" "
              ln -s '$DEPLOY_ROOT/shared/uploads' '$RELEASE/public/wp-content/uploads'
              ln -s '$DEPLOY_ROOT/shared/wp-config.php' '$RELEASE/wp-config.php'
              ln -sfnT '$RELEASE' '$DEPLOY_ROOT/current.next'
              mv -Tf '$DEPLOY_ROOT/current.next' '$DEPLOY_ROOT/current'
            "

The example assumes the deployable web root is public/, Composer dependencies are needed from the project-level vendor/ directory, and the remote host uses GNU coreutils. Adapt those paths to your application rather than copying them unchanged.

For readability, the workflow uses action major-version tags. A production repository can pin actions to reviewed full commit SHAs so a moving tag cannot change the code executed by the workflow.

Build production files before the deploy job

For a Composer WordPress deploy, let CI produce the production dependency tree. Do not run dependency resolution while visitors are using the release.

composer install reads the committed lock file when one exists. The production build adds --no-dev so development packages are left out, while --optimize-autoloader prepares the production autoloader.

The quality and test jobs need development dependencies because tools such as PHPCS and PHPUnit are commonly declared there. That is why those jobs use a normal install while the release build does not.

The same rule applies to JavaScript. npm ci installs from the lock file, then the project's build script creates the CSS, JavaScript, or other generated assets. The artifact needs those generated files, not node_modules unless your runtime specifically depends on it.

Building once also means the deploy job receives the exact artifact produced earlier in that workflow run. A failed production upload does not trigger a fresh Composer or npm resolution.

Deploy WordPress with GitHub Actions through release directories

To deploy WordPress with GitHub Actions safely, upload into a new release directory rather than synchronizing files directly over the live document root.

A WordPress deployment pipeline can keep this server layout:

/srv/site/
├── current -> /srv/site/releases/<active-release>
├── releases/
│   ├── <release-a>/
│   └── <release-b>/
└── shared/
    ├── uploads/
    └── wp-config.php

Each Git commit SHA gives the workflow a unique release path. rsync --delete is therefore limited to that release directory. It does not delete files from the live shared uploads directory.

The final commands create current.next, then rename it over current. Keep both links on the same filesystem. On GNU systems, mv -T treats the destination as the link itself rather than following a link to a directory.

Your web server points at $DEPLOY_ROOT/current/public. Requests continue using the previous release while the new files are transferred. The switch happens only after the upload and shared links are ready.

After the symlink switch, the WordPress OPcache settings guide explains revalidation and cache resets after deploys, because PHP-FPM can keep serving cached bytecode from the previous release until OPcache revalidates the files or is reset.

An atomic switch is one part of a zero downtime WordPress deploy. Old and new application code must also tolerate the database schema that exists while the release changes.

Replace SSH with the host's deployment interface when needed

Managed WordPress platforms may not provide SSH access that fits this release pattern. Keep the test and build stages, then replace only the deployment job with the host's supported Git, API, CLI, or deployment service.

WordPress VIP supports CI/CD build flows and documents GitHub Actions in its WordPress VIP CI/CD documentation (opens in a new tab). Its deployment model should be used instead of adding an unsupported rsync layer.

At Gallery Media Group I shipped changes on WordPress VIP through the team's review and deployment process.

Protect deployment secrets, environments and approvals

Create a GitHub production environment for the deploy job. GitHub's environments documentation (opens in a new tab) confirms that protection rules can gate a job before it runs or gains access to that environment's secrets.

Put private material such as the SSH key in environment secrets. Host name, user, port, and deployment path are configuration rather than credentials, so environment variables are a better fit for them.

A known_hosts entry is public information, but its trust matters. Obtain and verify the server host key outside the deployment session. Do not disable host-key checking merely to make unattended SSH connect.

The workflow gives the GitHub token only contents: read. Pull-request jobs never reference deployment secrets. The deploy job runs only after the quality, test, and build jobs succeed on a push to main.

concurrency: production also prevents two jobs in that concurrency group from deploying at the same time. Add required reviewers and deployment-branch restrictions to the production environment when your GitHub plan and repository configuration support them.

Keep rollback separate from database rollback

An atomic release layout makes code rollback small. Point current at a previous release rather than copying old files back over the live tree.

For a GNU/Linux host, the same switch pattern can be used:

set -eu

DEPLOY_ROOT=/srv/site
PREVIOUS_RELEASE="$DEPLOY_ROOT/releases/previous-release"

ln -sfnT "$PREVIOUS_RELEASE" "$DEPLOY_ROOT/current.next"
mv -Tf "$DEPLOY_ROOT/current.next" "$DEPLOY_ROOT/current"

That code rollback is safe only while the previous code can still use the current database schema.

Handle schema changes as ordered, versioned migrations owned by the application. Record successful migrations so the same change is not applied repeatedly.

Prefer changes that remain compatible with the previous release. Add a new table, column, or data shape before removing the old one. Code can move to the new structure first, then a later release can remove obsolete structures after older code is gone.

Do not place a production SQL dump in Git, and do not make database replacement part of the normal deploy job. A destructive migration needs a separate recovery plan because changing the code symlink cannot restore lost data.

What to do next

Start with pull-request checks, then make CI build the exact production artifact. Add a staging environment, test the symlink switch and rollback there, then put the production job behind a protected GitHub environment.

For VIP releases, compare the pipeline with the WordPress VIP development guide. If CI/CD is part of a platform move, use the WordPress VIP migration checklist to keep deployment work separate from migration work. For help implementing or reviewing the release process, the WordPress VIP development service covers that engineering work.

Frequently asked questions

Should I commit wp-content/uploads to Git?

No. Production uploads are runtime content and should persist outside individual code releases. Link or mount that persistent storage into each release instead of copying media through Git.

Should GitHub Actions deploy the WordPress database?

A routine code deployment should not replace the WordPress database. Handle required schema changes with ordered versioned migrations, while content, settings, customers, and orders remain in the live database.

Can GitHub Actions deploy only a WordPress theme or plugin?

Yes, if that package is independent and your hosting model supports isolated package releases. If themes, plugins, Composer packages, and built assets depend on each other, a single tested application artifact gives you a clearer release boundary.

How do I roll back a failed WordPress deployment?

With release directories, point the live symlink back to the previous tested release. Code rollback remains simple while database migrations are backward-compatible; a destructive schema or data change needs its own planned recovery path.

Enjoyed this? Get the next article by email.

Occasional, useful posts. No spam — unsubscribe anytime.