Install
$ agentstack add skill-avanderheyde-levelsio-vps-setup-skill-levelsio-vps-setup-skill ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
levelsio-vps-deploy
Run a web app on one cheap, hardened VPS — the levels.io way. No Kubernetes, no microservices, no managed PaaS. One box, boring tech, vertical scaling, ship fast.
First: pick the workflow (ask the user)
There are two ways to use this skill. Before doing anything, ask the user which they want — they are different setups:
> Do you want to just deploy to the VPS (you develop on your own computer, the > VPS only hosts), or do you want to develop AND host on the VPS itself the way > levels.io does?
| | Path A — host only | Path B — develop on the VPS (the levels way) | |---|---|---| | Where you code | your own laptop | on the VPS, via Claude Code in tmux | | How code reaches prod | rsync + build-on-box (deploy.sh) | edited in place — already live (~3s restart) | | Connect from | — | Termius (laptop and phone) over Tailscale | | Good for | teams, staging boundary, regulated | solo / indie, cheapest always-on dev+host, code from anywhere | | Read | this file's Phase 1 + 2 | references/develop-on-vps.md |
Both share the same hardened foundation below. They are not mutually exclusive — a solo dev can do Path B and still deploy.sh to a separate staging box. When unsure, Path B is "the full levels.io experience"; Path A is "the VPS is just my cheap host."
The stack (defaults — swap only with reason)
| Layer | Choice | Why | |---|---|---| | Host | Hetzner CPX21 (~$8/mo) or DigitalOcean equivalent, Ubuntu 24.04 LTS | flat price, plenty for a small/medium app | | Network/SSH | Tailscale — SSH locked to the tailnet | SSH invisible on the public internet; lets you connect from anywhere incl. phone | | App | Next.js App Router, output: "standalone" (or any Node app) | single self-contained process | | DB | SQLite via better-sqlite3, WAL mode + busy_timeout | one file, no DB server, fast | | Proxy / TLS | Caddy, reverse-proxy to 127.0.0.1:3000 | automatic HTTPS, 3-line config | | Process mgmt | systemd unit, Restart=always | survives crashes and reboots | | Dev (Path B) | Claude Code + tmux, Termius client | persistent on-box development from any device | | Edge (optional) | Cloudflare in front | DNS, origin hiding, WAF, caching | | Backups | Litestream → S3/R2 | continuous offsite SQLite replication |
Vertically scale the box (more RAM/CPU) long before you reach for distribution.
How the pieces fit
Tailscale (private) Internet
you ── laptop / phone ──┐ (Cloudflare) ↓ :443
▼ ▼
┌──────────────────────────────────────────┐
│ VPS │ SSH:22 tailnet-only │ Caddy :443 │
│ │ Claude Code in tmux │ ↓ :3000 │
│ │ (Path B dev) │ Next.js │
│ (systemd) │
│ ↓ │
│ SQLite (WAL) ──► Litestream → R2
└──────────────────────────────────────────┘
Read references/architecture.md for the hosting diagram + rationale, references/security-tailscale.md for the network lockdown, and references/develop-on-vps.md for the on-VPS development workflow.
Shared foundation — Provision + secure (BOTH paths)
Phase 0 — Create the VPS (optional; agent can do this)
If the user doesn't already have a server, create one for them rather than making them click around a cloud console. scripts/create-hetzner-server.sh uses the hcloud CLI to spin up a fresh Ubuntu box with the user's SSH key installed as root — ready for Phase 1. Full walkthrough: references/provision-hetzner.md.
Steps for the agent:
- Make sure the user has an SSH key (see "SSH keys" below — generate one if not).
- Check
hcloudis installed and authenticated:hcloud server list. If not,
guide the user to create a Hetzner API token (Cloud Console → Security → API Tokens, Read & Write) and authenticate themselves so the token never passes through your tool calls — tell them to run, in-session, ! hcloud context create and paste the token at the hidden prompt. The token is a secret (it can create/delete servers); never put it in a script, config, or command line.
- Edit the
CONFIGblock inscripts/create-hetzner-server.sh(SERVER_NAME,
SERVER_TYPE default cpx21, LOCATION, IMAGE, SSH_KEY_PUB) and run it. It prints the new server's IP and the exact Phase 1 command.
- Second/Nth server: change
SERVER_NAME(andDOMAIN/APP_NAMEin
provision.sh) and run again. The script refuses to clobber an existing name. Each box is independent and joins the same tailnet. If the user says "provision another server for ` at `", run this whole chain (create → provision → Tailscale lockdown → their path) for the new box.
If the user already has a box (any provider) with root SSH, skip to Phase 1.
Phase 1 — Provision (one time)
scripts/provision.sh is a template: review it, edit the CONFIG block, run it as root on a fresh VPS. It will:
- Create a non-root
deployuser with sudo + your SSH key. - Lock down SSH: disable password auth and root login.
- Firewall with
ufw: allow 22, 80, 443 (22 is locked to the tailnet later —
see below — so you don't get locked out mid-provision).
- Enable
unattended-upgradesandfail2ban. - Install Tailscale (daemon only; you run
tailscale upafter). - Install Node LTS and Caddy.
- Create app dirs (
/srv/,/srv/-build,/var/lib/) owned by
deploy, and the secrets file /etc//env (mode 0640, root:deploy).
- Write a
Caddyfile(DOMAIN→reverse_proxy 127.0.0.1:3000) and a systemd
unit for the app.
Steps for the agent:
- Ask for
APP_NAME,DOMAIN,DEPLOY_USER(defaultdeploy), and the VPS IP /
root access.
- Set up the SSH key for the user (do this yourself — see "SSH keys" below —
don't make them figure it out). The deploy user must trust a key before SSH is locked down.
- Edit the
CONFIGblock at the top ofscripts/provision.sh(paste the public
key from step 2 into DEPLOY_SSH_PUBKEY).
- Have the user copy it to the box and run as root:
scp scripts/provision.sh root@SERVER:/tmp/ && ssh root@SERVER 'bash /tmp/provision.sh'.
- Generate app secrets server-side (
openssl rand -base64 32) into
/etc//env. Transfer third-party creds out-of-band (see Secrets).
SSH keys — generate locally + install on the box (agent-driven)
Do this for the user. A public key is not a secret, so it's safe to read, paste into configs, and echo onto the box. (A private key never leaves the machine it was generated on.)
First device (the user's computer, where you're running):
- Check for an existing key:
``bash ls ~/.ssh/id_ed25519.pub 2>/dev/null && cat ~/.ssh/id_ed25519.pub ``
- If there's none, generate one (no passphrase keeps phone/Termius login simple;
offer a passphrase + ssh-agent if the user wants more security): ``bash ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "@" ``
- Read the public key (
cat ~/.ssh/id_ed25519.pub) and paste it into
DEPLOY_SSH_PUBKEY in provision.sh. For Path A, point SSH_KEY in deploy.sh at the matching private key (~/.ssh/id_ed25519).
Adding another device later (e.g. the user's phone): you can't run commands on their phone, so the phone generates its own key in Termius (Keychain → New Key → Generate). Have the user paste that device's public key to you, then install it on the box yourself:
ssh @ 'umask 077; echo "ssh-ed25519 AAAA...phone..." >> ~/.ssh/authorized_keys'
`` is the tailnet IP once Tailscale is up, otherwise the public IP. No private key is ever copied between devices.
Phase 1b — Tailscale lockdown (recommended, both paths)
This is what levels does on every new box: SSH only over the tailnet. Order matters or you lock yourself out — full detail in references/security-tailscale.md:
- On the box:
sudo tailscale up(follow the auth link), thentailscale ip -4. - Install Tailscale on your laptop/phone, same account.
- Confirm
ssh deploy@100.x.y.zworks over the tailnet (new terminal, keep
the old session open as a fallback).
- Only then:
bash scripts/lock-down-tailscale.shon the box — restricts SSH 22
to the tailnet (and optionally 443 to Cloudflare IPs).
Path A — Deploy from your computer (host only)
scripts/deploy.sh is a template run from your dev machine. The key trick: build on the box so native modules (better-sqlite3) compile for the server. It rsyncs your tree → /srv/-build, runs npm ci && npm run build on the box, assembles the standalone output (copying .next/static, public, and the better-sqlite3 .node binding), swaps it into /srv/, restarts the service, and health-checks the domain.
Steps for the agent:
- Confirm the app's
next.config.*setsoutput: "standalone". - Edit the
CONFIGblock inscripts/deploy.sh(SSH_HOST,SSH_KEY,
APP_NAME). If SSH is locked to the tailnet, SSH_HOST uses the tailnet IP.
- Run it. On failure, consult
references/troubleshooting.md.
Path B — Develop ON the VPS (the levels.io way)
The full levels workflow: code lives on the box, Claude Code runs in tmux, you connect from laptop or phone via Termius over Tailscale, and edits are basically already live. Read references/develop-on-vps.md for the end-to-end walkthrough.
Setup, after Phase 1 + Tailscale:
- On the box:
bash scripts/setup-dev-on-vps.sh— installs tmux + Claude Code
and adds the tm "one tmux session per folder" helper to ~/.bashrc.
- Put the code where it serves (clone the repo into
/srv/or a per-site
folder like levels' /srv/http/.com).
- Log Claude Code in on the headless box:
claude→/login→ use the
browser-link flow (open the link on any device, paste the code back) or an API key. See references/develop-on-vps.md.
- Add a Termius host per site with startup snippet
cd /srv/ && tm, on both
laptop and phone, pointed at the tailnet IP.
- Develop. Detach with
Ctrl-b d— Claude keeps running; reattach from any
device. The tmux mechanics are in references/remote-claude-code-tmux.md (also publishable on its own).
Secrets — non-negotiable rules
- Secrets live only in
/etc//envon the server (mode0640,
owner root:deploy). Never in the repo, the DB, or logs.
- Generate strong secrets server-side:
openssl rand -base64 32. - The deploy script / systemd unit
sources / loads/etc//env— it never
bakes secrets into the build or prints them.
- Transfer third-party credentials out-of-band:
scpa temp file, extract
into /etc//env, then shred -u the temp file. Never echo a credential on the command line (it lands in shell history and logs).
- An Anthropic API key used for Path B Claude Code login is a secret too — prefer
the browser-link login, or keep the key out of the repo.
SQLite gotcha (read before first build)
Set busy_timeout before switching the database to WAL mode, or the first write during build/migration can fail with SQLITE_BUSY:
const db = require("better-sqlite3")(process.env.DATABASE_PATH);
db.pragma("busy_timeout = 5000"); // FIRST
db.pragma("journal_mode = WAL"); // THEN
The DB file lives outside the deploy target (e.g. /var/lib//data.db) so rsync --delete never wipes it.
References
references/provision-hetzner.md— create the server itself withhcloud
(API token, first + Nth server), plus a DigitalOcean (doctl) alternative.
references/architecture.md— hosting diagram, per-layer rationale, scaling,
backups (Litestream → R2).
references/security-tailscale.md— Tailscale lockdown, lockout-safe ordering,
recovery.
references/develop-on-vps.md— Path B end to end: Termius, Claude Code login,
the live-edit workflow, phone development.
references/remote-claude-code-tmux.md— the tmux/persistent-Claude-Code
module (self-contained; also published standalone).
references/troubleshooting.md—SQLITE_BUSY, native bindings, Caddy certs,
systemd env, /srv perms, plus Claude-login / tmux / Tailscale-lockout fixes.
Scripts
scripts/create-hetzner-server.sh— create the VPS itself viahcloud(Phase 0).scripts/provision.sh— one-time server lockdown + install, incl. Tailscale.scripts/lock-down-tailscale.sh— restrict SSH to the tailnet (+ optional
Cloudflare-only 443). Run after Tailscale is confirmed working.
scripts/setup-dev-on-vps.sh— Path B: install Claude Code + tmux +tmhelper.scripts/deploy.sh— Path A: repeatable build-on-box deploy.
All use set -euo pipefail, are commented, and have a clearly marked CONFIG block at the top. Review and edit before running.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Avanderheyde
- Source: Avanderheyde/levelsio-vps-setup-skill
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.