Bojan Josifoski < wp developer />

The Git Kinsta Pipeline: How I Built a Safe, Selective Deployment System

November 9, 2025 • Bojan

The Problem: Deploying WordPress Multisite Without Breaking Production

Multisite deployments are easy to mess up. One bad sync and you overwrite uploads, nuke a plugin, or serve stale cache to thousands of users. I wanted a pipeline that only deploys what I own, always takes a backup, supports dry runs, clears cache, and never runs two jobs at once.

Here is the system that does exactly that.


The Stack


Workflow Overview

  1. Manual trigger with dry run
  2. Concurrency guard
  3. SSH setup and host verification
  4. Pre deployment backup with rotation
  5. rsync with a strict allowlist
  6. Post deployment cache clear

That is it. Simple, safe, repeatable.


1) Manual Trigger + Dry Run

Manual runs prevent accidents. Dry run builds trust.

name: Deploy selected wp-content paths to Kinsta (manual only, with backup)

on:
  workflow_dispatch:
    inputs:
      dry_run:
        description: "Dry run (no remote changes)"
        required: true
        default: "true"

2) Concurrency Guard

Only one deployment at a time. If someone clicks again, the latest run wins.

concurrency:
  group: deploy-wp-content
  cancel-in-progress: true

3) SSH Setup and Verify

Keys live in GitHub Secrets. The host key is pinned. We verify connectivity before touching anything.

- name: Setup SSH Agent
  uses: webfactory/[email protected]
  with:
    ssh-private-key: ${{ secrets.KINSTA_SSH_PRIVATE_KEY }}

- name: Add Kinsta host to known_hosts
  run: |
    mkdir -p ~/.ssh
    ssh-keyscan -p ${{ secrets.KINSTA_SSH_PORT }} -H ${{ secrets.KINSTA_SSH_HOST }} >> ~/.ssh/known_hosts

- name: Verify SSH connectivity
  run: |
    ssh -p ${{ secrets.KINSTA_SSH_PORT }} ${{ secrets.KINSTA_SSH_USER }}@${{ secrets.KINSTA_SSH_HOST }} "echo Connected"

4) Automatic Backup With Rotation

Every real deploy creates a timestamped tarball of wp-content. Last three are kept. Dry run skips backup.

- name: Backup remote wp-content (keep last 3)
  if: ${{ github.event.inputs.dry_run != 'true' }}
  env:
    KINSTA_PATH: ${{ secrets.KINSTA_PATH }}
  run: |
    ssh -p ${{ secrets.KINSTA_SSH_PORT }} ${{ secrets.KINSTA_SSH_USER }}@${{ secrets.KINSTA_SSH_HOST }} \
      "mkdir -p ${KINSTA_PATH}/_backups && \
       tar -C ${KINSTA_PATH} -czf ${KINSTA_PATH}/_backups/wp-content-$(date +%Y%m%d%H%M%S).tgz wp-content && \
       ls -1t ${KINSTA_PATH}/_backups/wp-content-*.tgz | tail -n +4 | xargs -r rm -f"

Rollback is a single tar command away.


5) The Allowlist Filter

Order matters. Include paths first, then exclude everything else. This deploys only my theme, four plugins, and three mu plugins. It never touches uploads, cache, or third party code.

