One-line bootstrapper that turns a fresh Ubuntu VPS into a secured
management box running Cockpit and a single-node k3s/Rancher cluster,
with Traefik as a public HTTP/HTTPS ingress. Cockpit, Rancher, and the
Traefik dashboard are Tailscale-only; the ingress itself (80/443) is
public on purpose - see Security model. This repo
also publishes a Helm chart catalog (ArgoCD, Epinio, and anything else
added under charts/) that the marketplace step registers in Rancher
automatically - see Rancher Marketplace. Every
step can be turned back off later without reinstalling anything else -
see Removing a feature.
curl -fsSL https://raw.githubusercontent.com/perspikapps/vps/main/dispatch.sh | sudo shRun it with no arguments on an actual terminal (not piped from curl) and
you get an interactive menu instead of having to remember flag names - see
Interactive menu. Run with -h for the full flag
list (--skip-tailscale, --skip-rancher, etc.), or set env vars
beforehand, e.g.:
sudo TAILSCALE_AUTHKEY=tskey-... \
VPS_ADMIN_USER=ops VPS_ADMIN_SSH_KEY="ssh-ed25519 AAAA..." \
RANCHER_HOSTNAME=rancher.example.internal \
sh dispatch.shdispatch.sh runs eight feature folders, in the order each one's
package.json declares (vps.order - see
One folder per feature): system, security,
tailscale, cockpit, k3s (includes Traefik configuration), rancher,
dockermanager, and marketplace. All of them run by default. Three
flag families control which of them run:
--skip-<step>- run everything except the named step(s).--with-<step>- turn on an opt-in step that's off by default; harmless (a no-op) on a step that's already on by default. (Every step currently defaults to on - this flag exists for whatever future step doesn't.)--only-<step>- run only the named step(s), regardless of its default; pass it more than once to run a few together. Any--only-*flag overrides every--skip-*/--with-*flag on the command line.
When you already have dispatch.sh on disk (e.g. after the
full copy-paste example's -o /tmp/dispatch.sh
download), pass flags after the filename like any script:
# Re-run just Rancher, e.g. after changing RANCHER_HOSTNAME:
sudo RANCHER_HOSTNAME=new.example.com sh /tmp/dispatch.sh --only-rancher
# Re-run Cockpit and the dockermanager plugin together, skipping everything else:
sudo sh /tmp/dispatch.sh --only-cockpit --only-dockermanager
# Full run except Rancher (e.g. you're not using Kubernetes on this box):
sudo sh /tmp/dispatch.sh --skip-rancher --skip-k3s
# Re-run just the marketplace catalog registration:
sudo sh /tmp/dispatch.sh --only-marketplaceWarning
With the piped one-liner (curl ... | sudo sh), you cannot just
append flags after sh - sudo sh --only-rancher fails with
sh: --only-rancher: invalid option, because the shell parses
--only-rancher as an option to the shell itself (it looks like one:
--only-rancher starts with --, same shape as sh's own
--posix/etc.), not as an argument to hand the script being read from
stdin. You must add -s -- first: -s tells the shell to read the
script from stdin, and -- marks the end of the shell's own options so
everything after it is passed through as $1, $2, ... to dispatch.sh:
curl -fsSL https://raw.githubusercontent.com/perspikapps/vps/main/dispatch.sh \
| sudo sh -s -- --only-rancherThis composes with env vars the usual way (see the
sudo env-var gotcha
above - put them on the sudo line, not in a plain export):
curl -fsSL https://raw.githubusercontent.com/perspikapps/vps/main/dispatch.sh \
| sudo RANCHER_HOSTNAME=new.example.com sh -s -- --only-rancherThis is equivalent to (and a convenience wrapper around) invoking a
feature's own script directly, as shown in Layout below -
--only-rancher just means "run rancher/run.sh through
dispatch.sh's usual repo clone/update, dependency resolution, and final
summary, instead of calling it by hand." Because every feature's script
is idempotent, re-running a single step to pick up a changed env var
(like RANCHER_HOSTNAME above) is safe and won't disturb the others. See
-h/--help for the full flag list.
Every feature is its own npm workspace package under <name>/,
and its package.json's standard dependencies field is the single
source of truth for what it needs - rancher/package.json
declares "@tomgrv/vps-k3s": "*", so does marketplace's. dispatch.sh
reads that field directly (no separate config to keep in sync): enabling
any of them auto-enables k3s too, even if you didn't ask for it
explicitly:
# k3s isn't named here, but this still installs it - rancher needs it:
sudo sh dispatch.sh --only-rancher
# -> [vps-setup] Also enabling 'k3s' (required by 'rancher').The same dependencies field is read in reverse for
--down-<step>: bringing k3s
down while rancher/marketplace are still enabled is refused, since
it would leave them broken. Adding a new dependency for a feature is a
one-line edit to its package.json - see
One folder per feature below.
Run dispatch.sh with no arguments, on an actual terminal (an SSH
session, not curl ... | sudo sh, which pipes the script itself into
stdin and never triggers this), to get a menu instead of having to
remember flag names:
sudo sh /tmp/dispatch.sh==== VPS setup menu ====
1) * system [up ] Base system update & essentials
2) * security [up ] Firewall / SSH / fail2ban hardening
3) * tailscale [up ] Tailscale install
4) * cockpit [up ] Cockpit install
5) * k3s [up ] k3s / kubectl / helm install (includes Traefik configuration)
6) * rancher [up ] Rancher install
7) * dockermanager [up ] cockpit-packagekit/files/dockermanager install
8) * marketplace [up ] Rancher Apps & Marketplace catalog registration
(* = installed by default) Enter a number to cycle
skip -> up -> down -> skip for that step.
<enter> to proceed, 'q' to quit without changing anything.
>
Type a step's number to cycle it through skip -> up -> down -> skip
(down means uninstall it - see the next section), press enter to
proceed with whatever you've selected, or q to quit without changing
anything. This is purely a friendlier way to build the same --skip-*
/ --with-* / --down-* selection described above - everything below
about flags, env vars, and dependencies applies whether you got there via
the menu or the command line.
Every step can be brought back down (uninstalled/disabled) independently,
without touching anything else already on the box - pass --down-<step>
instead of installing it:
# Remove the marketplace catalog registration only (Rancher, k3s,
# Cockpit, etc. are untouched - apps already installed from the
# catalog via Rancher's UI are untouched too):
sudo sh /tmp/dispatch.sh --down-marketplace
# Remove more than one step in the same run:
sudo sh /tmp/dispatch.sh --down-marketplace --down-dockermanagerA step whose dependency is still enabled refuses to come down, so you don't accidentally break something still running:
sudo sh /tmp/dispatch.sh --down-k3s
# [vps-setup] Refusing to bring 'k3s' down: 'rancher' depends on it and is still enabled.
# [vps-setup] Also pass --down-rancher, or --force-down to override (may leave 'rancher' broken).Either bring the dependent step down in the same run (--down-k3s --down-rancher --down-marketplace, to remove the whole cluster
cleanly), or pass --force-down if you really want to pull k3s out from
under something still enabled.
What each step's down action actually does - and doesn't - undo:
| Step | down removes |
Left in place |
|---|---|---|
system |
(no down action - a base package upgrade, nothing to undo) | everything |
security |
ufw rules (disables ufw entirely), sshd hardening, fail2ban jail | the admin user/password up created, if any |
tailscale |
logs out of the tailnet, disables tailscaled |
the tailscale package itself (PURGE_TAILSCALE=true to remove it too) |
cockpit |
the Cockpit packages and socket config | nothing else depends on it |
k3s |
k3s itself (via its own uninstaller) - takes Rancher and anything installed via the Marketplace down with it | - |
rancher |
the Helm release and its namespace | cert-manager, apps installed via Apps & Marketplace |
dockermanager |
cockpit-dockermanager, cockpit-packagekit, cockpit-files | Docker itself (REMOVE_DOCKER=true to also remove it) |
marketplace |
the ClusterRepo catalog registration only |
any apps already installed from it (uninstall those from Rancher's UI) |
Each feature's run.sh also accepts the action directly if you'd rather
run it without going through dispatch.sh (e.g. from an existing
/opt/vps-setup checkout):
sudo bash marketplace/run.sh down
sudo bash marketplace/run.sh up # same as calling it with no argumentA realistic one-shot install on a fresh Ubuntu VPS, run as root right after first boot. Replace the SSH key and auth key with your own (see Getting the keys you'll need below):
curl -fsSL https://raw.githubusercontent.com/perspikapps/vps/main/dispatch.sh -o /tmp/dispatch.sh
VPS_ADMIN_USER=ops \
VPS_ADMIN_SSH_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... you@laptop" \
TAILSCALE_AUTHKEY="tskey-auth-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
RANCHER_HOSTNAME="rancher.tailnet-name.ts.net" \
RANCHER_BOOTSTRAP_PASSWORD="$(openssl rand -base64 24)" \
sh /tmp/dispatch.shThis creates the ops sudo user with your key, disables SSH password
login, joins your tailnet immediately, and installs Cockpit + k3s +
Rancher. When it finishes, connect over Tailscale and open Cockpit
(https://<tailscale-ip>:9080) and Rancher
(https://rancher.tailnet-name.ts.net:7083) from a machine on the same
tailnet. Save the printed Rancher bootstrap password (also written to
/root/.rancher-bootstrap-password) to log in.
The one-liner above always fetches dispatch.sh from main, but
dispatch.sh itself clones the whole repo again (into VPS_SETUP_DIR) to
get every feature folder - so to test a branch end-to-end you need to
point both fetches at it with VPS_SETUP_REPO_REF.
Warning
export FOO=bar then ... | sudo sh will NOT work. sudo resets
the environment by default, so a plain shell export is invisible to the
command it runs - dispatch.sh will silently fall back to main even
though echo $VPS_SETUP_REPO_REF shows the right value in your shell.
Either put the assignment directly on the sudo line (it is passed
through even with env reset on), or use sudo -E. Don't do this:
export VPS_SETUP_REPO_REF=my-branch # WRONG: lost by sudo
curl -fsSL ".../my-branch/dispatch.sh" | sudo shPiped directly (no intermediate file), with the var set on the sudo line
so it survives:
BRANCH=claude/vps-setup-ubuntu-scripts-br4ddo
curl -fsSL "https://raw.githubusercontent.com/perspikapps/vps/${BRANCH}/dispatch.sh" \
| sudo VPS_SETUP_REPO_REF="$BRANCH" bashOr equivalently, keep your export but tell sudo to preserve it with -E
(only works if your sudoers config allows it - the explicit form above
always works and needs no special sudoers setup):
export VPS_SETUP_REPO_REF=claude/vps-setup-ubuntu-scripts-br4ddo
curl -fsSL "https://raw.githubusercontent.com/perspikapps/vps/${VPS_SETUP_REPO_REF}/dispatch.sh" \
| sudo -E bashDownloading to a file first (useful when passing several variables, as in
the full example above) works the same way -
put every variable on the same line as sudo, before bash:
BRANCH=claude/vps-setup-ubuntu-scripts-br4ddo
curl -fsSL "https://raw.githubusercontent.com/perspikapps/vps/${BRANCH}/dispatch.sh" -o /tmp/dispatch.sh
sudo VPS_SETUP_REPO_REF="$BRANCH" \
VPS_ADMIN_USER=ops VPS_ADMIN_SSH_KEY="ssh-ed25519 AAAA..." \
sh /tmp/dispatch.shEnv vars for this:
| Variable | Default | Purpose |
|---|---|---|
VPS_SETUP_REPO_URL |
https://github.com/perspikapps/vps.git |
Clone a fork instead |
VPS_SETUP_REPO_REF |
main |
Branch, tag, or commit to check out |
VPS_SETUP_DIR |
/opt/vps-setup |
Where the repo is cloned/updated |
To point at a fork as well as a branch, set both:
sudo VPS_SETUP_REPO_URL=https://github.com/ \
VPS_SETUP_REPO_REF=my-feature \
sh /tmp/dispatch.sh < you > /vps.gitdispatch.sh re-clones into VPS_SETUP_DIR on every run (git fetch +
reset --hard if it's already a checkout), so re-running it after pushing
new commits to the same branch picks them up automatically - no need to
re-download dispatch.sh itself unless you're switching branches/forks.
SSH key pair (for VPS_ADMIN_SSH_KEY) - generate one on your own
machine, never on the VPS:
ssh-keygen -t ed25519 -C "you@laptop" -f ~/.ssh/vps_ed25519
cat ~/.ssh/vps_ed25519.pub # paste this whole line as VPS_ADMIN_SSH_KEY- If you already have a key, it's usually at
~/.ssh/id_ed25519.pubor~/.ssh/id_rsa.pub(cateither one). - GitHub/GitLab users already have a public key on file:
curl https://github.com/<your-username>.keysreturns it directly. - Docs: GitHub - Generating a new SSH key, Ubuntu - OpenSSH keys.
Tailscale auth key (for TAILSCALE_AUTHKEY) - generate one in the
Tailscale admin console:
- Go to https://login.tailscale.com/admin/settings/keys.
- Click "Generate auth key". For a server, prefer a reusable, ephemeral: off, and pre-approved (if your tailnet requires device approval) key with a short expiry.
- Copy the
tskey-auth-...value intoTAILSCALE_AUTHKEY.
Docs: Tailscale - Auth keys.
Without this variable, vps-tailscale/run.sh still installs Tailscale;
just run tailscale up manually afterwards and follow the login link.
Rancher bootstrap password (for RANCHER_BOOTSTRAP_PASSWORD) - any
string works; generate a random one with:
openssl rand -base64 24If you don't set it, rancher/run.sh generates and saves one for
you automatically.
cloud-init/kairos-vps-setup.yaml is a
#cloud-config user-data file that runs dispatch.sh unattended on first
boot - no interactive SSH session needed to kick it off. It works with:
- Kairos Ubuntu-flavored images, passed as the
install config (e.g.
kairos-agent install --config kairos-vps-setup.yaml, or via the ISO/PXE/network install config). - Any plain cloud-init VPS provider (DigitalOcean, Hetzner Cloud,
OpenStack, etc.) that lets you paste "User data" at creation time -
Kairos and stock cloud-init share the same document format for the
users/write_files/runcmdkeys this file uses.
To use it:
- Copy the file and fill in the placeholders: your SSH public key (in two
places -
users[].ssh_authorized_keysandVPS_ADMIN_SSH_KEY), your Tailscale auth key, andRANCHER_HOSTNAME. See Getting the keys you'll need above. - Paste it into your provider's "User data" / cloud-init field (or pass it to Kairos) when creating the VPS.
- On first boot the VPS installs itself unattended; check
/var/log/vps-setup.logfor progress/output.
Because runcmd already executes as root, this sidesteps the
export ... | sudo sh env-var gotcha
entirely - there's no sudo involved.
dispatch.sh- leading script, deliberately plain POSIX/bin/sh(see One folder per feature for why): clones/ updates this repo, discovers every*/package.json, resolves which steps run (flags, the interactive menu, and dependencies read straight from each package.json), bootstrapszz_useonce viasetup.sh(not per-feature - see below), and runs each feature'srun.shin order, idempotent and re-runnable, eitherupordown.setup.sh- installszz_use(fromtomgrv/scripts) ontoPATH, then execs this repo's"main"entrypoint frompackage.json(dispatch.sh) if it's sitting next tosetup.shin a local checkout, forwarding every arg through - sosh setup.sh --only-rancherfrom an existing checkout works the same as callingdispatch.shdirectly. Piped straight fromcurlwith no local checkout (curl .../setup.sh | sh -s -- ...), there's nopackage.jsonnext to the running script to find, so it only bootstrapszz_useand stops - usedispatch.sh's own one-liner (which clones the repo first) for that case. A thin wrapper, deliberately:zz_useitself isn't this repo's script, so duplicating its ownsetup.sh's bin-dir/linking logic here would just be a second copy to keep in sync.dispatch.shruns it once, up front, itself falling back tosetup.sh(and exec'ing back into itself) the same way ifzz_useisn't onPATHyet; every feature's ownrun.shno longer bootstrapszz_useitself (that would mean onecurlper feature instead of one total) - it just fails fast with a one-line message pointing here ifzz_useisn't already onPATHwhen run standalone. Pin thetomgrv/scriptsref withZZ_SCRIPTS_REF(defaultmain), or bootstrap from a fork entirely withZZ_SCRIPTS_SETUP_URL(a fullsetup.shURL). See Replicating this pattern in another repo.package.json(root) - an npm workspace root ("workspaces": [<every top-level folder>]); ties every feature package together for tooling (npm install,npm ls, lint-staged, commitlint's workspace-scope rules) without dispatch.sh itself needing npm/node at all.cloud-init/kairos-vps-setup.yaml- cloud-init/Kairos user-data that runsdispatch.shunattended on first boot.common/- shared logging/retry/idempotency helpers sourced by every feature'srun.sh(strict bash mode, non-interactive apt, "already done" checks);net_port/net_access/all_network_portsfor reading each feature's ownpackage.jsonport declarations - see Network config; anddispatch_action/helm_teardown, the shared plumbing behind every feature'sup/downactions. Colors and leveled logging (log/ok/warn/die) delegate tozz_colors/zz_logfromtomgrv/scripts- the same core shared withtomgrv/devcontainer-features' common-utils feature - bootstrapped on first source via itssetup.shif not already onPATH. This is bash, not POSIX sh - everyrun.shis invoked bydispatch.shas abashsubprocess, never sourced from the sh dispatcher itself. Like every feature,common/is a top-level<name>/{package.json,run.sh}folder in this repo, laid out the same waytomgrv/scriptslays out its own scripts - which is what letszz_usefetch and install it (or any feature) directly from this repo, from anywhere:zz_use perspikapps/vps/common. It isn't an installable step itself, though -dispatch.sh's feature discovery skips it (andsummary/) explicitly.summary/- prints connection info, the Tailscale URL, and the Cockpit/Rancher credentials at the end of a run. Also its own top-levelzz_use-installable folder, also excluded from feature discovery.system/- apt update/upgrade, base tooling, unattended security upgrades.security/- optional non-root admin user, ufw (default-deny inbound, rules generated from every feature's ownpackage.jsonport declarations: SSH and Traefik's 80/443 public, everything else Tailscale-only), sshd hardening, fail2ban, and a Cockpit/console login password.vps-tailscale/- installs Tailscale, enablestailscaledas a systemd service, and joins the tailnet.cockpit/- installs Cockpit, served on ports 9080/9083.k3s/- installs k3s (Traefik enabled), kubectl, Helm, and configures Traefik as a public HTTP/HTTPS ingress with a Let's Encrypt certResolver and a Tailscale-only dashboard - see Traefik ingress.rancher/- installs cert-manager (required by Rancher's self-signed TLS even with ingress disabled) and the latest Rancher via Helm, exposed on ports 7080/7083 through k3s's built-in ServiceLB. Depends onk3s(see itspackage.json).dockermanager/- installscockpit-packagekit,cockpit-files, Docker (docker.io, as a dependency), and the third-party cockpit-dockermanager plugin for managing Docker containers/images from Cockpit.marketplace/- registers this repo's Helm chart catalog (charts/, published to GitHub Pages) as a RancherClusterRepo, so it shows up under Apps & Marketplace → Repositories - see Rancher Marketplace. Depends onk3s/rancher.charts/- Helm charts for "extra" apps (ArgoCD, Epinio) that install onto the k3s cluster rather than the host itself - not adispatch.shfeature folder (norun.sh), published as a standard Helm repo and installed through Rancher's UI instead - see Rancher Marketplace.
Each feature is a small, self-contained npm workspace package:
rancher/
package.json # name, description, "bin": { "rancher": "run.sh" },
# "vps": { "default": true|false }, and
# "dependencies": { "@tomgrv/vps-<other-feature>": "*" }
run.sh # up() and down() - see Removing a feature, below
Folder names carry no ordering (rancher/, not
05-rancher/) - install order is a plain integer,
package.json's vps.order, and dispatch.sh sorts by that instead of
by folder name. Everything that used to live in setup.sh's
hand-maintained bash tables (label, install order, default on/off, what
depends on what) now lives in each feature's own package.json instead:
{
"name": "@tomgrv/vps-rancher",
"version": "1.0.0",
"private": true,
"description": "Rancher install",
"bin": { "rancher": "run.sh" },
"vps": { "order": 5, "default": true },
"dependencies": { "@tomgrv/vps-k3s": "*", "@tomgrv/vps-common": "*" }
}"bin" follows the same {"<name>": "run.sh"} convention
tomgrv/scripts uses for its own
scripts - what makes zz_use perspikapps/vps/rancher resolvable (see
Running a single feature via zz_use
below). "dependencies" always includes @tomgrv/vps-common (every feature
sources it - see Layout), plus any other @tomgrv/vps-<feature> it
needs; dispatch.sh's own dependency reading (auto-enable,
--down-<step> refusal) explicitly excludes common/summary from this
field, since neither is an installable step.
One folder is named differently from its own feature for exactly this
reason: vps-tailscale/, not tailscale/ - its run.sh calls the real
tailscale CLI internally, and zz_use has no notion of "bin" at all
(it always installs <name>/run.sh under the literal folder name <name>
it was asked for) - zz_use perspikapps/vps/tailscale would install this
feature's own script as tailscale, shadowing the actual binary it
depends on. Its package.json's "name" field is still "@tomgrv/vps-tailscale"
though (that's what dispatch.sh reads - CLI flags like --only-tailscale
are unaffected), so only the folder (and therefore the zz_use/"bin"
identity) differs from every other feature's own name.
Adding a new feature is: create whatever/ with a
package.json (following the shape above, with an order that places it
where you want in the install sequence) and a run.sh (up()/down() +
dispatch_action "$@" at the end, same as any other feature - see
common/). dispatch.sh picks it up automatically; add it to root
package.json's "workspaces" array too. Removing a feature is deleting
its folder (and that array entry).
The root package.json's "workspaces" array registers every
feature as an npm workspace member, so standard npm tooling (npm ls,
npm install, the repo's existing lint-staged/commitlint config, which
already referenced @commitlint/config-workspace-scopes) understands the
dependency graph too - package-lock.json resolves @tomgrv/vps-rancher's
@tomgrv/vps-k3s dependency like any other workspace package. dispatch.sh
itself never needs npm installed, though: it's plain POSIX /bin/sh (see
Layout) and reads each package.json's dependencies/vps
fields directly with sed/awk, precisely because those fields are
simple, single-key-per-line JSON it controls the format of. If you hand-edit
a feature's package.json, keep that one-key-per-line shape or
dispatch.sh's parser won't find it.
Because every feature is a top-level <name>/run.sh folder - the same
layout tomgrv/scripts uses for its
own scripts - zz_use (from that repo) can fetch and install any one of
them directly, from any machine, without cloning this repo or running
dispatch.sh:
curl -fsSL https://raw.githubusercontent.com/tomgrv/scripts/main/setup.sh | sh
command -v jq > /dev/null || sudo apt-get update && sudo apt-get install -y jq # common/run.sh needs it
zz_use perspikapps/vps/rancher
sudo rancher upzz_use's [org/repo/]<tool>[@ref] syntax resolves perspikapps/vps as
the origin and rancher as the script, downloads this repo (cached
locally after the first call, per-origin/ref - see
tomgrv/scripts's README),
and symlinks rancher/run.sh onto PATH as rancher. Since every
feature's own run.sh in turn fetches common/run.sh from this same
repo the same way, a feature installed this way works exactly like it
would through dispatch.sh - it just skips discovery, ordering,
dependency auto-enable, and the interactive menu, so you're responsible
for running any features it depends on yourself first (see
Dependencies between steps).
Every port this repo opens, and whether it's public or Tailscale-only, is
declared on the feature that owns it, in its package.json's vps.ports
array (same file that carries vps.default/dependencies - see
One folder per feature). Each entry looks like:
{
"name": "rancher_http",
"port": 7080,
"access": "tailscale",
"note": "optional, becomes the ufw rule's comment"
}(access is "tailscale" or "public".) rancher/package.json
carries rancher_http/rancher_https, k3s/package.json
carries http/https/traefik_dashboard, and so on - each feature's own
run.sh is what actually binds the port, so its declaration lives right
next to the code that uses it instead of a separate central file.
security/run.sh doesn't know about any of that port detail
itself: it calls common/run.sh's all_network_ports(), which scans
every */package.json and builds ufw's rules from whatever it
finds - there's no per-service ufw logic in that script at all, just a
loop over that combined list. Every feature that binds a port itself
(Cockpit, Rancher, Traefik's dashboard) reads its own default via
common/run.sh's net_port() helper (resolving its own package.json
automatically - see the function's comment for how summary/run.sh, which
isn't any one feature, asks for another feature's port explicitly), so the
port ufw opens and the port the app actually listens on can't drift apart.
To change a default port for good, edit that feature's package.json and
re-run the affected step(s) (e.g. --only-security --only-rancher after
changing rancher_http). To override a port for a single run without
editing anything, use its env var - the name is always the entry's name,
upper-cased, with _PORT appended: rancher_http -> RANCHER_HTTP_PORT,
ssh -> SSH_PORT, and so on.
Lookups are done with jq (already a base dependency installed by
system) - no separate YAML tooling needed now that this
lives in package.json alongside everything else npm already parses.
SSH and HTTP/HTTPS (Traefik's ingress) are the only things reachable from
the public internet. Everything else - Cockpit, Rancher, the Traefik
dashboard, the k3s API - is bound by ufw to the tailscale0
interface only, so you must join the same tailnet to reach them. Apps
you install through Rancher's Apps & Marketplace (ArgoCD, Epinio, or
anything else) manage their own exposure - see each chart's README.md
under charts/ and Rancher Marketplace
for what to check before installing one.
HTTP/HTTPS are public unconditionally, not behind a flag: Traefik is this VPS's real ingress, and Let's Encrypt's HTTP-01 challenge needs port 80 reachable from the internet to issue certs at all - a Tailscale-only ingress would defeat the point of having one.
Because of this, dispatch.sh refuses to run at all if the Tailscale step
is enabled but TAILSCALE_AUTHKEY is unset - proceeding anyway would
lock down ufw and leave every Tailscale-only service unreachable by
anything. Pass --skip-tailscale if you genuinely want to run without
Tailscale (you can join manually later with tailscale up, then sudo sh dispatch.sh --only-tailscale).
k3s/run.sh leaves k3s's bundled Traefik enabled (rather than
disabling it, as you'll see suggested in some k3s+Rancher guides) and
configures it as this VPS's public ingress via a HelmChartConfig -
k3s's own mechanism for overriding a bundled chart's values, watched
continuously so it's safe to re-apply any time (e.g. via --only-k3s).
- Public HTTP/HTTPS, any hostname: ports 80/443 are k3s's own defaults
for Traefik's
web/websecureentrypoints, exposed via its built-in ServiceLB like Rancher's ports - no extra configuration needed, just ufw open on those two (see Security model). Traefik routes by the incoming request'sHostheader, not a fixed hostname list: any FQDN or subdomain you point at this VPS's public IP is routed by whicheverIngressdeclares that host, with no changes needed here- that's how a Marketplace-installed app's per-app subdomains would work too, if it uses one (e.g. Epinio - see Rancher Marketplace).
- Let's Encrypt: a certResolver named
letsencryptis configured (email fromTRAEFIK_ACME_EMAIL, HTTP-01 challenge on thewebentrypoint, state persisted to a PVC so certs survive pod restarts). This makes the resolver available - it doesn't issue anything by itself. To get a real cert for your own app, create anIngress(or TraefikIngressRoute) with the annotationtraefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt, and a real DNS record pointing this VPS's public IP at your hostname (the HTTP-01 challenge needs that to succeed). Each hostname gets its own cert, issued on demand the first time it's requested - HTTP-01 can't issue a single wildcard cert covering a domain and all its subdomains at once (that needs a DNS-01 challenge, which isn't wired up here); every Ingress you add gets its own cert instead. - Staging by default:
TRAEFIK_ACME_STAGINGdefaults totrue, which points the resolver at Let's Encrypt's staging environment - browsers will show a certificate-warning page, but there's no rate limit, so it's safe to test against repeatedly while you get your Ingress/DNS right. SetTRAEFIK_ACME_STAGING=falseonce you're ready for real, trusted certs (production Let's Encrypt has strict per-domain rate limits - avoid iterating against it directly). - Dashboard: exposed on
TRAEFIK_DASHBOARD_PORT(default8088), Tailscale-only like Cockpit/Rancher, athttp://<tailscale-ip>:8088/dashboard/(trailing slash required). It has no login of its own - that's fine given it's already gated to the tailnet, same threat model as the rest of this repo's admin surfaces, but don't put it on a public port.
- Cockpit authenticates via PAM against a real Linux account and
password - separate from SSH, which stays key-only.
security/run.shsets a password forVPS_ADMIN_USER(orrootif that's unset): eitherVPS_ADMIN_PASSWORDif you set it, or a random one saved to/root/.cockpit-admin-password(username in/root/.cockpit-admin-user). - Rancher username is always
admin; the initial password isRANCHER_BOOTSTRAP_PASSWORDif set, otherwise a random one saved to/root/.rancher-bootstrap-password. Rancher prompts you to change it on first login.
Anything you install afterwards through Apps & Marketplace (ArgoCD,
Epinio, etc.) sets up its own login the way its own chart does - see
Rancher Marketplace and that chart's README.md
under charts/.
"Extra", non-essential apps - things that run on the k3s cluster
rather than being part of the host-level bootstrap - aren't installed by
dispatch.sh any more. Instead, this repo publishes them as a standard
Helm chart repo from charts/, and the marketplace step
(on by default) registers that repo as a Rancher ClusterRepo so it
shows up under Apps & Marketplace → Repositories as
perspikapps-vps, pointed at https://perspikapps.github.io/vps/.
From there, installing (or removing) ArgoCD, Epinio, or anything else
this repo publishes is just using Rancher's own Apps & Marketplace →
Charts UI like any other catalog app - fill in that chart's values
(see its README.md under charts/<name>/ for what's required) and
install. dispatch.sh itself no longer knows how to install/uninstall
these apps directly; marketplace/run.sh down only removes the catalog
registration; uninstall an already-installed app from Rancher's UI.
Why this split: cockpit/ and dockermanager/ stay as dispatch.sh
steps because they configure the host itself (apt packages, systemd
services) - a Helm chart doesn't fit them. ArgoCD and Epinio, by
contrast, are ordinary Kubernetes workloads with nothing VPS-specific
about them once installed, so a Rancher-native catalog is a better fit
than a bash script re-running helm upgrade --install - it gets you
Rancher's own install/upgrade/values UI, version pinning, and easy
removal for free.
Publishing: charts/<name>/ are thin umbrella charts (a Chart.yaml
dependency pointing at the real upstream chart, plus a values.yaml
with sane defaults) - see charts/argocd and
charts/epinio. .github/workflows/publish-charts.yml
packages every chart under charts/* and publishes them (via
helm/chart-releaser-action)
as GitHub Releases plus an index.yaml on the gh-pages branch,
whenever charts/** changes on main - that gh-pages branch, served
via GitHub Pages, is what https://perspikapps.github.io/vps/ actually
serves. Adding a new app to the catalog is: add
charts/<name>/Chart.yaml + values.yaml (+ README.md documenting
any required values), push to main, and it's live in the catalog
within a few minutes.
Cockpit/Rancher's own credentials are still printed by summary/run.sh
at the end of an install; anything installed through the Marketplace
prints its own credentials/URLs the way that chart's own notes (or its
README.md under charts/) describe.
All *_PORT variables below are per-run overrides of a default that
actually lives in the owning feature's own package.json - see
Network config - edit
that file to change a default for good, or set the env var for one run.
| Variable | Default | Purpose |
|---|---|---|
VPS_ADMIN_USER |
unset | Create this sudo user |
VPS_ADMIN_SSH_KEY |
unset | Authorized key for the admin user and root |
VPS_ADMIN_PASSWORD |
random | Cockpit/console login password (separate from SSH) |
SSH_PORT |
22 |
SSH port kept open publicly |
TAILSCALE_AUTHKEY |
unset | Auto-join a tailnet (required unless --skip-tailscale) |
TAILSCALE_EXTRA_ARGS |
unset | Extra flags appended to tailscale up |
COCKPIT_HTTP_PORT / COCKPIT_HTTPS_PORT |
9080 / 9083 |
Cockpit ports (9xxx) |
RANCHER_HTTP_PORT / RANCHER_HTTPS_PORT |
7080 / 7083 |
Rancher ports (7xxx) |
RANCHER_HOSTNAME |
node IP | Hostname used in Rancher's cert |
RANCHER_BOOTSTRAP_PASSWORD |
random | Rancher initial admin password |
INSTALL_DOCKER |
true |
Install docker.io for cockpit-dockermanager to manage |
COCKPIT_DOCKERMANAGER_VERSION |
latest |
cockpit-dockermanager release tag to install |
TRAEFIK_ACME_EMAIL |
placeholder | Let's Encrypt contact email - set this to a real address |
TRAEFIK_ACME_STAGING |
true |
Use Let's Encrypt's staging (untrusted, no rate limit) vs. production certs |
TRAEFIK_DASHBOARD_PORT |
8088 |
Traefik dashboard port (Tailscale-only) |
MARKETPLACE_REPO_NAME |
perspikapps-vps |
Name of the Rancher ClusterRepo the marketplace step registers |
MARKETPLACE_REPO_URL |
https://perspikapps.github.io/vps/ |
URL of the Helm chart catalog to register |
CERT_MANAGER_VERSION |
latest | Pin cert-manager's chart version (installed by rancher) |
Ports follow a per-app range so they're easy to tell apart at a glance:
Cockpit 9xxx, Rancher 7xxx, Traefik dashboard 8xxx - the
ingress itself is always 80/443, per HTTP/HTTPS convention, not part of
this scheme. Apps installed through the Rancher Marketplace (see
Rancher Marketplace) configure their own ports/
Ingresses via that chart's own values, outside this table.
Each feature's run.sh can also be run standalone, from within a
checkout or on its own - but it doesn't bootstrap zz_use itself (that's
setup.sh's job, run once - see Layout); it just fetches
common/run.sh from this repo via zz_use perspikapps/vps/common if
zz_use is already on PATH, and fails fast with a one-line message
pointing at setup.sh if it isn't:
curl -fsSL https://raw.githubusercontent.com/perspikapps/vps/main/setup.sh | sh
sudo RANCHER_HOSTNAME=new.example.com bash rancher/run.shThis is what dispatch.sh --only-<step>, described in
Running a single step (or a subset)
above, does for you (and bootstraps zz_use for, once, up front, for
every step - not per-feature).
Every run.sh also takes an explicit up or down action as its first
argument (up is the default, so the invocation above is really ... bash rancher/run.sh up) - see
Removing a feature for what each
step's down does.
Every script runs under set -euo pipefail and sources common/run.sh,
which installs an error trap: the first command that fails without being
explicitly handled (i.e. not part of an if/&&/||) prints its exact
file, line number, and the failing command, then the script exits. For
example:
[vps-setup] ERROR: command failed (exit 1) at /opt/vps-setup/rancher/run.sh line 52: helm upgrade --install rancher ...
When a step fails during a full dispatch.sh run, it also prints which
numbered step failed and how to re-run just that one after fixing the
issue:
[vps-setup] Step 'Rancher install' (rancher/run.sh up) failed (exit 1) - see the error above. Fix it and re-run just this step with: sudo sh dispatch.sh --only-rancher
If you ever see a step stop with truly no output at all (not even its own
first log line), that most often means a prerequisite step was
skipped - e.g. running --only-rancher on a box where --only-k3s (or a
full run) was never done first, so kubectl/helm don't exist yet.
rancher/run.sh and marketplace/run.sh
both check for kubectl (and rancher/run.sh for helm) explicitly and
die with a clear message in that case; if you hit a silent stop
anywhere else, please open an issue with the exact command you ran and
the last few lines of output.
npm install --global bats # or: apt-get install bats
bats tests/Covers script syntax (sh -n/bash -n on every run.sh/dispatch.sh),
the zz_use/common wiring every run.sh is expected to have, and
common/run.sh's pure logic (net_port, net_access,
all_network_ports, feature_package_json, dispatch_action) against a
small fixture tree. The features themselves (apt/Helm/k3s installs) need
a live root Ubuntu box to actually test, so that part of this repo has no
automated coverage.
This repo, tomgrv/devcontainer-features'
common-utils feature, and tomgrv/scripts
itself all share the same shape - a repo that's both a normal codebase
and a zz_use-installable source of scripts. Adopting it elsewhere:
- One top-level folder per script, each an npm workspace package:
<name>/package.json+<name>/run.sh(+ optionallyREADME.md,test.bats,config/). This is the one hard requirement -zz_use org/repo/<name>only works if<name>/run.shsits directly under the repo root.package.jsonneeds at minimum a"name"and"bin": {"<name>": "run.sh"}(the latter is fornpm/workspace tooling only -zz_useitself always installs under the literal folder name requested, never reads"bin"- see One folder per feature above for why that distinction matters, e.g.vps-tailscale/). - A root
setup.shthat installszz_use(fromtomgrv/scripts) ontoPATH, then execs yourpackage.json's"main"field (falling back to a rootmain.sh, or just stopping if neither exists) - copy this repo'ssetup.shverbatim; it doesn't hardcodeperspikapps/vpsanywhere, it only needs thetomgrv/scriptsURL and a"main"/main.shnext to itself. Your dispatcher (this repo'sdispatch.sh, or whatever entry point runs every script in sequence) is that"main"target, and runssetup.shonce, up front, forwarding its own args back into itself on the way out:Individual scripts don't bootstrapcommand -v zz_use > /dev/null 2>&1 || exec sh "$REPO_ROOT/setup.sh" "$@"
zz_usethemselves - that would mean onecurlper script instead of one total, exactly the duplication a rootsetup.shexists to avoid. They just fail fast if it's somehow still missing (e.g. run standalone, outside the dispatcher):Never embed thecommand -v zz_use > /dev/null 2>&1 || { echo "zz_use not found on PATH - run this repo's setup.sh first: curl -fsSL https://raw.githubusercontent.com/<org>/<repo>/main/setup.sh | sh" >&2 exit 1 }
tomgrv/scriptsURL directly in more than one place. - A shared
common/folder (or whatever you'd call it) for logic more than one script needs - not a "core" script itself, just another<name>/run.shfolder, sourced viazz_use <org>/<repo>/common; . commonrather than a relativesource ../lib/common.sh, so it resolves the same way whether a script runs from a local checkout, standalone, orzz_use-installed from anywhere. Exclude it (and anything else that's shared logic rather than an installable unit, like this repo'ssummary/) from whatever discovers your installable units by convention - seedispatch.sh'slist_feature_dirs()/feature_deps()here for how this repo does it. - Root
package.json: an npm workspaces root listing every folder explicitly (not a glob - see tomgrv/scripts's ownpackage.jsonfor the same convention), sonpm install/npm lsunderstand the whole graph andzz_use-resolvable folders that reference each other as real"dependencies"(@<org>/<repo>-<name>here) actually work. - Tests:
sh -n/bash -nevery script at minimum;batsfor anything with pure logic worth covering (seetests/here). Anything that genuinely needs a live target system (this repo's own apt/Helm/k3s installs) won't have automated coverage from within the repo alone - say so rather than skipping the question.