David Lewin David Lewin
Cloud VPS & Hosting

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.

Short answer

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.

How this guide was made

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

QuestionAnswer
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
DowntimeKinsta: "a couple of seconds" at the end of a push. Near-zero, not zero.
RollbackRun 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_hosts breaks on the next deploy.
Manual push in MyKinstaThis pipeline
DatabaseCopied unless you choose otherwise every timeNever: push_db: false in code
FilesUsually all of them, including uploadsOnly the folders in DEPLOY_PATHS; uploads and wp-content are refused
What's on stagingWhatever someone last changed thereThe commit that passed CI, proven by build.txt
ChecksSomeone clicks aroundThe same smoke tests on staging and on live
RecordMyKinsta's activity logA workflow run per commit, with the approver

3. The deploy lifecycle

Deploy lifecycle: staging, API validation, production swap
Deploy lifecycle: staging, API validation, production swapgit push to main → Copy to staging → API validation → Approval → Backup → Production swap → After the swapgit push to main: GitHub Actions: lint every PHP file, run PHPUnitgit push to mainGitHub Actions: lint every PHP file, run PHPUnitCopy to staging: rsync over SSH, only your plugin and theme folders; build.txt holds the commit SHACopy to stagingrsync over SSH, only your plugin and theme folders; build.txtholds the commit SHAAPI validation: GET environments: live and staging exist, neither blocked, same PHP; smoke tests on stagingAPI validationGET environments: live and staging exist, neither blocked, samePHP; smoke tests on stagingApproval: GitHub environment "production" with required reviewersApprovalGitHub environment "production" with required reviewersBackup: POST manual-backups, tag pre-deploy-SHA, wait for 200BackupPOST manual-backups, tag pre-deploy-SHA, wait for 200Production swap: PUT environments: files only, SPECIFIC_FILES, push_db false, no search-replaceProduction swapPUT environments: files only, SPECIFIC_FILES, push_db false, nosearch-replaceAfter the swap: optional WP-CLI through the API, clear the cache, the same smoke tests on liveAfter the swapoptional WP-CLI through the API, clear the cache, the samesmoke tests on live
Each API step starts an operation; the script waits for it to finish before starting the next. If anything fails, the run stops there.

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-After and 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

ScenarioResult
Validate stagingPages, Store API add-to-cart and build marker pass on the real store; no write calls
Live on PHP 8.3, staging on 8.4Refused before any push
Build marker from another commitValidation fails
PromoteBackup, push, WP-CLI, cache clear, in order; each operation finished before the next started
Request bodiesAll valid against the OpenAPI spec, no unknown fields
Push bodypush_db: false, push_files_option: SPECIFIC_FILES, the two folders, no search-replace
429 with Retry-AfterWaited and retried
Push operation fails (500)Stops: no cache clear, no smoke test, exit 1
Fatal error on /checkout/ after the pushExit 1, names /checkout/ -> 500 (critical error page), says to redeploy rather than restore
DEPLOY_PATHS of uploads, wp-content, a .. path, or emptyRefused before any API call
WP-CLI command with ;Refused locally
Blocked live environmentStops before any write
Push never finishesGives up after OP_TIMEOUT with "check MyKinsta", no cache clear
Expired API keyClean 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

ToolRoleWhy it fits
KinstaManaged WordPress hosting with an APIStaging 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).
Hostinger VPSThe self-hosted routeKVM 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.

11. Pitfalls

  • Leaving out push_db: the API default is true. Set it to false explicitly.
  • 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 --delete removes it. Every change goes through git.
Bottom line

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.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.