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 vps-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/setup.sh | sh
zz_use perspikapps/vps/vps-setup
sudo vps-setupThe first two lines bootstrap zz_use
(this repo's own package/dependency-fetching mechanism - see
One folder per feature) and fetch vps-setup,
the interactive/flag-driven orchestrator that runs every step and prints
a final connection summary; sudo vps-setup is the one that actually
installs anything, and is safe to re-run any time afterwards. See
Running vps-setup for the full picture - there is
no single one-liner that does everything end-to-end, deliberately: see
that section for why.
Run vps-setup with no arguments on an actual terminal 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-vps-tailscale, --skip-vps-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 \
vps-setupThere's no single command that clones this repo, bootstraps everything,
and runs it end-to-end - by design. Every folder here, vps-setup
included, is fetched and run the same way any tomgrv/scripts-style
package is: zz_use <origin>/<name> then run <name>. Bootstrapping is
always the same two steps:
curl -fsSL https://raw.githubusercontent.com/perspikapps/vps/main/setup.sh | sh
zz_use perspikapps/vps/vps-setupsetup.sh only installs zz_use (and the rest of the core zz_*
bundle) onto PATH - it's a bulk copy of
tomgrv/scripts's own setup.sh,
byte-for-byte, kept here only so the one-liner's URL lives under this
repo instead of pointing straight at tomgrv/scripts. It never runs
anything from this repo itself. zz_use perspikapps/vps/vps-setup is the
separate step that actually fetches vps-setup.
From there, sudo vps-setup [flags] is what installs (or removes)
anything - see Running a single step
below for the full flag reference, and
Interactive menu for running it with no flags on a
terminal. It's always safe to re-run: every step's script is idempotent,
and vps-setup itself re-clones/updates its own working checkout (see
below) each time.
A single zz_use perspikapps/vps/vps-setup only fetches the
vps-setup/ folder - orchestrating every other step means reading every
sibling folder's package.json/run.sh, which a single-folder fetch
doesn't give you. vps-setup/run.sh handles this itself: run from inside
a full local checkout (this repo cloned, vps-setup/run.sh invoked
directly), it uses that checkout as-is; run standalone (the zz_use
case, which is how the one-liner above works), it clones/updates a full
checkout into VPS_SETUP_DIR (default /opt/vps-setup) first, then
proceeds exactly the same way. VPS_SETUP_REPO_URL/VPS_SETUP_REPO_REF/
VPS_SETUP_DIR (same env vars as before) control that clone - see
Running from a non-standard branch or fork.
vps-setup runs nine feature folders, in the order each one's
package.json declares (vps.order - see
One folder per feature): vps-system, vps-security,
vps-tailscale, vps-cockpit, vps-k3s (includes Traefik configuration), vps-rancher,
vps-dockermanager, vps-marketplace, and vps-github-arc. All but
vps-github-arc run by default - it's opt-in (see
GitHub Actions Runner Controller).
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, e.g.--with-vps-github-arc.--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.
Once vps-setup is installed (see above), flags are
just ordinary arguments - no piped-curl shell-parsing gotchas to worry
about:
# Re-run just Rancher, e.g. after changing RANCHER_HOSTNAME:
sudo RANCHER_HOSTNAME=new.example.com vps-setup --only-vps-rancher
# Re-run Cockpit and the dockermanager plugin together, skipping everything else:
sudo vps-setup --only-vps-cockpit --only-vps-dockermanager
# Full run except Rancher (e.g. you're not using Kubernetes on this box):
sudo vps-setup --skip-vps-rancher --skip-vps-k3s
# Re-run just the marketplace catalog registration:
sudo vps-setup --only-vps-marketplaceThis is equivalent to (and a convenience wrapper around) invoking a
feature's own script directly, as shown in Layout below -
--only-vps-rancher just means "run vps-rancher/run.sh through
vps-setup'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 - vps-rancher/package.json declares
"@tomgrv/vps-k3s": "*", vps-marketplace/package.json declares
"@tomgrv/vps-rancher": "*" (Rancher's ClusterRepo API needs Rancher,
not k3s directly), and vps-dockermanager/package.json declares
"@tomgrv/vps-cockpit": "*" (its plugins are meaningless without Cockpit
itself). vps-setup reads that field directly (no separate config to
keep in sync) and resolves it transitively: enabling vps-marketplace
auto-enables vps-rancher, which in turn auto-enables vps-k3s, even
though vps-marketplace/package.json never mentions vps-k3s at all:
# vps-k3s isn't named here, but this still installs it - vps-rancher needs it,
# and vps-marketplace needs vps-rancher:
sudo vps-setup --only-vps-marketplace
# -> [vps-setup] Also enabling 'vps-rancher' (required by 'vps-marketplace').
# -> [vps-setup] Also enabling 'vps-k3s' (required by 'vps-rancher').The same dependencies field is read in reverse for
--down-<step>: bringing vps-k3s
down while vps-rancher/vps-marketplace are still enabled is refused,
and bringing vps-cockpit down while vps-dockermanager is still enabled
is refused too, since either would leave the dependent step broken.
Adding a new dependency for a feature is a one-line edit to its
package.json - see One folder per feature
below.
Run vps-setup with no arguments, on an actual terminal, to get a
menu instead of having to remember flag names:
sudo vps-setup==== VPS setup menu ====
1) * vps-system [up ] Base system update & essentials
2) * vps-security [up ] Firewall / SSH / fail2ban hardening
3) * vps-tailscale [up ] Tailscale install
4) * vps-cockpit [up ] Cockpit install
5) * vps-k3s [up ] k3s / kubectl / helm install (includes Traefik configuration)
6) * vps-rancher [up ] Rancher install
7) * vps-dockermanager [up ] cockpit-packagekit/files/dockermanager install
8) * vps-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 vps-setup --down-vps-marketplace
# Remove more than one step in the same run:
sudo vps-setup --down-vps-marketplace --down-vps-dockermanagerA step whose dependency is still enabled refuses to come down, so you don't accidentally break something still running:
sudo vps-setup --down-vps-k3s
# [vps-setup] Refusing to bring 'vps-k3s' down: 'vps-rancher' depends on it and is still enabled.
# [vps-setup] Also pass --down-vps-rancher, or --force-down to override (may leave 'vps-rancher' broken).Either bring the dependent step down in the same run (--down-vps-k3s --down-vps-rancher --down-vps-marketplace, to remove the whole cluster
cleanly), or pass --force-down if you really want to pull vps-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 |
|---|---|---|
vps-system |
(no down action - a base package upgrade, nothing to undo) | everything |
vps-security |
ufw rules (disables ufw entirely), sshd hardening, fail2ban jail | the admin user/password up created, if any |
vps-tailscale |
logs out of the tailnet, disables tailscaled |
the tailscale package itself (PURGE_TAILSCALE=true to remove it too) |
vps-cockpit |
the Cockpit packages and socket config - refused while vps-dockermanager is still enabled |
- |
vps-k3s |
k3s itself (via its own uninstaller) - takes Rancher, GitHub ARC, and anything installed via the Marketplace down with it | - |
vps-rancher |
the Helm release and its namespace | cert-manager, apps installed via Apps & Marketplace |
vps-dockermanager |
cockpit-dockermanager, cockpit-packagekit, cockpit-files | Docker itself (REMOVE_DOCKER=true to also remove it) |
vps-marketplace |
the ClusterRepo catalog registration only |
any apps already installed from it (uninstall those from Rancher's UI) |
vps-github-arc |
both Helm releases (controller and runner scale set), the GitHub App secret, and the github namespace |
- |
Each feature's run.sh also accepts the action directly if you'd rather
run it without going through vps-setup (e.g. from an existing
/opt/vps-setup checkout):
sudo bash vps-marketplace/run.sh down
sudo bash vps-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/setup.sh | sh
zz_use perspikapps/vps/vps-setup
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)" \
vps-setupThis 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.
Two separate fetches need to agree on the branch/fork you're testing:
zz_use's own fetch of the vps-setup folder itself (@<ref> in the
zz_use call), and vps-setup/run.sh's internal clone of the full repo
(VPS_SETUP_REPO_URL/VPS_SETUP_REPO_REF, since it needs every sibling
feature folder too - see Running vps-setup):
BRANCH=claude/vps-setup-ubuntu-scripts-br4ddo
curl -fsSL "https://raw.githubusercontent.com/perspikapps/vps/${BRANCH}/setup.sh" | sh
zz_use "perspikapps/vps/vps-setup@${BRANCH}"
sudo VPS_SETUP_REPO_REF="$BRANCH" vps-setupWarning
export FOO=bar then sudo vps-setup will NOT pick it up. sudo
resets the environment by default, so a plain shell export is invisible
to the command it runs - vps-setup will silently fall back to main
for its internal clone even though echo $VPS_SETUP_REPO_REF shows the
right value in your shell. Either put the assignment directly on the
sudo line (as above - it's passed through even with env reset on), or
use sudo -E (only works if your sudoers config allows it):
export VPS_SETUP_REPO_REF=my-branch # WRONG on its own: lost by sudo
sudo vps-setup # falls back to main - use sudo -E vps-setup insteadEnv vars for the internal clone:
| 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 (and fetch vps-setup
itself from the fork's ref too):
zz_use "perspikapps/vps/vps-setup@my-feature"
sudo VPS_SETUP_REPO_URL=https://github.com/<you>/vps.git \
VPS_SETUP_REPO_REF=my-feature \
vps-setupvps-setup 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 - only
zz_use's own fetch of vps-setup itself is cached (zz_update forces a
fresh download of that, bypassing the cache, if you've changed
vps-setup/run.sh itself on the branch you're testing).
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, vps-rancher/run.sh generates and saves one for
you automatically.
cloud-init/kairos-vps-setup.yaml is a
#cloud-config user-data file that bootstraps zz_use and runs
vps-setup 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, zz_use/vps-setup need no
sudo at all here - see the note in the file itself.
setup.sh- installszz_use(and the rest of the corezz_*bundle, fromtomgrv/scripts) ontoPATH, then stops - a bulk copy oftomgrv/scripts's ownsetup.sh, kept here only so the one-liner's URL lives under this repo. It never runs anything from this repo itself;zz_use perspikapps/vps/vps-setupis the separate next step - see Running vps-setup. Pin thetomgrv/scriptsref withZZ_ORIGIN_REF(defaultmain), or bootstrap from a fork entirely withZZ_ORIGIN. See Replicating this pattern in another repo.vps-setup/- the interactive/flag-driven orchestrator (see Running vps-setup): resolves which steps run (flags, the interactive menu, and dependencies read straight from each package.json), runs each feature'srun.shin order, idempotent and re-runnable, eitherupordown, then prints the final connection summary (Tailscale URL, Cockpit/Rancher credentials). Like every feature, a top-level<name>/{package.json,run.sh}folder,zz_use-installable on its own (zz_use perspikapps/vps/vps-setup) - but since a singlezz_usefetch only pulls this one folder, it clones/updates a full checkout of everything else itself when run standalone (see Running vps-setup). Not an installable step itself, though - its own feature discovery skips it (andvps-common/) explicitly.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) withoutvps-setupitself needing npm/node at all.cloud-init/kairos-vps-setup.yaml- cloud-init/Kairos user-data that bootstrapszz_useand runsvps-setupunattended on first boot.vps-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 (ok, andzz_logdirectly for everything else) 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.sh(vps-setupincluded) is abashscript. Like every feature,vps-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/vps-common. It isn't an installable step itself, though - feature discovery skips it (andvps-setup/) explicitly.vps-system/- apt update/upgrade, base tooling, unattended security upgrades.vps-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.vps-cockpit/- installs Cockpit, served on ports 9080/9083.vps-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.vps-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 onvps-k3s(see itspackage.json).vps-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. Depends onvps-cockpit(see itspackage.json) - these are Cockpit plugins, meaningless without Cockpit itself already installed.vps-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 onvps-k3s/vps-rancher.vps-github-arc/- installs GitHub Actions Runner Controller (ARC) via Helm, registering self-hosted runners against a GitHub org/repo - see GitHub Actions Runner Controller. Opt-in (off by default). Depends onvps-k3s.charts/- Helm charts for "extra" apps (ArgoCD, Epinio) that install onto the k3s cluster rather than the host itself - not avps-setupfeature 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:
vps-rancher/
package.json # "name": "@tomgrv/vps-rancher" (npm scope + this repo's
# own "vps-" prefix - see below), description,
# "bin": { "vps-rancher": "run.sh" } (matches the folder),
# "vps": { "default": true|false }, "scripts": { "test": "bats test.bats" },
# and "dependencies": { "@tomgrv/vps-<other-feature>": "*" }
run.sh # up() and down() - see Removing a feature, below
test.bats # syntax + shape checks - see Tests, below
README.md # this feature's env vars, up/down usage, and dependencies
Every feature folder in this repo follows this shape exactly - README.md
and test.bats aren't optional here, even though
Replicating this pattern in another repo
below lists them as optional for adopting the convention elsewhere.
Folder names carry no ordering (vps-rancher/, not
05-vps-rancher/) - install order is a plain integer,
package.json's vps.order, and vps-setup 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": { "vps-rancher": "run.sh" },
"vps": { "order": 5, "default": true },
"dependencies": { "@tomgrv/vps-k3s": "*", "@tomgrv/vps-common": "*" }
}"name" is always @tomgrv/<folder> - this repo's own npm scope plus
the folder's bare name, which itself always carries the vps- prefix
(vps-rancher/, not rancher/) - unlike
tomgrv/scripts's own convention of
an unscoped "name" identical to the folder, since that repo's scripts
aren't all prefixed the same way. "bin" follows the usual
{"<folder>": "run.sh"} shape, what makes
zz_use perspikapps/vps/vps-rancher resolvable (see
Running a single feature via zz_use
below) - and what every internal identity (the folder itself, "bin",
vps-setup's own flags/state tracking, e.g. --only-vps-rancher) is
built from: the package "name" with only the npm scope (@tomgrv/)
stripped, never the vps- prefix too. "dependencies" always includes
@tomgrv/vps-common (every feature sources it - see Layout),
plus any other feature folder it needs, keyed by that folder's own scoped
name; vps-setup's own dependency reading (auto-enable, --down-<step>
refusal) strips the npm scope the same way and explicitly excludes
vps-common/vps-setup from the result, since neither is an
installable step, and only counts a key that actually names a real
<folder>/{package.json,run.sh} - so an ordinary (non-feature) npm
dependency, if this repo ever adds one, won't be mistaken for a step to
auto-enable.
Because every folder already carries the vps- prefix uniformly, there's
no longer a special case here the way there used to be for
vps-tailscale/ alone (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 - so a folder named plain tailscale/ would have zz_use perspikapps/vps/tailscale shadow the actual binary it depends on).
Prefixing every folder the same way sidesteps that class of collision for
free, for any feature, not just this one.
Adding a new feature is: create vps-whatever/ with a
package.json (following the shape above, with an order that places it
where you want in the install sequence), a run.sh (up()/down() +
dispatch_action "$@" at the end, same as any other feature - see
vps-common/), a test.bats (see any existing feature's for the shape to
copy), and a README.md. vps-setup 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 vps-rancher's
vps-k3s dependency like any other workspace package. vps-setup
itself never needs npm installed, though: it reads each package.json's
dependencies/vps fields directly with jq (installed on demand via
ensure_jq in vps-setup/run.sh if missing, since this can run before
vps-system - the step that would otherwise install it - on a totally
fresh box).
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
vps-setup:
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 # vps-common/run.sh needs it
zz_use perspikapps/vps/vps-rancher
sudo vps-rancher upzz_use's [org/repo/]<tool>[@ref] syntax resolves perspikapps/vps as
the origin and vps-rancher as the script, downloads this repo (cached
locally after the first call, per-origin/ref - see
tomgrv/scripts's README),
and symlinks vps-rancher/run.sh onto PATH as vps-rancher. Since every
feature's own run.sh in turn fetches vps-common/run.sh from this same
repo the same way, a feature installed this way works exactly like it
would through vps-setup - 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".) vps-rancher/package.json
carries rancher_http/rancher_https, vps-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.
vps-security/run.sh doesn't know about any of that port detail
itself: it calls vps-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
vps-common/run.sh's net_port() helper (resolving its own package.json
automatically - see the function's comment for how vps-setup/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-vps-security --only-vps-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
vps-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, vps-setup 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-vps-tailscale if you genuinely want to run without
Tailscale (you can join manually later with tailscale up, then sudo vps-setup --only-vps-tailscale).
vps-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-vps-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.
vps-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
vps-setup any more. Instead, this repo publishes them as a standard
Helm chart repo from charts/, and the vps-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. vps-setup itself no longer knows how to install/uninstall
these apps directly; vps-marketplace/run.sh down only removes the catalog
registration; uninstall an already-installed app from Rancher's UI.
Why this split: vps-cockpit/ and vps-dockermanager/ stay as vps-setup
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 vps-setup/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.
Opt-in - pass --with-vps-github-arc (or --only-vps-github-arc) to
install it; it doesn't run on a plain vps-setup with no flags.
vps-github-arc/run.sh installs
GitHub Actions Runner Controller
(ARC) via its two official Helm charts - the controller
(gha-runner-scale-set-controller) and a runner scale set
(gha-runner-scale-set) - both into a single github namespace on the
k3s cluster, so self-hosted GitHub Actions runners can be dispatched
straight onto this VPS. Depends on vps-k3s (see its package.json).
Authentication is via a GitHub App, the method the docs recommend over a personal access token - you create the App yourself (following the quickstart above) and give this script its credentials; it doesn't create the App for you.
- Required:
GITHUB_ARC_CONFIG_URL(the org or repo the runners register against, e.g.https://github.com/perspikappsorhttps://github.com/perspikapps/vps),GITHUB_ARC_APP_ID,GITHUB_ARC_APP_INSTALLATION_ID, andGITHUB_ARC_APP_PRIVATE_KEY_FILE(a path to the App's private key PEM- not the key content itself, so it's never passed on the command line or logged).
- Scaling:
GITHUB_ARC_MIN_RUNNERS/GITHUB_ARC_MAX_RUNNERS(default0/5) control the runner scale set's autoscaling range. - Neither chart binds a port
ufwneeds to know about: runners connect outbound to GitHub, nothing needs to be reachable from outside the cluster.
sudo GITHUB_ARC_CONFIG_URL=https://github.com/perspikapps/vps \
GITHUB_ARC_APP_ID=123456 \
GITHUB_ARC_APP_INSTALLATION_ID=78901234 \
GITHUB_ARC_APP_PRIVATE_KEY_FILE=/root/github-arc-app.private-key.pem \
vps-setup --only-vps-github-arcCheck on it with kubectl -n github get autoscalingrunnersets and
kubectl -n github get pods; vps-setup reports whether it's installed
like every other step.
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-vps-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 vps-marketplace step registers |
GITHUB_ARC_CONFIG_URL |
unset | Org/repo URL runners register against (required to run vps-github-arc) |
GITHUB_ARC_APP_ID |
unset | GitHub App ID (required to run vps-github-arc) |
GITHUB_ARC_APP_INSTALLATION_ID |
unset | GitHub App installation ID (required to run vps-github-arc) |
GITHUB_ARC_APP_PRIVATE_KEY_FILE |
unset | Path to the GitHub App's private key PEM (required to run vps-github-arc) |
GITHUB_ARC_MIN_RUNNERS / GITHUB_ARC_MAX_RUNNERS |
0 / 5 |
Runner scale set autoscaling range |
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 vps-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
vps-common/run.sh from this repo via zz_use perspikapps/vps/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 vps-rancher/run.shThis is what vps-setup --only-<step>, described in
Running a single step (or a subset)
above, does for you.
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 vps-rancher/run.sh up) - see
Removing a feature for what each
step's down does.
Every script runs under set -euo pipefail and sources vps-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/vps-rancher/run.sh line 52: helm upgrade --install rancher ...
When a step fails during a full vps-setup 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' (vps-rancher/run.sh up) failed (exit 1) - see the error above. Fix it and re-run just this step with: sudo vps-setup --only-vps-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-vps-rancher on a box where --only-vps-k3s (or a
full run) was never done first, so kubectl/helm don't exist yet.
vps-rancher/run.sh and vps-marketplace/run.sh
both check for kubectl (and vps-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
npm test # or: bats --recursive .Following tomgrv/scripts's own
convention, every feature folder carries its own <name>/test.bats
(runnable on its own with bats <name>/test.bats, or via that folder's
npm test), covering that folder's run.sh syntax (bash -n), its
zz_use/vps-common wiring, up()/(where applicable) down(), and its
package.json's bin/vps.order/dependencies shape.
vps-common/test.bats additionally covers vps-common/run.sh's
pure logic (net_port, net_access, all_network_ports,
feature_package_json, dispatch_action) against a small fixture tree.
tests/ holds only what doesn't belong to any single folder:
setup.sh itself, and invariants spanning every
folder's run.sh/package.json (e.g. no leftover log/warn/die
wrappers - see tests/test-syntax.bats). 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(and the corezz_*bundle, fromtomgrv/scripts) ontoPATH, then stops - a bulk copy oftomgrv/scripts's ownsetup.sh(see Layout), kept in your repo only so the one-liner's URL lives under it instead of pointing straight attomgrv/scripts. It doesn't hardcodeperspikapps/vpsanywhere (or anything else about this repo) - copy it verbatim, unmodified. If you need something that runs every script in sequence (this repo'svps-setup, described throughout this README), that's its own ordinary<name>/{package.json,run.sh}folder like any other - fetched and run as its own explicit step (zz_use <org>/<repo>/<name>, then run<name>), never auto-exec'd bysetup.shitself. Individual scripts don't bootstrapzz_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, beforesetup.sh):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's orchestrator,vps-setup/) from whatever discovers your installable units by convention - seevps-setup/run.sh's ownlist_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.