The Git Kinsta Pipeline: How I Built a Safe, Selective Deployment System
November 9, 2025 • Bojan
November 9, 2025 • Bojan

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.
That is it. Simple, safe, repeatable.
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"
Only one deployment at a time. If someone clicks again, the latest run wins.
concurrency:
group: deploy-wp-content
cancel-in-progress: true
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"
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.
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
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'"
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
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
Time to deploy went from hours to minutes. Risk went from guesswork to boring.
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.

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.