skill
vps-setup-skill
Claude Code skill: take a Kamal Rails app from a bare Ubuntu VPS to a hardened, live production server (provision, harden, deploy, DNS, backups).
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/coorasse/vps-setup-skill itself, not from our server, so what you get is exactly what its author published.
Install instructions come from the README
README
synced Oct 25, 2026vps-setup-skill
A Claude Code skill that takes a Rails app from a bare Ubuntu VPS to a live, hardened production server — interactively, one phase at a time — deploying with Kamal or Dokku, your choice.
You bring a fresh server's IP and an SSH key; the skill asks which tool you want, provisions and hardens the box (optionally putting SSH behind Tailscale and adding Beszel monitoring), configures Kamal or installs Dokku and creates the app, points DNS at it, runs the first deploy, and sets up off-server backups.
What it does
| Phase | What happens |
|---|---|
| 0 | Pre-flight: checks the repo (Dockerfile, Kamal files, dokku remote, database adapter), confirms an SSH key, and asks whether to deploy with Kamal or Dokku |
| 1 | Inspects the server's current state and confirms a checklist of proposed actions with you (multi-select) — already-satisfied items aren't re-offered |
| 2 | You create the VPS (Hetzner instructions provided); the skill starts from the IP |
| 3 | Provisions & hardens the box over SSH, optionally joins it to a Tailscale tailnet and closes public SSH, optionally installs the Beszel monitoring agent, then verifies it with a read-only audit — see How the server is hardened |
| 4 | Kamal: configures deploy.yml (servers, registry, ssh.user, secrets). Dokku: installs Dokku + plugins on the server, creates the app, sets domain/ports, storage mount or Postgres service, config, health check, git remote |
| 5 | Points DNS at the server (any provider; Cloudflare optional) |
| 6 | First deploy (kamal setup, or git push dokku main + letsencrypt:enable) with Let's Encrypt TLS, then verifies the site is live |
| 7 | Backups: Litestream (continuous SQLite replication, both tools) or Dokku Postgres backups to S3, plus Hetzner VM snapshots |
| 8 | Wrap-up + day-2 operations toolkit (including what was skipped) |
It never creates, selects, or deletes cloud servers/projects, and never runs provider CLIs/APIs — you own that step, so there's no risk of it touching the wrong account.
Kamal or Dokku?
The skill always asks, recommending whichever the repo already points at. The server-side hardening is identical; only the deployment layer differs:
| Kamal | Dokku | |
|---|---|---|
| Where the image is built | your laptop (or a builder), pushed to a registry | on the server, from a git push |
| Needs | a container registry account, local Docker | ~4 GB RAM on the box (builds), nothing local |
| Config lives in | config/deploy.yml, .kamal/secrets | the server (dokku config:set), an optional app.json |
| Day-2 | bin/kamal deploy / rollback / app logs | git push dokku main, ssh dokku@host logs |
| Several apps per server | possible, one deploy.yml each | the normal case |
| Several servers per app | yes (roles, accessories) | no |
| TLS | kamal-proxy, automatic | letsencrypt plugin + renewal cron |
They can't share a box: Dokku's nginx and kamal-proxy both want ports 80/443,
and the audit warns if it finds both. scripts/install-dokku.sh installs a
pinned Dokku release with its official bootstrap (reusing the Docker from
provisioning), registers the deploy user's SSH keys as Dokku admin keys, sets
the global domain, and installs the letsencrypt (and optionally postgres)
plugins — idempotently, so it's safe on a server that already runs Dokku.
How the server is hardened
Phase 3 runs scripts/provision.sh (idempotent, safe to re-run) and then, only
once the new deploy user is proven to work, scripts/harden-ssh.sh. If you opt
in, it also runs scripts/setup-tailscale.sh and, after another gate,
scripts/restrict-ssh-to-tailscale.sh. Each protection and why it's there:
Access
- A non-root
deployuser — created with its own home and shell, with root'sauthorized_keyscopied across so your existing key keeps working. It getsdockergroup membership and passwordless sudo through a dedicated/etc/sudoers.d/<user>file (validated withvisudo -cbefore it counts). Kamal then deploys as this user instead of root, so a compromised deploy credential isn't automatically root. - Key-only SSH —
PasswordAuthentication noandKbdInteractiveAuthentication no, so password guessing is off the table entirely no matter how weak an account password is. - Root login disabled —
PermitRootLogin no. Attackers must first guess a valid username, and every privileged action goes through a named account. Both SSH changes land assshd_config.ddrop-ins and are applied only aftersshd -tvalidates the config, so a typo can't lock you out of the box.
Network exposure
- UFW firewall — default deny incoming, allow outgoing, with only
22(SSH),80and443(HTTP/S for kamal-proxy and Let's Encrypt) opened, and logging on. Everything else a package might start listening on is unreachable from the internet. - fail2ban with an
sshdjail — systemd backend,maxretry = 5,findtime = 10m,bantime = 1h, enabled at boot. Repeated failed logins get the source IP dropped, which cuts off SSH brute-forcing before it becomes a problem. - An optional whitelist for your own IP — the skill always offers to add
your current public IP to fail2ban's
ignoreip. This matters more than it sounds: an SSH agent that offers many keys (1Password with ~20 keys, say) burns several failed auths per connection, so a couple of ordinary logins can ban you for the full hour. The app keeps serving; you just can't get in.
Private SSH over Tailscale (optional)
- The server joins your tailnet —
setup-tailscale.shinstalls Tailscale from its official repository and joins with a single-use, pre-authorized auth key you generate in the admin console (the skill never touches the Tailscale console or API). The script is idempotent and changes nothing about the firewall, so public SSH keeps working while you verify thatssh deploy@100.x.y.zdoes too. - Public port 22 is closed — once SSH over the tailnet is confirmed,
restrict-ssh-to-tailscale.shadds a UFW rule for22/tcp on tailscale0and deletes the public22/tcprule. Nobody on the internet can reach sshd any more, so brute-forcing and accidental fail2ban self-bans stop being a concern; fail2ban and key-only auth stay in place as defence in depth. The script refuses to run unless the SSH session it's in already comes from the tailnet (that's why it's invoked withsudo -E), so it can't cut the branch you're sitting on. Undo:sudo ufw allow 22/tcp. - Kamal follows —
servers.webis pointed at the server's Tailscale IP or MagicDNS name, since Kamal only uses that host for SSH; the site is still served on the public IP. Anything that runsbin/kamal(a CI runner, say) must then be on the tailnet as well. - Two lockout traps the skill calls out — Tailscale node keys expire after 180 days unless the machine is tagged or key expiry is disabled in the admin console, and with public SSH closed an expired key means the provider's web console is the only way in. The skill reminds you to disable expiry right after joining, and again in the wrap-up.
Staying patched
- A full upgrade at provisioning time —
apt-get dist-upgrade, not plainupgrade, so kernel updates that pull in new packages aren't held back. - Unattended security upgrades — APT refreshes package lists and installs security updates automatically, so the box doesn't drift months behind on known CVEs between deploys.
- An optional automatic-reboot window —
unattended-upgradesnever reboots on its own, so a kernel or libc update can sit unapplied indefinitely. The skill offers to set a reboot time in the server's local timezone (it checks and can set the timezone too), trading a few seconds of downtime in a quiet hour for updates that actually take effect.
Stability
- A swap file — sized on request (2 GB default), persisted in
/etc/fstabwithvm.swappiness=20in/etc/sysctl.d/. It gives a small VM headroom so a memory spike degrades performance instead of triggering the OOM killer. - Docker — installed and enabled at boot, ready for Kamal.
Monitoring (optional)
- Beszel agent — if you already run a Beszel hub,
install-beszel-agent.shinstalls the agent with Beszel's official installer (auto-update on) using the hub URL, public key and token from the hub's Add System dialog. The agent connects outbound to the hub, so no firewall rule is needed. The skill never sets up the hub itself.
A caveat the skill is explicit about: Docker manipulates iptables directly and can bypass UFW for published container ports. Kamal only publishes 80/443, which are open anyway, so the two stay consistent — but anything else you publish by hand will be reachable regardless of UFW.
Verifying the hardening
scripts/check-hardening.sh is a read-only auditor: it changes nothing,
re-runs safely anytime, and exits 0 only when every required check passes
(1 if any FAIL). WARNs are advisory and never affect the exit code, so an
agent — or a cron job — can confirm the server's posture deterministically
instead of eyeballing scrollback.
ssh deploy@SERVER_IP 'sudo bash -s' -- deploy < scripts/check-hardening.sh
What it checks, and how each result is graded:
| Group | Checks | Grading |
|---|---|---|
| SSH hardening | PasswordAuthentication no, PermitRootLogin no, KbdInteractiveAuthentication no | first two FAIL, third WARN |
| Firewall | UFW active; default incoming policy is deny; ports 80/443 allowed; port 22 allowed either publicly or on tailscale0 only (reports which; both at once is flagged) | first two FAIL, port findings WARN |
| fail2ban | service running; enabled at boot; the sshd jail actually active | running + jail FAIL, boot-enable WARN |
| Security updates | unattended-upgrades installed; list refresh and unattended installs enabled; update origins configured (Allowed-Origins on Ubuntu, Origins-Pattern on Debian); apt-daily-upgrade.timer active | FAIL |
| Reboot window | auto-reboot configured (reports the time and timezone); flags a currently pending reboot | WARN — it's optional |
| Swap | swap active (reports device and size); vm.swappiness <= 20 | WARN |
| Deploy user | user exists; in the docker group; has /etc/sudoers.d/<user>; has a non-empty authorized_keys | existence + keys FAIL, rest WARN |
| Docker | daemon running | FAIL |
| Deployment tool | reports Dokku (version + apps) or a running kamal-proxy; both at once is flagged | INFO, both WARN |
| Tailscale | joined the tailnet (reports the IP); flags tailscaled running but logged out; not installed is only INFO | WARN — it's optional |
| Monitoring | Beszel agent running; flags an agent that's installed but stopped; not installed is only INFO | WARN — it's optional |
| Public listeners | enumerates every TCP socket bound to all interfaces; 22/80/443 are expected, anything else is called out for review | WARN |
Two details worth knowing. The SSH checks read the effective config via
sshd -T rather than grepping files, so an override in an include, a stale
drop-in, or a config that never got reloaded can't hide a mistake. And the
public-listener sweep is what catches the classic accident — a database or
admin port published to 0.0.0.0 by a container, which UFW won't stop.
It ends with a PASS/WARN/FAIL summary. Any FAIL almost always means a
provisioning or hardening step didn't apply, and is worth fixing before you
deploy.
Install
Clone into your Claude Code skills directory. The skill's name (from the
SKILL.md frontmatter) is setup-vps, so use that as the folder name:
git clone https://github.com/coorasse/vps-setup-skill.git ~/.claude/skills/setup-vps
Or add it to a project instead of globally:
git clone https://github.com/coorasse/vps-setup-skill.git .claude/skills/setup-vps
Use
In Claude Code, run /setup-vps, or just ask: "set up a production server",
"deploy this app to a VPS", "deploy with Dokku", "go live". The skill is
interactive: it asks Kamal or Dokku, then prompts you for the domain, registry
or Let's Encrypt email, deploy user, DNS choice, and so on.
Requirements
- A Rails app with a
Dockerfile(bin/rails newon Rails 7.1+ generates one). For Kamal, the Kamal files too (bin/kamal,config/deploy.yml) — Rails 8 ships them; the skill runskamal initotherwise. - Kamal only: local Docker (the image is built locally) and a container registry account. Dokku only: a server with enough RAM to build images.
- An SSH key, and a fresh Ubuntu VPS you've created with that key authorized.
- Optional: a Tailscale account, with the client installed and logged in on your machine, if you want SSH to go over the tailnet.
- Optional: a running Beszel hub, if you want the server monitored.
Contents
SKILL.md— the skill definition and step-by-step procedure.scripts/provision.sh— idempotent server setup +deployuser (run as root). Installs Docker and base tooling, unattended security upgrades, the fail2bansshdjail, the UFW ruleset, and a swap file. It deliberately does not touch SSH access, so a mistake here can't lock you out.scripts/harden-ssh.sh— disables password auth and root login (run only after verifying the deploy user works). Validates withsshd -tbefore restarting the daemon.scripts/setup-tailscale.sh— optional: installs Tailscale and joins the tailnet with an auth key (idempotent, firewall untouched).scripts/restrict-ssh-to-tailscale.sh— optional: scopes UFW's port 22 rule totailscale0and removes the public one. Refuses to run unless invoked over a tailnet connection (sudo -E), or with--force.scripts/install-beszel-agent.sh— optional: installs the Beszel agent and connects it to your hub (idempotent, firewall untouched).scripts/install-dokku.sh— Dokku path only: installs a pinned Dokku, registers admin SSH keys, sets the global domain, installs theletsencryptand optionallypostgresplugins. Idempotent.scripts/check-hardening.sh— the read-only auditor described in Verifying the hardening. Exits non-zero on any failure; re-runnable anytime as a day-2 check.