All Articles
CategoryDevOps
Reading Time
15 min read
Published
2026-03-10
Word Count
3,899words

Grab a coffee — this one is a deep dive!

GitHub Actions to VPS: Zero-Downtime Next.js Deploy

Summary

Learn how to chain lint, test, build, sync, restart and health-check steps into a GitHub Actions workflow that deploys Next.js to your own VPS with a zero-downtime guarantee.

  • Split the deploy pipeline into a lint → test → build → sync → restart → health job chain, and only trigger restart once the health-check has succeeded.
  • GitHub Actions' concurrency block (group + cancel-in-progress) prevents two next build runs from hitting the VPS at once — on a low-RAM server, two simultaneous next build processes can exhaust memory, cut a build off midway, and leave a process trying to serve from a half-finished .next directory.
  • If the sync step doesn't satisfy all three of ownership, permissions (640 for .env) and atomicity (symlink-swap) at once, a non-root service setup risks a leak or a half-finished deploy.
  • Secrets travel only as env vars through GitHub Secrets, never written in plain text on the command line; build output is stored as an artifact, and live curl verification is done separately.
GitHub Actions to VPS: Zero-Downtime Next.js Deploy

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

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:

yaml
1name: Deploy to VPS
2on:
3 push:
4 branches: [main]
5run-name: "Deploy ${{ github.sha }} by ${{ github.actor }}"
6 
7jobs:
8 lint:
9 runs-on: ubuntu-latest
10 steps:
11 - uses: actions/checkout@v4
12 - run: npm ci
13 - run: npm run lint
14 
15 test:
16 runs-on: ubuntu-latest
17 steps:
18 - uses: actions/checkout@v4
19 - run: npm ci
20 - run: npm test
21 
22 build:
23 needs: [lint, test]
24 runs-on: ubuntu-latest
25 steps:
26 - uses: actions/checkout@v4
27 - run: npm ci
28 - run: npm run build
29 - uses: actions/upload-artifact@v4
30 with:
31 name: standalone-build
32 path: .next/standalone
33 
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: build
38 runs-on: ubuntu-latest
39 steps:
40 - uses: actions/download-artifact@v4
41 with:
42 name: standalone-build
43 - 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.

yaml
1- uses: actions/cache@v4
2 with:
3 path: ~/.npm
4 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.

yaml
1concurrency:
2 group: deploy-${{ github.ref }}
3 cancel-in-progress: false

This 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 .env must be 640 (owner read-write, group read, others none) — 644 leaves them world-readable.
  • Atomicity: files must never be read by the service while half-copied; using rsync + a temp directory + mv to swap is safer than overwriting with cp.
bash
1#!/usr/bin/env bash
2# /usr/local/bin/app-sync.sh — runs on the server
3set -euo pipefail
4: "${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"/.env
11echo "$RELEASE" > /srv/app/release-new.path
12systemctl restart myapp-ssr@new

GITHUB_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.

bash
1#!/usr/bin/env bash
2set -euo pipefail
3 
4RELEASE=$(cat /srv/app/release-new.path)
5NEW_PORT=4001
6LIVE_PORT=4000
7PREVIOUS=$(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 failure
10trap 'systemctl stop myapp-ssr@new' EXIT
11 
12for i in 1 2 3; do
13 if curl -fsS -o /dev/null "http://127.0.0.1:${NEW_PORT}/api/health"; then
14 echo "new release healthy, swapping symlink and restarting"
15 ln -sfn "$RELEASE" /srv/app/current
16 systemctl restart myapp-ssr
17 sleep 3
18 if curl -fsS -o /dev/null "http://127.0.0.1:${LIVE_PORT}/api/health"; then
19 echo "OK: restarted service serving healthy new release"
20 exit 0
21 fi
22 echo "FAIL: restart verification failed"
23 if [ -n "$PREVIOUS" ]; then
24 echo "rolling back to $PREVIOUS"
25 ln -sfn "$PREVIOUS" /srv/app/current
26 systemctl restart myapp-ssr
27 else
28 echo "FAIL: no previous release to roll back to (first deploy)"
29 fi
30 exit 1
31 fi
32 echo "health check $i/3 failed, retrying..."
33 sleep 5
34done
35 
36echo "FAIL: new release health check did not pass, service NOT restarted, old build stays up"
37exit 1

This 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.

yaml
1purge-cdn:
2 needs: deploy
3 runs-on: ubuntu-latest
4 steps:
5 - name: Purge CDN cache
6 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.

bash
1gh secret set DEPLOY_SSH_KEY < deploy_key.pem
2printf '%s' "$TOKEN" | gh secret set CDN_API_TOKEN

The 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),

bash
1curl -sI http://127.0.0.1:4000/dashboard | grep -i cache-control

that 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

Tags

#GitHub Actions#CI/CD#Next.js#VPS deploy#DevOps#systemd#zero-downtime
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

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.

Share