Deploy to a VPS with zero runtime dependencies
The single-binary deploy story: a Linux executable built in CI, scp’d
to your VPS, run under systemd. The VPS needs nothing installed but
ssh, systemd, and a working glibc — no node, no bun, no
node_modules. No Docker.
Full working source in
examples/vps-deploy/
— GitHub Actions workflow, systemd unit, README with one-time setup.
When this is the right answer
Section titled “When this is the right answer”- You want predictable monthly costs. A $4–6/mo VPS comfortably runs a real Next.js app. No per-invocation fees, no platform surprises.
- You want zero lock-in. The deploy artifact is one file you control end-to-end.
- You want a simple operational model. systemd restarts on crash, caddy in front handles TLS, that’s the entire stack.
- You don’t want a Docker daemon on the VPS, or a build pipeline
that needs
docker login/ image registry credentials.
If you want autoscaling, multi-region, or the Vercel feature set, this isn’t the recipe — use Vercel or a container platform instead.
How it works
Section titled “How it works”-
GitHub Actions builds the binary
Section titled “GitHub Actions builds the binary”bun run build:linux-x64(NBC_TARGET=bun-linux-x64 next build) cross-compiles to a single Linux binary regardless of the runner architecture. Produces a ~30MB./serverartifact. -
scp the binary to the VPS
Section titled “scp the binary to the VPS”The workflow uploads to a staging filename (
server.new) instead of overwritingserverdirectly.scpwriting to the live binary would briefly leave it truncated if the transfer were interrupted. -
Atomic swap + restart
Section titled “Atomic swap + restart”Over SSH:
Terminal window mv -f server.new server # atomic on same filesystemrm -rf .next public # wipe stale extracted assetssudo systemctl restart next-app # the unit picks up the new binarymvwithin a filesystem is atomic at the inode level — readers either see the old binary or the new one, never a half-written file. Therm -rfof extracted assets is belt-and-suspenders: a new binary detects the manifest mismatch and re-extracts everything with overwrite, but files the new build no longer ships (deleted pages, renamed chunks) would linger without the wipe. -
Smoke test
Section titled “Smoke test”The workflow polls
/api/healthzfor up to 30s after restart. If it doesn’t come back 200, the workflow fails — you find out about broken rollouts in CI, not from your users.
VPS setup (one-time)
Section titled “VPS setup (one-time)”Pick any VPS that runs systemd and ships glibc — Ubuntu, Debian, Rocky, anything modern.
-
Create a deploy user
Section titled “Create a deploy user”Terminal window sudo useradd -r -m -s /bin/bash -d /srv/next-app deploysudo mkdir -p /srv/next-appsudo chown -R deploy:deploy /srv/next-app -
Generate a deploy SSH key
Section titled “Generate a deploy SSH key”On your laptop (don’t reuse a personal key):
Terminal window ssh-keygen -t ed25519 -f ~/.ssh/next-app-deploy -N ""cat ~/.ssh/next-app-deploy.pubOn the VPS, add the public key to
deploy’s authorized_keys. -
Allow
Section titled “Allow deploy to restart the service”deployto restart the serviceTerminal window sudo visudo -f /etc/sudoers.d/deploy-next-appdeploy ALL=(root) NOPASSWD: /bin/systemctl restart next-app -
Install the systemd unit
Section titled “Install the systemd unit”The unit file is at
examples/vps-deploy/systemd/next-app.servicein the repo. Copy it to the VPS, then:Terminal window sudo cp next-app.service /etc/systemd/system/sudo systemctl daemon-reloadsudo systemctl enable next-appDon’t start yet — there’s no binary in
/srv/next-app/serveruntil the first deploy. -
(Recommended) Put caddy in front for TLS
Section titled “(Recommended) Put caddy in front for TLS”Terminal window sudo apt install caddy/etc/caddy/Caddyfile:example.com {reverse_proxy 127.0.0.1:3000}Caddy handles HTTPS via Let’s Encrypt automatically.
Repo secrets + variables
Section titled “Repo secrets + variables”In repo Settings → Secrets and variables → Actions:
Secrets:
| Name | Value |
|---|---|
VPS_HOST |
example.com or VPS IP |
VPS_USER |
deploy |
VPS_SSH_KEY |
contents of ~/.ssh/next-app-deploy (the private key) |
VPS_KNOWN_HOSTS |
ssh-keyscan example.com — pin the host key |
Variables:
| Name | Value |
|---|---|
APP_DIR |
/srv/next-app |
SERVICE_NAME |
next-app |
Native dependencies (sharp, etc.)
Section titled “Native dependencies (sharp, etc.)”The binary loads native bindings just fine on any glibc-based VPS. For sharp’s text rendering features specifically (watermarks), install fontconfig + a font face on the VPS:
sudo apt install fontconfig fonts-dejavu-coreNo fancier setup required.
Why not Docker?
Section titled “Why not Docker?”Docker on a VPS is fine — it just adds layers (the daemon, an image registry, image pulls on every deploy). The single-binary approach is what makes “skip Docker” reasonable:
| Docker on VPS | next-bun-compile + scp | |
|---|---|---|
| Per-deploy transfer | image pull (~50–200MB) | one binary (~30MB) |
| Daemon process | yes, always | none |
| Registry creds | yes | none |
| Rollback | re-pull old tag | mv the previous binary back |
If you already have a Docker pipeline you like, keep it. The Distroless + sharp recipe shows the container path.
Multi-app, multi-arch, no-reverse-proxy variants
Section titled “Multi-app, multi-arch, no-reverse-proxy variants”- Multiple apps on one VPS: each gets its own user, its own
WorkingDirectory, and its own systemd unit. Caddy in front handles host-based routing. - ARM VPS (Hetzner, Oracle, AWS Graviton): swap
build:linux-x64forbuild:linux-arm64in the workflow. - Skip the reverse proxy: set
HOSTNAME=0.0.0.0andPORT=80in the unit, plusAmbientCapabilities=CAP_NET_BIND_SERVICEto bind a privileged port as non-root. You’re now responsible for TLS — most people shouldn’t.
Rollback
Section titled “Rollback”The previous binary is gone (mv overwrote it). If you need
rollback insurance, change the workflow to keep one backup:
mv -f server server.prevmv -f server.new serverThen mv -f server.prev server && sudo systemctl restart next-app
rolls back instantly.
For more sophisticated rollouts (canary, blue/green), you’ve outgrown “one VPS + systemd” — time to look at a container platform.