Skip to content
Conduit

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

coorasse/vps-setup-skillcommunitylistedunreviewed

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

Skills do not use an MCP config. Follow the steps in the README below.

README

synced Oct 25, 2026

vps-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

PhaseWhat happens
0Pre-flight: checks the repo (Dockerfile, Kamal files, dokku remote, database adapter), confirms an SSH key, and asks whether to deploy with Kamal or Dokku
1Inspects the server's current state and confirms a checklist of proposed actions with you (multi-select) — already-satisfied items aren't re-offered
2You create the VPS (Hetzner instructions provided); the skill starts from the IP
3Provisions & 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
4Kamal: 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
5Points DNS at the server (any provider; Cloudflare optional)
6First deploy (kamal setup, or git push dokku main + letsencrypt:enable) with Let's Encrypt TLS, then verifies the site is live
7Backups: Litestream (continuous SQLite replication, both tools) or Dokku Postgres backups to S3, plus Hetzner VM snapshots
8Wrap-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:

KamalDokku
Where the image is builtyour laptop (or a builder), pushed to a registryon the server, from a git push
Needsa container registry account, local Docker~4 GB RAM on the box (builds), nothing local
Config lives inconfig/deploy.yml, .kamal/secretsthe server (dokku config:set), an optional app.json
Day-2bin/kamal deploy / rollback / app logsgit push dokku main, ssh dokku@host logs
Several apps per serverpossible, one deploy.yml eachthe normal case
Several servers per appyes (roles, accessories)no
TLSkamal-proxy, automaticletsencrypt 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 deploy user — created with its own home and shell, with root's authorized_keys copied across so your existing key keeps working. It gets docker group membership and passwordless sudo through a dedicated /etc/sudoers.d/<user> file (validated with visudo -c before it counts). Kamal then deploys as this user instead of root, so a compromised deploy credential isn't automatically root.
  • Key-only SSH — PasswordAuthentication no and KbdInteractiveAuthentication 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 as sshd_config.d drop-ins and are applied only after sshd -t validates 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), 80 and 443 (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 sshd jail — 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.sh installs 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 that ssh deploy@100.x.y.z does too.
  • Public port 22 is closed — once SSH over the tailnet is confirmed, restrict-ssh-to-tailscale.sh adds a UFW rule for 22/tcp on tailscale0 and deletes the public 22/tcp rule. 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 with sudo -E), so it can't cut the branch you're sitting on. Undo: sudo ufw allow 22/tcp.
  • Kamal follows — servers.web is 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 runs bin/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 plain upgrade, 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-upgrades never 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/fstab with vm.swappiness=20 in /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.sh installs 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:

GroupChecksGrading
SSH hardeningPasswordAuthentication no, PermitRootLogin no, KbdInteractiveAuthentication nofirst two FAIL, third WARN
FirewallUFW 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
fail2banservice running; enabled at boot; the sshd jail actually activerunning + jail FAIL, boot-enable WARN
Security updatesunattended-upgrades installed; list refresh and unattended installs enabled; update origins configured (Allowed-Origins on Ubuntu, Origins-Pattern on Debian); apt-daily-upgrade.timer activeFAIL
Reboot windowauto-reboot configured (reports the time and timezone); flags a currently pending rebootWARN — it's optional
Swapswap active (reports device and size); vm.swappiness <= 20WARN
Deploy useruser exists; in the docker group; has /etc/sudoers.d/<user>; has a non-empty authorized_keysexistence + keys FAIL, rest WARN
Dockerdaemon runningFAIL
Deployment toolreports Dokku (version + apps) or a running kamal-proxy; both at once is flaggedINFO, both WARN
Tailscalejoined the tailnet (reports the IP); flags tailscaled running but logged out; not installed is only INFOWARN — it's optional
MonitoringBeszel agent running; flags an agent that's installed but stopped; not installed is only INFOWARN — it's optional
Public listenersenumerates every TCP socket bound to all interfaces; 22/80/443 are expected, anything else is called out for reviewWARN

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 new on Rails 7.1+ generates one). For Kamal, the Kamal files too (bin/kamal, config/deploy.yml) — Rails 8 ships them; the skill runs kamal init otherwise.
  • 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 + deploy user (run as root). Installs Docker and base tooling, unattended security upgrades, the fail2ban sshd jail, 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 with sshd -t before 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 to tailscale0 and 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 the letsencrypt and optionally postgres plugins. 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.

License

MIT

↑↓ navigate openesc close