Every team that deploys Next.js to its own VPS lives with the same fear: after git push, opening the site to check whether it's still up. A GitHub Actions CI/CD pipeline removes that fear — but only when it's built in the right order: lint, test, build, sync, restart, health. In this post I walk through these six steps, why concurrent builds can crash production, and which traps file syncing falls into when you run under a non-root service user.
💡 Pro Tip: Before writing the deploy workflow, answer one question: "If the build fails, what will be running in production?" If the answer isn't "the old build," the pipeline is incomplete.
Table of Contents
- Pipeline shape: lint → test → build → sync → restart → health
- Dependency table between jobs
- Making build cache persistent in Actions
- Build-lock: why concurrent builds crash production
- File sync and ownership/permission traps (non-root service)
- Health-retry and the "don't restart on fail" rule
- Wiring the CDN purge step into the pipeline
- Secrets management and deploy key hygiene
- Proof: deploy log + live verification
- FAQ
- How do I deploy to my own server with GitHub Actions?
- How is zero downtime achieved for a Next.js deploy?
- Why do concurrent builds crash the site?
- How do I set up automatic rollback if a deploy fails?
- Does the build cache get re-downloaded on every deploy?
- Update (September 2026)
- Conclusion
- Sources
Pipeline shape: lint → test → build → sync → restart → health
GitHub Actions workflows live as YAML files under .github/workflows/ at the repo root — that's the official definition of where GitHub looks for workflow files (workflow syntax). The on: key defines the event that triggers the workflow (for example push: branches: [main]); multiple events or branch filters can be defined at once. run-name lets you give each run a readable name — a small but useful field for seeing which commit got deployed on the CI dashboard, without confusion.
Split the six-step chain into jobs and order them with needs:. Lint and test can run as independent parallel jobs; build depends on both via needs; sync/restart/health then depend on build. This is a direct application of GitHub's official workflow model, requiring no third-party tool:
1name: Deploy to VPS2on:3 push:4 branches: [main]5run-name: "Deploy ${{ github.sha }} by ${{ github.actor }}"6 7jobs:8 lint:9 runs-on: ubuntu-latest10 steps:11 - uses: actions/checkout@v412 - run: npm ci13 - run: npm run lint14 15 test:16 runs-on: ubuntu-latest17 steps:18 - uses: actions/checkout@v419 - run: npm ci20 - run: npm test21 22 build:23 needs: [lint, test]24 runs-on: ubuntu-latest25 steps:26 - uses: actions/checkout@v427 - run: npm ci28 - run: npm run build29 - uses: actions/upload-artifact@v430 with:31 name: standalone-build32 path: .next/standalone33 34The `actions/*` steps in these examples are pinned to `@v4`; newer major versions of `actions/checkout` and `actions/cache` were already out when this was written — pick a major that fits your runner, pin it, and read the release notes before upgrading.35 36 deploy:37 needs: build38 runs-on: ubuntu-latest39 steps:40 - uses: actions/download-artifact@v441 with:42 name: standalone-build43 - run: echo "sync + restart + health go here"This post doesn't repeat the server-side setup (systemd unit + nginx reverse proxy); I already covered that step by step in Running Next.js in production with systemd and nginx. The focus here is the automation layer that sits on top of that setup.
Dependency table between jobs
Job | needs | Purpose | On failure |
|---|---|---|---|
lint | — | Code style + static errors | Pipeline stops, deploy never starts |
test | — | Unit/integration tests | Pipeline stops, deploy never starts |
build | lint, test | next build + artifact upload | Pipeline stops, old build stays up |
deploy | build | sync → restart → health | Restart is NOT triggered on health fail (below) |
Making build cache persistent in Actions
GitHub Actions' dependency cache mechanism saves package managers' (npm/yarn/pnpm) frequently used files across runs to avoid re-downloading them (dependency caching). The critical security rule is clear: restored files must be treated as untrusted input, and secrets must never go into the cache. This is the direct reason deploy keys belong in GitHub Secrets, not in a cache step.
1- uses: actions/cache@v42 with:3 path: ~/.npm4 key: npm-${{ hashFiles('package-lock.json') }}5 restore-keys: npm-Another important distinction: files that should be preserved after a run, like build logs, are not cache but artifacts — "Use artifacts when you want to save files produced by a job to use or view after a workflow run has ended, such as built binaries or build logs" (dependency caching). Moving the .next/standalone output with actions/upload-artifact in the workflow example above is an application of that same distinction.
Build-lock: why concurrent builds crash production
GitHub Actions' concurrency key guarantees that only one job or workflow runs at a time within the same concurrency group (control workflow concurrency). By default, when a new run enters the same group, an older run that hasn't started yet (pending) is canceled and the new one replaces it; if cancel-in-progress: true is set, a job that's currently running is also canceled and the new one starts.
1concurrency:2 group: deploy-${{ github.ref }}3 cancel-in-progress: falseThis matters even when build runs on an ubuntu-latest runner, as in the example above: back-to-back pushes running parallel sync/restart jobs against the same release directory can serve a half-finished release; when build runs directly on the VPS, two next build processes at once on a low-RAM server can exhaust memory and cut the build off midway. The concurrency block prevents this at the Actions level: a pending older run in the same group gets canceled and replaced by the new one; add cancel-in-progress: true to also cancel a run already in progress. A lock file or a pgrep -f 'next build' check on the server is an optional defense-in-depth layer on top.
This relates directly to the "push often to main" practice: without a concurrency block, a team pushing to main frequently can produce overlapping builds.
File sync and ownership/permission traps (non-root service)
In Next.js, environment variables are read only on the server side by default; to expose a variable to the browser, the NEXT_PUBLIC_ prefix is required, and those values get embedded into the JS bundle at next build time (self-hosting). The practical consequence for the sync step: the .env file must be correct at build time, but at runtime it also has to sit somewhere the service user can access — without being world-readable.
In a setup running under a non-root service user (defined via User= in the systemd unit), the sync step has to get three things right at the same time:
- Ownership: copied files must belong to the service user, not remain
root:root. - Permissions: secret-bearing files like
.envmust be640(owner read-write, group read, others none) —644leaves them world-readable. - Atomicity: files must never be read by the service while half-copied; using
rsync+ a temp directory +mvto swap is safer than overwriting withcp.
1#!/usr/bin/env bash2# /usr/local/bin/app-sync.sh — runs on the server3set -euo pipefail4: "${GITHUB_SHA:?GITHUB_SHA must be passed as an env var in the deploy step}"5 6RELEASE="/srv/app/releases/${GITHUB_SHA}"7mkdir -p "$RELEASE"8rsync -a --delete /tmp/build-artifact/ "$RELEASE"/9chown -R appuser:appuser "$RELEASE"10chmod 640 "$RELEASE"/.env11echo "$RELEASE" > /srv/app/release-new.path12systemctl restart myapp-ssr@newGITHUB_SHA isn't defined on the server shell by itself; the deploy step's SSH call passes it explicitly: ssh deploy@server "GITHUB_SHA='${{ github.sha }}' /usr/local/bin/app-sync.sh". The :? check stops the sync entirely if the variable arrives empty — otherwise every deploy would write to the same releases/ directory and overwrite the previous one.
The current symlink is NEVER touched here; the swap only happens in the health-check script, after verification. Each release is written to its own SHA-keyed directory, so the previous release stays intact on disk — the rollback target is whatever current pointed to right before the swap. myapp-ssr@new runs the app as a separate systemd template instance on a temporary NEW_PORT; it reads which release to serve from /srv/app/release-new.path at startup, looking directly at $RELEASE, not current. That's why every deploy uses restart rather than start (a running instance only picks up the new path on restart), and it's stopped once the health-check finishes — otherwise the next deploy's health-check would end up verifying the previous release.
The Next.js docs also stress that in self-hosting, the app shouldn't be exposed directly to the internet — a reverse proxy (like nginx) should sit in front. The sync step's "which user writes to which directory" question is a natural extension of that same model — the proxy handles outside requests, while the app process only ever runs from a directory its own user can write to.
These three rules (ownership, permissions, atomicity) aren't independent — unless all three hold, "zero downtime" is hollow. If ownership is right but permissions stay at 644, the secrets in .env become readable by every user on the server; without atomicity (overwriting with cp), the service can read a half-synced directory mid-copy and return a bad response. The rsync --delete flag also needs care: it deletes files that existed in the old release but not the new one, which is why writing to a temp directory and swapping with ln -sfn beats pointing rsync --delete straight at the live directory — no moment of sync ever serves a "half directory."
Health-retry and the "don't restart on fail" rule
Pages Next.js renders dynamically carry a Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate header (self-hosting). This behavior gives the health-check step a technical verification point for "is the new build actually live" — a response that doesn't return the expected header can be a sign that old processes are still running.
Only trigger the restart AFTER the health-check passes: the restart process kills instantly, and if the new build is broken, the site goes down entirely right after restart. Keeping this order preserves the running (old) process even in a "bad deploy" scenario.
1#!/usr/bin/env bash2set -euo pipefail3 4RELEASE=$(cat /srv/app/release-new.path)5NEW_PORT=40016LIVE_PORT=40007PREVIOUS=$(readlink -e /srv/app/current 2>/dev/null || true) # -e: empty if target missing (first deploy)8 9# stop the temporary instance on every exit, success or failure10trap 'systemctl stop myapp-ssr@new' EXIT11 12for i in 1 2 3; do13 if curl -fsS -o /dev/null "http://127.0.0.1:${NEW_PORT}/api/health"; then14 echo "new release healthy, swapping symlink and restarting"15 ln -sfn "$RELEASE" /srv/app/current16 systemctl restart myapp-ssr17 sleep 318 if curl -fsS -o /dev/null "http://127.0.0.1:${LIVE_PORT}/api/health"; then19 echo "OK: restarted service serving healthy new release"20 exit 021 fi22 echo "FAIL: restart verification failed"23 if [ -n "$PREVIOUS" ]; then24 echo "rolling back to $PREVIOUS"25 ln -sfn "$PREVIOUS" /srv/app/current26 systemctl restart myapp-ssr27 else28 echo "FAIL: no previous release to roll back to (first deploy)"29 fi30 exit 131 fi32 echo "health check $i/3 failed, retrying..."33 sleep 534done35 36echo "FAIL: new release health check did not pass, service NOT restarted, old build stays up"37exit 1This retry-then-restart pattern treats the restart as a "commit point": it only fires once the new build is already responding healthily. If all 3 health-check attempts fail, the script exits with code 1, but the service stays untouched — the site stays up, only the deploy is marked failed. Note: in this single-process setup, systemctl restart still leaves a few seconds of handover window — "zero downtime" here guarantees a broken build never reaches production, not that the restart itself is instant; a truly gapless handover needs two processes (LIVE_PORT + NEW_PORT) and a reverse-proxy upstream switch.
Wiring the CDN purge step into the pipeline
The purge step is added as a separate job in the deploy workflow that runs AFTER the health-check succeeds; the required API credentials (token, zone ID) are passed into the workflow through GitHub Secrets, never written in plain text into the YAML.
1purge-cdn:2 needs: deploy3 runs-on: ubuntu-latest4 steps:5 - name: Purge CDN cache6 env:7 CDN_API_TOKEN: ${{ secrets.CDN_API_TOKEN }}8 CDN_ZONE_ID: ${{ secrets.CDN_ZONE_ID }}9 run: |10 echo "CDN purge step goes here — provider-specific API call"How exactly you call your CDN provider's own API depends on that provider's official documentation; this post only fixes one principle: "purge should be one of the last steps in the pipeline, running AFTER the health-check, not before" — if purge runs first, the CDN can keep caching the old build that hasn't been restarted yet.
Secrets management and deploy key hygiene
Creating a secret at the repository, environment, or organization level can also be done via the GitHub CLI: the gh secret set subcommand exists for exactly this (using secrets in GitHub Actions). To mask sensitive values that might leak into logs, use ::add-mask::VALUE — this flags the value like a GitHub secret and strips it from logs automatically.
1gh secret set DEPLOY_SSH_KEY < deploy_key.pem2printf '%s' "$TOKEN" | gh secret set CDN_API_TOKENThe official docs also warn clearly: avoid passing secrets as command-line arguments between processes, since command-line processes are visible to other users via ps — prefer an environment variable or stdin instead. The VPS/SSH equivalent: the deploy SSH key stays in GitHub Secrets, passed as an env var to something like ssh-agent or the appleboy/ssh-action action, never written in plain text as ssh -i /path/key.pem .... Authenticating to the cloud via OIDC instead of a long-lived static secret is another alternative the docs recommend — no direct VPS/SSH equivalent, but worth considering if a cloud provider integration is added later (e.g., the CDN API called from a cloud account).
Once secret hygiene gets put into practice, a handful of simple rules are enough; the table below summarizes them.
What | Right practice | Wrong practice |
|---|---|---|
Deploy SSH key | GitHub Secrets, passed to action as env | -i /path/key.pem on the command line |
CDN/API token | Secret added via gh secret set | Plain text inside the workflow YAML |
Log output | Masked with ::add-mask::VALUE | Printing the token directly with echo |
Cache contents | Dependency files only | Secrets or .env included in the cache |
Proof: deploy log + live verification
Files worth reviewing after a run, like build binaries and logs, should be stored as workflow artifacts — the official Actions equivalent of "every deploy should have log evidence." The .next/standalone output uploaded with actions/upload-artifact in the build job above keeps a permanent record of exactly what a deploy shipped.
On top of that, the Cache-Control header seen in the health-check step is the technical piece of live verification: once the deploy finishes, bypassing the CDN and hitting the origin directly, on a dynamically rendered PAGE route (not a Route Handler),
1curl -sI http://127.0.0.1:4000/dashboard | grep -i cache-controlthat output containing the expected private, no-cache, no-store, max-age=0, must-revalidate value is proof that "the new process is actually responding." A curl against the public domain doesn't prove this — the CDN edge can override the origin's header. This header also only applies to dynamically rendered PAGES, not to a Route Handler like /api/health, and not to a statically prerendered page either — the official docs define it only for dynamic pages, ISR responses, and immutable static assets. Log evidence (artifact) plus live response-header evidence (curl) together give two independent verifications of "the deploy finished" — one inside the pipeline, one outside it.
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
Reader Reward
If you're reading this to build your own VPS pipeline, I've gathered the checklist you should go through before shipping in one place. Compare the list below against your own .github/workflows/deploy.yml; don't turn on automatic deploy to production until every item is checked.
FAQ
How do I deploy to my own server with GitHub Actions?
You create a .github/workflows/deploy.yml file at the repo root and define the trigger with on: push: branches: [main]; in the build job you run next build and upload the output as an artifact, then in a separate deploy job you sync that artifact to the VPS over SSH, restart the service (systemd unit), and verify with a health-check. The server-side systemd + nginx setup is a separate topic — I covered it step by step in Running Next.js in production with systemd and nginx.
How is zero downtime achieved for a Next.js deploy?
Zero downtime is achieved by only calling the restart command after the new build's health-check has been verified to pass. The sync step must be atomic (copy to a temp directory, then swap the symlink), the restart must depend on the health-check, and the health-check itself should make at least a few attempts. Together, these three guarantee a broken build never reaches production at all.
Why do concurrent builds crash the site?
If two next build processes run at the same time on a low-RAM VPS, memory runs out, one (or both) builds gets cut off midway, and that can lead to a process trying to serve from a half-finished .next directory. GitHub Actions' concurrency key prevents this at the workflow level: only a single job runs at a time within the same concurrency group.
How do I set up automatic rollback if a deploy fails?
This post's deploy script handles two failure branches differently: if the new release's health-check fails all 3 attempts, restart is never called and the service stays untouched — the old process keeps running, and the new build is never switched to. If verification AFTER restart fails, the script swaps the symlink back to the previous release it saved right before the swap, and restarts again; either way, the temporary @new instance is stopped on exit. On the first deploy, with no previous release to roll back to, the script just exits with an error code.
Does the build cache get re-downloaded on every deploy?
No — with actions/cache, the dependency cache is keyed by the lock file's hash; as long as the lock file doesn't change, the same cache gets restored and dependencies aren't re-downloaded. Never put secrets or credentials into the cache; restored content should be treated as untrusted input.
Update (September 2026)
The original body of this post was written against the 16.1.x release line as of 2026-03-10. Since then, the most concrete change came in Next.js 16.3: Turbopack's filesystem cache is now on by default for next build too, and in some projects it cuts CI rebuild time dramatically — the Next.js team put it as "5.5x faster builds on CI" (Next.js 16.3 announcement). That gain only kicks in when the build cache (.next/cache) is preserved across builds; a VPS's persistent working directory provides this automatically, while GitHub Actions' ephemeral runner still needs a cache step — the official docs recommend CI providers configure .next/cache as their build cache (Turbopack filesystem cache).
Immutable static asset support was also added: when a deployment adapter turns on config.supportsImmutableAssets, Next.js produces content-addressed static files shared across deploys — reducing the risk of old/new asset mismatches (asset skew) in symlink-swap-style deploys, though a plain VPS running next start doesn't get it automatically (immutable static assets). GitHub Actions' core YAML model hasn't changed; concurrency is still the recommended way to block concurrent deploys. One addition: on 2026-05-06 GitHub documented the optional queue field for multiple pending runs in the same group — queue: single (default) keeps one pending run, queue: max allows up to 100; queue: max can't combine with cancel-in-progress: true (control workflow concurrency). I covered Next.js 16.3's other cache and routing changes in Next.js 16.3: instant navigation and cache components, and the Edge Runtime removal in Next.js 16.3: moving from Edge Runtime to Node.
Conclusion
A zero-downtime deploy pipeline isn't one big feature — it's small guarantees chained together: concurrency prevents concurrent builds, atomic sync never leaves files half-synced, restart only fires with the health-check's permission, and secrets never travel in plain text. Build these six steps on GitHub Actions' official workflow model, and you're free of the habit of checking the site right after git push.
Lay the server-setup groundwork first with Running Next.js in production with systemd and nginx.
Sources
- Workflow syntax for GitHub Actions — official reference for
on,run-name, and workflow file location. - Dependency caching in GitHub Actions — the cache vs. artifact distinction and cache security rules.
- Control workflow concurrency —
concurrencyandcancel-in-progressbehavior. - How to self-host your Next.js application — env var behavior, reverse proxy recommendation,
Cache-Controlheader. - Using secrets in GitHub Actions —
gh secret set, log masking, OIDC recommendation. - Next.js 16.3 announcement — Turbopack FileSystem Cache and build speedups.
- Immutable static assets — content-addressed asset mechanism shared across deploys.
- Turbopack filesystem cache — how the build cache works and configuration guidance for CI providers.
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