- name: Deploy (allowlist; dry_run=${{ github.event.inputs.dry_run }})
  run: |
    cat > rsync-filter.txt <<'EOF'
    # ---- THEMES (only sampleflows) ----
    + /themes/
    + /themes/sampleflows/
    + /themes/sampleflows/**
    - /themes/**

    # ---- PLUGINS (only these 4) ----
    - /plugins/paddle-multisite-integration/vendor/**
    + /plugins/
    + /plugins/crm-oauth-tester/
    + /plugins/crm-oauth-tester/**
    + /plugins/direct-oauth-sso/
    + /plugins/direct-oauth-sso/**
    + /plugins/email-template-manager/
    + /plugins/email-template-manager/**
    - /plugins/email-template-manager/templates/**   # protect live user templates
    + /plugins/paddle-multisite-integration/
    + /plugins/paddle-multisite-integration/**
    - /plugins/**

    # ---- MU-PLUGINS (only these 3 files) ----
    + /mu-plugins/
    + /mu-plugins/multisite-session-security.php
    + /mu-plugins/samplehq-plan-switcher.php
    + /mu-plugins/session-security.php
    - /mu-plugins/**

    # ---- NEVER sync these ----
    - /uploads/**
    - /upgrade/**
    - /cache/**
    - /wflogs/**

    # ---- Generic excludes ----
    - /.git/**
    - /.github/**
    - **/node_modules/**
    - **/vendor/**
    - **/dist/**
    - *.log

    # Ignore everything else at wp-content root
    - /*
    - **
    EOF

    SRC="wp-content/"
    DEST="${{ secrets.KINSTA_SSH_USER }}@${{ secrets.KINSTA_SSH_HOST }}:${{ secrets.KINSTA_PATH }}/wp-content/"

    if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
      rsync -n -avz --itemize-changes --delete --delete-after \
        --exclude='plugins/email-template-manager/templates/' \
        --filter="merge rsync-filter.txt" \
        -e "ssh -p ${{ secrets.KINSTA_SSH_PORT }}" \
        "$SRC" "$DEST"
    else
      rsync    -avz --itemize-changes --delete --delete-after \
        --exclude='plugins/email-template-manager/templates/' \
        --filter="merge rsync-filter.txt" \
        -e "ssh -p ${{ secrets.KINSTA_SSH_PORT }}" \
        "$SRC" "$DEST"
    fi

Why rsync works here


6) Post Deployment Cache Clear

Changes should be visible immediately. No guesswork.

- name: Clear all Kinsta cache
  if: ${{ github.event.inputs.dry_run != 'true' }}
  env:
    KINSTA_PATH: ${{ secrets.KINSTA_PATH }}
  run: |
    ssh -p ${{ secrets.KINSTA_SSH_PORT }} ${{ secrets.KINSTA_SSH_USER }}@${{ secrets.KINSTA_SSH_HOST }} \
      "cd ${KINSTA_PATH} && \
       (wp kinsta cache purge --all --allow-root 2>/dev/null || echo 'Kinsta cache purge command not available') && \
       (wp cache flush --allow-root 2>/dev/null || echo 'Standard WP cache flush not available') && \
       echo 'Cache clearing completed'"

Git Hygiene That Matches Deploy Hygiene

A selective .gitignore mirrors the allowlist. The repo tracks only code I own. No uploads, no vendor, no node modules, no build artifacts.

uploads/
upgrade/
cache/
wflogs/
**/node_modules/
**/vendor/
**/dist/
plugins/*
!plugins/crm-oauth-tester/**
!plugins/direct-oauth-sso/**
!plugins/email-template-manager/**
plugins/email-template-manager/templates/
!plugins/paddle-multisite-integration/**
themes/*
!themes/sampleflows/**
themes/sampleflows/_tailadmin/
themes/sampleflows/*.md

Secrets

All sensitive values are GitHub Secrets. Keys are rotated. No secrets in code.

KINSTA_SSH_PRIVATE_KEY
KINSTA_SSH_HOST
KINSTA_SSH_PORT
KINSTA_SSH_USER
KINSTA_PATH

How I Use It

  1. Run with dry run set to true and scan the diff
  2. Run again with dry run set to false
  3. The action makes a backup, deploys only allowlisted paths, clears cache
  4. Validate on production, and rollback quickly if needed

Time to deploy went from hours to minutes. Risk went from guesswork to boring.


Edge Cases I Solved


Lessons Learned


TLDR

Build a deployment that refuses to touch anything you do not own. Always back up first. Dry run everything. Clear cache automatically. Guard concurrency. Keep secrets out of the repo. You will sleep better.

About the Author

About the Author

I’m Bojan Josifoski - I’m a WordPress systems engineer who developed and maintained a proprietary WordPress-based framework used by U.S. financial institutions between 2016 and 2025.

← Back to Blog