#!/usr/bin/env bash # # k3s on your own KnownHost unmanaged VPS, with real TLS, in about 4 minutes. # # curl -fsSL https://lab.knownhost.com/k3s | sudo bash -s -- --domain apps.example.com # # This is the thing you take home from the booth. It is not a demo -- it is the # same script we use to build the booth lab's base layer, minus the lab bits. # # You get: # * k3s (single node) with Traefik ingress # * cert-manager + a Let's Encrypt ClusterIssuer (HTTP-01) # * a hardened host: ufw, fail2ban, unattended-upgrades, SSH key-only # * a sample app on https:/// so you can see it working # * automatic etcd-free snapshots of the k3s datastore to /var/lib/kh-backup # # Tested on: Ubuntu 24.04 LTS and Debian 12, KnownHost unmanaged VPS. # Minimum sensible plan: Basic, 4 vCPU / 6 GB / 100 GB NVMe ($20/mo). # set -Eeuo pipefail DOMAIN="" EMAIL="" SAMPLE_APP=1 HARDEN=1 LOCKDOWN_SSH=0 # Channel, not a pinned patch: always resolves to the newest patch in a # maintained line. k8s 1.31 went EOL Nov 2025 -- do not pin back to it. K3S_CHANNEL="${K3S_CHANNEL:-v1.34}" # cert-manager v1.15 went EOL Feb 2025. v1.21 (Jul 2026) supports k8s # 1.33-1.36. Check https://cert-manager.io/docs/releases/ before an event. CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.21.0}" readonly C_OK=$'\033[32m' C_WARN=$'\033[33m' C_ERR=$'\033[31m' readonly C_DIM=$'\033[2m' C_B=$'\033[1m' C_N=$'\033[0m' step() { printf '\n%s==>%s %s%s%s\n' "$C_OK" "$C_N" "$C_B" "$1" "$C_N"; } info() { printf ' %s%s%s\n' "$C_DIM" "$1" "$C_N"; } warn() { printf ' %s! %s%s\n' "$C_WARN" "$1" "$C_N"; } die() { printf '\n%sERROR:%s %s\n\n' "$C_ERR" "$C_N" "$1" >&2; exit 1; } trap 'die "failed at line $LINENO -- the script is idempotent, just run it again"' ERR usage() { cat <= 1800 )) || die "k3s idles around 750 MB-1.6 GB. Below ~2 GB you will spend your evening reading OOM logs. The \$20/mo Basic (4 vCPU / 6 GB) is the smallest plan worth doing this on." (( MEM_MB >= 3500 )) || warn "${MEM_MB} MB is tight -- fine for learning, not for anything you page on" RESOLVED="$(getent hosts "$DOMAIN" | awk '{print $1; exit}' || true)" PUBIP="$(curl -fsS --max-time 8 https://api.ipify.org || true)" # Note: no apostrophes inside ${VAR:-default} -- bash treats a single quote in # the default word as opening a quoted section and the whole script fails to # parse, several hundred lines later, with a baffling error. [[ -n $RESOLVED ]] || die "$DOMAIN does not resolve yet. Add an A record pointing at ${PUBIP:-the IP of this server}, wait for it to propagate, then re-run. Let's Encrypt HTTP-01 needs it." if [[ -n $PUBIP && $RESOLVED != "$PUBIP" ]]; then warn "$DOMAIN -> $RESOLVED but this box is $PUBIP" warn "if that is not a proxy you control, cert issuance will fail" else info "dns: $DOMAIN -> $RESOLVED" fi # -------------------------------------------------------------- hardening -- if [[ $HARDEN -eq 1 ]]; then step "Hardening the host" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq ufw fail2ban unattended-upgrades curl jq >/dev/null ufw --force reset >/dev/null ufw default deny incoming >/dev/null ufw default allow outgoing >/dev/null for p in 22 80 443; do ufw allow "$p"/tcp >/dev/null; done # k3s pod and service networks must be allowed or the CNI cannot talk to # itself. This is the single most common "why is my k3s broken" after # enabling a firewall. ufw allow from 10.42.0.0/16 >/dev/null ufw allow from 10.43.0.0/16 >/dev/null ufw --force enable >/dev/null info "ufw: 22/80/443 open, pod+service CIDRs allowed, rest denied" systemctl enable --now fail2ban >/dev/null 2>&1 || true dpkg-reconfigure -f noninteractive unattended-upgrades >/dev/null 2>&1 || true info "fail2ban + unattended-upgrades on" if [[ $LOCKDOWN_SSH -eq 1 ]]; then if [[ -s /root/.ssh/authorized_keys ]] || \ compgen -G "/home/*/.ssh/authorized_keys" >/dev/null; then install -d -m 755 /etc/ssh/sshd_config.d cat >/etc/ssh/sshd_config.d/99-kh.conf <<'EOF' PasswordAuthentication no PermitRootLogin prohibit-password KbdInteractiveAuthentication no EOF sshd -t && systemctl reload ssh 2>/dev/null || systemctl reload sshd info "ssh: password auth disabled (keys only)" else warn "no authorized_keys found anywhere -- NOT disabling password auth" warn "locking you out of your own box would be a poor first impression" fi else info "ssh left as-is (pass --lockdown-ssh once key auth is confirmed)" fi fi # -------------------------------------------------------------------- k3s -- step "Installing k3s (channel ${K3S_CHANNEL})" if systemctl is-active --quiet k3s 2>/dev/null; then info "k3s already running -- leaving it alone" else # Keep servicelb: it is what binds host ports 80/443 to Traefik's # LoadBalancer Service. Without it nothing listens on :80 and Let's Encrypt # HTTP-01 can never validate. curl -sfL https://get.k3s.io | \ INSTALL_K3S_CHANNEL="$K3S_CHANNEL" \ INSTALL_K3S_EXEC="server --write-kubeconfig-mode 600 --tls-san ${DOMAIN}" \ sh -s - >/dev/null fi export KUBECONFIG=/etc/rancher/k3s/k3s.yaml kc() { k3s kubectl "$@"; } info "waiting for the API..." for i in $(seq 1 60); do kc get --raw='/readyz' >/dev/null 2>&1 && break sleep 2 (( i == 60 )) && die "k3s never became ready. Look at: journalctl -u k3s -n 200 --no-pager" done kc wait --for=condition=Ready node --all --timeout=180s >/dev/null info "node Ready: $(kc get node -o jsonpath='{.items[0].metadata.name}')" # Traefik must actually answer on 80 before cert-manager has any chance. # NOT `ss -lnt`: ServiceLB uses hostPort, which is iptables DNAT with no # listening socket, so ss reports nothing on a healthy cluster. Probe it. info "waiting for traefik to answer on port 80..." for i in $(seq 1 60); do CODE="$(curl -sS -o /dev/null -m 4 -w '%{http_code}' http://127.0.0.1/ 2>/dev/null || echo 000)" [[ $CODE != 000 ]] && break sleep 3 (( i == 60 )) && die "nothing answered on port 80 after 3 minutes. kubectl -n kube-system get svc traefik kubectl -n kube-system get pods | grep svclb No svclb-* pod means ServiceLB is disabled -- k3s needs it to map host ports to Traefik. Reinstall k3s without --disable servicelb: curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=${K3S_CHANNEL} \\ INSTALL_K3S_EXEC='server --write-kubeconfig-mode 600' sh -s -" done info "traefik answering on :80 (HTTP ${CODE})" # Make kubectl usable for the human who runs this. if ! command -v kubectl >/dev/null; then ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl fi for home in /root /home/*; do [[ -d $home ]] || continue install -d -m 700 "$home/.kube" install -m 600 /etc/rancher/k3s/k3s.yaml "$home/.kube/config" owner="$(stat -c %U "$home")" chown -R "$owner:$owner" "$home/.kube" 2>/dev/null || true done info "kubeconfig written to ~/.kube/config for every user on the box" # ----------------------------------------------------------- cert-manager -- step "Installing cert-manager ${CERT_MANAGER_VERSION}" kc apply -f \ "https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml" >/dev/null kc -n cert-manager rollout status deploy/cert-manager-webhook --timeout=300s >/dev/null # Staging issuer too. Let's Encrypt production has a hard rate limit of 5 # duplicate certificates per week -- if you are iterating on ingress config, # switch the annotation to letsencrypt-staging and save yourself a lockout. kc apply -f - >/dev/null </dev/null </dev/null info "sample app running" info "waiting for the certificate (usually 20-60s)..." for i in $(seq 1 36); do if kc -n hello get secret hello-tls >/dev/null 2>&1; then info "certificate issued" break fi sleep 5 (( i == 36 )) && warn "not issued yet: kc -n hello describe certificate hello-tls" done fi # --------------------------------------------------------------- backups --- step "Setting up datastore snapshots" install -d -m 700 /var/lib/kh-backup cat >/usr/local/bin/kh-backup <<'EOF' #!/bin/sh # k3s single-node uses SQLite, not etcd. Copy it consistently and keep 7 days. set -eu DEST=/var/lib/kh-backup STAMP=$(date +%Y%m%d-%H%M%S) sqlite3 /var/lib/rancher/k3s/server/db/state.db ".backup '$DEST/state-$STAMP.db'" \ 2>/dev/null || cp /var/lib/rancher/k3s/server/db/state.db "$DEST/state-$STAMP.db" gzip -f "$DEST/state-$STAMP.db" find "$DEST" -name 'state-*.db.gz' -mtime +7 -delete EOF chmod +x /usr/local/bin/kh-backup apt-get install -y -qq sqlite3 >/dev/null 2>&1 || true cat >/etc/systemd/system/kh-backup.service <<'EOF' [Unit] Description=Snapshot the k3s datastore [Service] Type=oneshot ExecStart=/usr/local/bin/kh-backup EOF cat >/etc/systemd/system/kh-backup.timer <<'EOF' [Unit] Description=Daily k3s datastore snapshot [Timer] OnCalendar=daily Persistent=true RandomizedDelaySec=1h [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable --now kh-backup.timer >/dev/null /usr/local/bin/kh-backup info "daily snapshots -> /var/lib/kh-backup (7 day retention)" warn "these live on the same disk as the cluster. Copy them off the box." # ------------------------------------------------------------------ done --- cat <