Zero-downtime WooCommerce deployments: Kinsta API and GitHub Actions vs manual staging pushes
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
Let GitHub Actions copy only your own plugin and theme folders to Kinsta staging over SSH. Validate staging through the Kinsta API and with smoke tests: the pages load, a real add-to-cart works through the Store API, and a build marker matches the commit. Then promote with the API's push endpoint set to files only (push_db false, specific folders), after a tagged backup. Never use the default push on a live store: it copies the database too, which replaces every order placed since staging was made. Kinsta says a push has a couple of seconds of downtime at the end, so this is near-zero, not zero. If the live smoke test fails, roll back by redeploying the previous commit, not by restoring the backup, which would take the orders with it.
On most WooCommerce stores, a deploy means opening the staging site, clicking "Push to live" and hoping. That push copies more than code. This guide replaces the button with a pipeline: tests in GitHub Actions, your code copied to Kinsta staging, checks run against staging through the Kinsta API, a backup, a files-only push to live, and the same checks on live. Orders placed in the meantime stay where they are.
We wrote the deploy script and tested it against a mock Kinsta API built from Kinsta's published OpenAPI spec (version 1.114.0). The mock checks every request body against the spec and rejects unknown fields. It answers operations the way the spec documents: 202 while running, 200 when done, 500 on failure. The smoke tests ran against a real WordPress 7.1.3 and WooCommerce 11.2.1 store. All 22 checks pass. The workflow passed actionlint 1.7.12 with shellcheck 0.11.0 with no findings. We didn't run it against a real Kinsta account or in GitHub's runners. Kinsta's behavior during a push comes from its documentation (October 2026).
1. The short answer
| Question | Answer |
|---|---|
| What gets deployed? | Only your own folders, e.g. wp-content/plugins/store-core and wp-content/themes/store-child |
| How does it reach staging? | rsync over SSH from GitHub Actions |
| How does it reach live? | The Kinsta API's push endpoint with push_db: false and SPECIFIC_FILES |
| What's checked? | Both environments exist and aren't blocked, PHP matches, pages load, Store API add-to-cart works, the build marker equals the commit |
| Who says go? | A GitHub environment with required reviewers between staging and live |
| Downtime | Kinsta: "a couple of seconds" at the end of a push. Near-zero, not zero. |
| Rollback | Run the workflow on the previous commit. The backup is the last resort, because restoring it rewinds orders. |
2. What the push button really copies
Kinsta's push endpoint, PUT /sites/{site_id}/environments, has these defaults in the API spec: push_db: true, push_files: true, push_files_option: ALL_FILES and run_search_and_replace: true. Leave a field out and you get the full copy. Kinsta's documentation adds what happens around it:
- Orders: "MyKinsta does not distinguish between new and old orders." A database push replaces live's orders, customers and stock levels with staging's, which are as old as the staging copy.
- Environment settings always go along: redirects, geolocation, PHP and Nginx settings from the source overwrite the target, even on a files-only push. A staging site on PHP 8.4 moves live to 8.4. The script below refuses to push when the two differ.
- Backup: Kinsta backs up the target before the push. The script takes its own, tagged with the commit, so it's easy to find.
- Downtime: "a couple of seconds" in the final stage.
- SSH: a push regenerates the host key, so a pinned
known_hostsbreaks on the next deploy.
| Manual push in MyKinsta | This pipeline | |
|---|---|---|
| Database | Copied unless you choose otherwise every time | Never: push_db: false in code |
| Files | Usually all of them, including uploads | Only the folders in DEPLOY_PATHS; uploads and wp-content are refused |
| What's on staging | Whatever someone last changed there | The commit that passed CI, proven by build.txt |
| Checks | Someone clicks around | The same smoke tests on staging and on live |
| Record | MyKinsta's activity log | A workflow run per commit, with the approver |
3. The deploy lifecycle
4. The workflow
Repository variables: KINSTA_SITE_ID, STAGING_URL, LIVE_URL, the staging SSH host, port and user (from MyKinsta, Info tab), and optionally POST_PUSH_WP_CLI. Secrets: KINSTA_API_KEY and KINSTA_STAGING_SSH_KEY. Create the API key under Company settings, API Keys, with an expiry date, from a user whose role only needs this site.
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
workflow_dispatch: # also how you roll back: run it on the previous commit
concurrency:
group: deploy-production # one deploy at a time; a second push waits instead of racing
cancel-in-progress: false
permissions:
contents: read
env:
DEPLOY_PATHS: wp-content/plugins/store-core,wp-content/themes/store-child
KINSTA_SITE_ID: ${{ vars.KINSTA_SITE_ID }}
STAGING_URL: ${{ vars.STAGING_URL }}
LIVE_URL: ${{ vars.LIVE_URL }}
DEPLOY_SHA: ${{ github.sha }}
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3" # the PHP version live runs
tools: composer
- run: composer install --no-interaction --prefer-dist
- name: Lint every PHP file
run: find wp-content/plugins/store-core wp-content/themes/store-child -name '*.php' -print0 | xargs -0 -n1 -P4 php -l
- run: vendor/bin/phpunit
staging:
needs: test
runs-on: ubuntu-24.04
environment: staging
steps:
- uses: actions/checkout@v5
- name: Production build
run: |
composer install --no-dev --optimize-autoloader --no-interaction --working-dir=wp-content/plugins/store-core
echo "$DEPLOY_SHA" > wp-content/plugins/store-core/build.txt
- name: Copy the folders to staging over SSH
env:
SSH_KEY: ${{ secrets.KINSTA_STAGING_SSH_KEY }}
SSH_HOST: ${{ vars.KINSTA_STAGING_SSH_HOST }}
SSH_PORT: ${{ vars.KINSTA_STAGING_SSH_PORT }}
SSH_USER: ${{ vars.KINSTA_STAGING_SSH_USER }}
run: |
install -m 700 -d ~/.ssh
install -m 600 /dev/null ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
# Kinsta regenerates the host key on every push, so it is fetched fresh on each run
ssh-keyscan -p "$SSH_PORT" "$SSH_HOST" > ~/.ssh/known_hosts
for p in ${DEPLOY_PATHS//,/ }; do
rsync -az --delete --exclude=.git -e "ssh -p $SSH_PORT" "$p/" "$SSH_USER@$SSH_HOST:public/$p/"
done
- name: Validate staging through the Kinsta API and smoke tests
env:
KINSTA_API_KEY: ${{ secrets.KINSTA_API_KEY }}
run: python3 .github/deploy/kinsta_deploy.py validate
production:
needs: staging
runs-on: ubuntu-24.04
environment: production # add required reviewers here for a human "go"
steps:
- uses: actions/checkout@v5
- name: Backup, files-only push, cache clear, smoke tests on live
env:
KINSTA_API_KEY: ${{ secrets.KINSTA_API_KEY }}
POST_PUSH_WP_CLI: ${{ vars.POST_PUSH_WP_CLI }}
run: python3 .github/deploy/kinsta_deploy.py promote
concurrency makes a second push wait rather than race the first. Because the host key changes on every push, the job fetches it each run. That's trust on first use; if your threat model needs more, compare the key with the one MyKinsta shows.
5. The deploy script
Save it as .github/deploy/kinsta_deploy.py. It uses only Python's standard library, so the job needs no install step.
#!/usr/bin/env python3
"""Promote code from a Kinsta staging environment to live without touching the live database.
kinsta_deploy.py validate checks staging after CI copied the build there: right environments, same PHP,
smoke tests (pages, Store API add-to-cart, build marker = this commit)
kinsta_deploy.py promote tagged manual backup of live, files-only push of the listed folders, optional
WP-CLI command, cache clear, the same smoke tests on live
Env: KINSTA_API_KEY, KINSTA_SITE_ID, STAGING_URL, LIVE_URL, DEPLOY_SHA, DEPLOY_PATHS (comma-separated, relative to
the site root, e.g. wp-content/plugins/store-core,wp-content/themes/store-child), optional POST_PUSH_WP_CLI,
KINSTA_API (default https://api.kinsta.com/v2). Python 3.9+, standard library only."""
import json, os, re, sys, time, urllib.error, urllib.parse, urllib.request
from http.cookiejar import CookieJar
API = os.environ.get('KINSTA_API', 'https://api.kinsta.com/v2').rstrip('/')
POLL_SECS = float(os.environ.get('POLL_SECS', '10'))
OP_TIMEOUT = float(os.environ.get('OP_TIMEOUT', '1800'))
WP_CLI_ALLOWED = re.compile(r"^wp\s+[a-zA-Z0-9_\-./:=@'\s]+$") # the API's own pattern for wp_command
CRITICAL = 'There has been a critical error on this website'
class DeployError(Exception):
pass
def log(msg):
print(time.strftime('%H:%M:%S ') + msg, flush=True)
def api(method, path, body=None, tries=6):
req = urllib.request.Request(API + path, method=method, data=json.dumps(body).encode() if body is not None else None,
headers={'Authorization': 'Bearer ' + os.environ['KINSTA_API_KEY'],
'Content-Type': 'application/json', 'Accept': 'application/json'})
for attempt in range(tries):
try:
with urllib.request.urlopen(req, timeout=60) as r:
return r.status, json.loads(r.read() or b'{}')
except urllib.error.HTTPError as e:
payload = json.loads(e.read() or b'{}') if e.headers.get('Content-Type', '').startswith('application/json') else {}
if e.code == 429 and attempt < tries - 1: # 120 requests/minute per company
wait = float(e.headers.get('Retry-After') or 30)
log(f'rate limited, waiting {wait:.0f}s')
time.sleep(wait)
continue
if e.code in (404, 500) and path.startswith('/operations/'):
return e.code, payload # operation states come back as status codes
raise DeployError(f'{method} {path}: HTTP {e.code} {payload.get("message", "")}'.strip())
except urllib.error.URLError as e:
if attempt == tries - 1:
raise DeployError(f'{method} {path}: {e.reason}')
time.sleep(5)
def wait_for(op_id, what):
"""GET /operations/{id}: 202 = running, 200 = done, 500 = failed. A fresh id can 404 for a moment."""
start, misses = time.time(), 0
while time.time() - start < OP_TIMEOUT:
code, body = api('GET', '/operations/' + urllib.parse.quote(op_id, safe=''))
if code == 200:
log(f'{what}: done ({time.time() - start:.0f}s)')
return body
if code == 500:
raise DeployError(f'{what} failed: {body.get("message", "no message")}')
if code == 404:
misses += 1
if misses > 3:
raise DeployError(f'{what}: operation {op_id} not found')
time.sleep(POLL_SECS)
raise DeployError(f'{what}: still running after {OP_TIMEOUT:.0f}s, check MyKinsta before retrying')
def start(method, path, body, what):
_, res = api(method, path, body)
if not res.get('operation_id'):
raise DeployError(f'{what}: no operation_id in response {res}')
log(f'{what}: started')
return wait_for(res['operation_id'], what)
def environments(site_id):
_, res = api('GET', f'/sites/{site_id}/environments')
envs = {e['name']: e for e in res['site']['environments']}
if 'live' not in envs or 'staging' not in envs:
raise DeployError(f'expected "live" and "staging" environments, found {sorted(envs)}')
live, staging = envs['live'], envs['staging']
for e in (live, staging):
if e.get('is_blocked'):
raise DeployError(f'environment {e["name"]} is blocked')
php = (live.get('container_info') or {}).get('php_engine_version'), (staging.get('container_info') or {}).get('php_engine_version')
# A push always copies environment settings, PHP version included: never let a deploy change PHP on the side.
if php[0] != php[1] and os.environ.get('ALLOW_PHP_CHANGE') != '1':
raise DeployError(f'PHP differs: live {php[0]}, staging {php[1]}. A push would switch live to {php[1]}. '
'Align them or set ALLOW_PHP_CHANGE=1')
return live, staging
def smoke(base, sha):
"""Fail on anything a shopper would notice: pages, a real add-to-cart through the Store API, the build marker."""
base = base.rstrip('/')
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(CookieJar()))
bust = f'nocache={int(time.time())}'
def get(path, headers=None):
req = urllib.request.Request(base + path, headers={'User-Agent': 'deploy-smoke/1', **(headers or {})})
try:
with opener.open(req, timeout=30) as r:
return r.status, r.headers, r.read().decode('utf-8', 'replace')
except urllib.error.HTTPError as e:
return e.code, e.headers, e.read().decode('utf-8', 'replace')
problems = []
for path in ('/', '/shop/', '/cart/', '/checkout/'):
code, _, html = get(f'{path}?{bust}')
if code != 200 or CRITICAL in html:
problems.append(f'{path} -> {code}' + (' (critical error page)' if CRITICAL in html else ''))
code, _, body = get(f'/wp-json/wc/store/v1/products?per_page=1&{bust}')
products = json.loads(body) if code == 200 else []
if not products:
problems.append(f'Store API products -> {code}')
else:
code, headers, _ = get('/wp-json/wc/store/v1/cart')
nonce = headers.get('Nonce') if code == 200 else None
req = urllib.request.Request(base + '/wp-json/wc/store/v1/cart/add-item', method='POST',
data=json.dumps({'id': products[0]['id'], 'quantity': 1}).encode(),
headers={'Content-Type': 'application/json', 'Nonce': nonce or ''})
try:
with opener.open(req, timeout=30) as r:
cart = json.loads(r.read())
if cart.get('items_count') != 1:
problems.append(f'add-to-cart: items_count {cart.get("items_count")}')
except urllib.error.HTTPError as e:
problems.append(f'add-to-cart -> {e.code}')
marker = os.environ.get('BUILD_MARKER', 'wp-content/plugins/store-core/build.txt')
code, _, text = get(f'/{marker}?{bust}')
if text.strip() != sha:
problems.append(f'build marker is {text.strip()[:12] or code!r}, expected {sha[:12]}')
if problems:
raise DeployError(f'smoke test on {base} failed: ' + '; '.join(problems))
log(f'smoke test on {base}: pages, Store API add-to-cart and build {sha[:12]} OK')
def main(cmd):
site, sha = os.environ['KINSTA_SITE_ID'], os.environ['DEPLOY_SHA']
paths = [p.strip().strip('/') for p in os.environ['DEPLOY_PATHS'].split(',') if p.strip()]
if not paths or any(p in ('', '.', 'wp-content') or '..' in p or p.startswith('wp-content/uploads') for p in paths):
raise DeployError(f'refusing DEPLOY_PATHS {paths}: list your own plugin and theme folders only')
post = os.environ.get('POST_PUSH_WP_CLI', '').strip()
if post and not WP_CLI_ALLOWED.match(post):
raise DeployError(f'POST_PUSH_WP_CLI {post!r} has characters the Kinsta API rejects')
live, staging = environments(site)
if cmd == 'validate':
smoke(os.environ['STAGING_URL'], sha)
return
start('POST', f'/sites/environments/{live["id"]}/manual-backups', {'tag': f'pre-deploy-{sha[:12]}'}, 'backup of live')
start('PUT', f'/sites/{site}/environments', {
'source_env_id': staging['id'], 'target_env_id': live['id'],
'push_db': False, # the default is true: it would replace live orders with staging's
'push_files': True, 'run_search_and_replace': False,
'push_files_option': 'SPECIFIC_FILES', 'file_list': paths}, 'files-only push staging -> live')
if post:
start('POST', f'/sites/environments/{live["id"]}/run-wp-cli-command', {'wp_command': post}, post)
start('POST', '/sites/tools/clear-cache', {'environment_id': live['id']}, 'clear live cache')
try:
smoke(os.environ['LIVE_URL'], sha)
except DeployError as e:
raise DeployError(f'{e}. Roll back by redeploying the previous commit. Do NOT restore the pre-deploy '
'backup unless you accept losing every order placed since it was taken.')
if __name__ == '__main__':
if len(sys.argv) != 2 or sys.argv[1] not in ('validate', 'promote'):
sys.exit(__doc__)
try:
main(sys.argv[1])
except DeployError as e:
print(f'::error::{e}', flush=True)
sys.exit(1)
Notes on the choices:
- Waiting on operations: every write returns an
operation_id.GET /operations/{id}returns 202 while it runs, 200 when it's done, and 500 if it failed. A brand-new ID that briefly returns 404 is polled again, up to three times, instead of being treated as a failure. - Rate limits: 120 requests a minute per company and 1,000 per IP. On a 429 the script waits for
Retry-Afterand tries again. - What the smoke test checks: a page that returns 200 can still be WordPress's "critical error" page, so the script looks for that text. It adds a product to the cart through the Store API, because a broken cart is what costs money. The add-to-cart creates a guest cart session on live, not an order.
- WP-CLI through the API: useful for your plugin's own database migrations after the push, since the database isn't pushed. The API only accepts commands matching
^wp\s+[a-zA-Z0-9_\-./:=@'\s]+$, so no;, pipes or quotes. The script checks that before calling.
6. Test results
| Scenario | Result |
|---|---|
| Validate staging | Pages, Store API add-to-cart and build marker pass on the real store; no write calls |
| Live on PHP 8.3, staging on 8.4 | Refused before any push |
| Build marker from another commit | Validation fails |
| Promote | Backup, push, WP-CLI, cache clear, in order; each operation finished before the next started |
| Request bodies | All valid against the OpenAPI spec, no unknown fields |
| Push body | push_db: false, push_files_option: SPECIFIC_FILES, the two folders, no search-replace |
429 with Retry-After | Waited and retried |
| Push operation fails (500) | Stops: no cache clear, no smoke test, exit 1 |
Fatal error on /checkout/ after the push | Exit 1, names /checkout/ -> 500 (critical error page), says to redeploy rather than restore |
DEPLOY_PATHS of uploads, wp-content, a .. path, or empty | Refused before any API call |
WP-CLI command with ; | Refused locally |
| Blocked live environment | Stops before any write |
| Push never finishes | Gives up after OP_TIMEOUT with "check MyKinsta", no cache clear |
| Expired API key | Clean error, exit 1 |
7. Rolling back
Run the workflow from the Actions tab on the last good commit (it has workflow_dispatch). It goes through the same tests, staging, approval and files-only push, so a rollback is just another deploy. Kinsta's restore puts back the whole environment, database included, as of the backup. On a store that took orders since then, that rewinds them. Keep it for the case where the database itself is damaged, and export the orders placed since the backup first.
8. When "a couple of seconds" is too many
Kinsta's API can also switch an environment's web root to another folder: POST /sites/environments/{env_id}/change-webroot-subfolder with, for example, /release_v2. Kinsta's changelog names Bedrock layouts and release folders as the use cases. With it, you upload each release to its own folder and switch to it, which is how zero-downtime deploys usually work. It needs a site layout where uploads and wp-config.php live outside the release folders, as Bedrock does. Kinsta doesn't say whether the switch is atomic, and we didn't test it. For a standard WooCommerce install, the files-only push above is the practical choice.
9. What it costs
Staging environments are included on every Kinsta WordPress plan, and the API is available to all customers. Kinsta's single-site plans start at $35 a month, or $30 a month billed yearly, for 35,000 visits and 10 GB of storage. WP 2 (two sites, 70,000 visits) is $70 a month, or $59 billed yearly (pricing page, October 2026). The GitHub Actions run takes a few minutes per deploy.
The self-hosted alternative is a VPS running Coolify, an open-source deploy platform. Hostinger sells KVM VPS plans with a Coolify template. Coolify needs at least 2 CPU cores, 2 GB of RAM and 30 GB of disk. You get git-push deploys for the whole stack, but you also run the database, backups, PHP updates and security yourself, and the order-safe promotion logic in this guide is still yours to write.
Related: caching cart and checkout safely, off-site backups with restic and centralizing error logs to see what a deploy changed.
10. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Managed WordPress hosting with an API | Staging on every plan and an API that covers environments, selective pushes, backups, cache clearing, WP-CLI and web-root switching. Rate limits are 120 requests a minute per company. Plans from $35 a month, or $30 billed yearly (October 2026). | |
| The self-hosted route | KVM VPS plans with Coolify available as a template. KVM 2 has 2 vCPUs, 8 GB of RAM and 100 GB of NVMe storage. Prices vary by term and promotion, so check the plan page. You run the stack yourself. |
Alternatives: free tools and references
We don't earn anything from these links.
- Coolify: open-source, self-hosted deploys from git, on any VPS.
- Kinsta API reference: the OpenAPI spec this script was tested against.
- GitHub deployment environments: required reviewers and environment secrets.
- actionlint: checks workflow files, and the shell inside them with shellcheck, before you push.
11. Pitfalls
- Leaving out
push_db: the API default istrue. Set it tofalseexplicitly. - Pushing
ALL_FILES: uploads made on live since staging was created are overwritten or missing. - Different PHP on staging: the push copies it to live.
- Restoring a backup to undo a code change: it rewinds orders. Redeploy the old commit.
- Checking status codes only: look for WordPress's critical-error text too, and run a real add-to-cart.
- A pinned SSH host key: it changes on every push.
- Two deploys at once: use
concurrency. - Editing code on staging by hand: the next deploy's
rsync --deleteremoves it. Every change goes through git.
Deploy code, never the database: copy your own folders to staging from CI, prove staging runs that commit, then push files only to live and test it like a shopper. Kinsta's API gives you every step, but its defaults are the full push, so the safe values have to be in your script. Roll back by deploying the previous commit; keep backup restores for real disasters.