The Git Kinsta Pipeline: How I Built a Safe, Selective Deployment System
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

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
- GitHub Actions for CI
- SSH for transport
- rsync for selective sync
- An allowlist that refuses to touch anything outside my control
Workflow Overview
- Manual trigger with dry run
- Concurrency guard
- SSH setup and host verification
- Pre deployment backup with rotation
- rsync with a strict allowlist
- 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/ssh-agent@v0.9.0
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
- Only sends changed files
- Dry run tells you exactly what will change
- Preserves permissions and timestamps
- Deletes stragglers after transfer completes
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
- Run with dry run set to true and scan the diff
- Run again with dry run set to false
- The action makes a backup, deploys only allowlisted paths, clears cache
- 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
- Protecting user edited templates in production
- Excluding vendor so Composer manages it on the server if needed
- Cancel in progress so two clicks do not collide
- Backup rotation so storage does not fill up
Lessons Learned
- Includes before excludes in rsync filters or you will deploy the wrong thing
- Dry run is not optional
- Backups are non negotiable
- Selective deploys are the only sane way to handle Multisite
- Cache must be cleared as part of the job, not after
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.
Bojan