#!/usr/bin/env bash # # Klonkt — installer for a Debian/Ubuntu VPS. # Installs Node 20, Caddy (automatic HTTPS) and Klonkt as a systemd service. # # Safe on a server that ALREADY runs things: it won't upgrade your system Node, # auto-picks a free port, and skips Caddy if a webserver/reverse-proxy is already # listening on port 80/443 (you then get instructions to put Klonkt behind your # own proxy). # # Usage (as root), non-interactive: # curl -fsSL https://raw.githubusercontent.com/roboburr/klonkt/main/scripts/install.sh \ # | sudo bash -s -- --domain klonkt.example.com # Or interactively from a downloaded file: # sudo bash install.sh # # Re-running on the same server = update (git pull + restart). # Fully isolated alternative: Docker (see docker-compose.yml in the repo). # set -euo pipefail # ── Settings (override via env var or flag) ──────────────────────────────── KLONKT_REPO="${KLONKT_REPO:-https://github.com/roboburr/klonkt.git}" # `stable` = the release channel: it only moves forward to a version that has been verified, # so a self-host auto-update (klonkt-update) never pulls work-in-progress. Use `--branch main` # for the bleeding-edge dev branch instead. KLONKT_BRANCH_SET="${KLONKT_BRANCH:+1}" # channel chosen via env? (empty = no, "1" = yes) KLONKT_BRANCH="${KLONKT_BRANCH:-stable}" KLONKT_DIR="${KLONKT_DIR:-/opt/klonkt}" # Where instance data lives, one directory per slug. The code in KLONKT_DIR is # shared; everything an instance writes stays under here. KLONKT_DATA_ROOT="${KLONKT_DATA_ROOT:-/var/lib/klonkt}" # Short name for this instance: its directory under the data root and its # systemd unit (klonkt@). Derived from the domain when left empty. KLONKT_SLUG="${KLONKT_SLUG:-}" KLONKT_USER="${KLONKT_USER:-klonkt}" KLONKT_PORT="${KLONKT_PORT:-3000}" KLONKT_DOMAIN="${KLONKT_DOMAIN:-}" KLONKT_LANG="${KLONKT_DEFAULT_LANG:-}" NODE_MAJOR="${NODE_MAJOR:-20}" NO_CADDY="${KLONKT_NO_CADDY:-}" # set to 1 to NEVER install Caddy (own proxy) NODE_FORCE="${NODE_FORCE:-}" # set to 1 to (re)install system Node anyway PORT_EXPLICIT=0 BRANCH_EXPLICIT="${KLONKT_BRANCH_SET:-0}" # 1 = operator chose the channel (env or --branch) while [ $# -gt 0 ]; do case "$1" in --domain) KLONKT_DOMAIN="$2"; shift 2;; --repo) KLONKT_REPO="$2"; shift 2;; --branch) KLONKT_BRANCH="$2"; BRANCH_EXPLICIT=1; shift 2;; --dir) KLONKT_DIR="$2"; shift 2;; --port) KLONKT_PORT="$2"; PORT_EXPLICIT=1; shift 2;; --lang) KLONKT_LANG="$2"; shift 2;; --no-caddy) NO_CADDY=1; shift;; --force-node) NODE_FORCE=1; shift;; -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0;; *) echo "Unknown option: $1" >&2; exit 1;; esac done log() { printf '\n\033[1;33m▸ %s\033[0m\n' "$*"; } ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; } warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; } die() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; } as_klonkt() { runuser -u "$KLONKT_USER" -- env HOME="$KLONKT_DIR" "$@"; } port_busy() { ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${1}$"; } [ "$(id -u)" = 0 ] || die "Run this as root (sudo bash install.sh)." command -v apt-get >/dev/null || die "Debian/Ubuntu only (apt). On other systems use the Docker route." if [ -z "$KLONKT_DOMAIN" ]; then read -rp "Domain for Klonkt (e.g. klonkt.example.com): " KLONKT_DOMAIN /klonkt.git (or KLONKT_REPO=...).";; esac export DEBIAN_FRONTEND=noninteractive # ── Preflight: see what's already running, adapt instead of clobbering ────── log "Preflight (what's already running?)…" apt-get update -y >/dev/null apt-get install -y iproute2 >/dev/null 2>&1 || true # Port: busy? With --port → error. Otherwise auto-pick a free one. if port_busy "$KLONKT_PORT"; then if [ "$PORT_EXPLICIT" = 1 ]; then die "Port ${KLONKT_PORT} is already in use. Pick a free port with --port." fi picked="" for p in $(seq "$KLONKT_PORT" $((KLONKT_PORT+30))); do port_busy "$p" || { picked="$p"; break; } done [ -n "$picked" ] || die "No free port found near ${KLONKT_PORT}. Provide one with --port." warn "port ${KLONKT_PORT} busy → Klonkt uses ${picked}" KLONKT_PORT="$picked" else ok "port ${KLONKT_PORT} free" fi # Webserver on 80/443 that isn't Caddy? → skip Caddy, own-proxy mode. FOREIGN_PROXY=0 if [ -z "$NO_CADDY" ] && command -v ss >/dev/null 2>&1; then if ss -ltnpH 2>/dev/null | grep -E '[:.](80|443) ' | grep -viq 'caddy'; then NO_CADDY=1; FOREIGN_PROXY=1 warn "something is already listening on port 80/443 (not Caddy) → NOT installing Caddy; you'll get proxy instructions" fi fi # ── Node: respect an existing version, don't silently upgrade ────────────── log "Node ${NODE_MAJOR}.x…" if command -v node >/dev/null 2>&1 && [ -z "$NODE_FORCE" ]; then CUR="$(node -v | sed 's/v//;s/\..*//')" if [ "$CUR" -lt "$NODE_MAJOR" ]; then die "Node $(node -v) is already installed on this server; Klonkt needs ≥${NODE_MAJOR}. I will NOT auto-upgrade your system Node — that could break other apps. Options: (a) use the Docker route (own Node, touches nothing), or (b) upgrade Node yourself, or (c) force with NODE_FORCE=1 (at your own risk)." fi ok "using existing node $(node -v)" else curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - apt-get install -y nodejs ok "node $(node -v) installed" fi log "Other packages…" apt-get install -y curl ca-certificates git gnupg openssl build-essential python3 apt-get install -y webp >/dev/null 2>&1 || true # cwebp = image→WebP (optional) ok "base packages" if [ -z "$NO_CADDY" ]; then log "Caddy (reverse proxy + auto-HTTPS)…" if ! command -v caddy >/dev/null 2>&1; then curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list apt-get update -y apt-get install -y caddy fi ok "caddy present" fi log "Service user '${KLONKT_USER}'…" id -u "$KLONKT_USER" >/dev/null 2>&1 || useradd --system --home-dir "$KLONKT_DIR" --shell /usr/sbin/nologin "$KLONKT_USER" ok "user" log "Fetching Klonkt source…" if [ -d "$KLONKT_DIR/.git" ]; then git -C "$KLONKT_DIR" remote set-url origin "$KLONKT_REPO" # Re-run on an EXISTING install: keep the channel this install already tracks — never # silently switch it to the stable default. Only an explicit --branch / KLONKT_BRANCH # overrides; a fresh install (else-branch) uses the stable default. if [ "$BRANCH_EXPLICIT" != "1" ]; then _cur=$(git -C "$KLONKT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true) [ -n "$_cur" ] && [ "$_cur" != "HEAD" ] && KLONKT_BRANCH="$_cur" fi log "Channel: $KLONKT_BRANCH" git -C "$KLONKT_DIR" fetch --depth 1 origin "$KLONKT_BRANCH" # Check out FETCH_HEAD AS the target branch — not `reset --hard origin/$KLONKT_BRANCH` # (a single-branch/shallow clone, or one that started on a different branch like main, # has no origin/ ref → "ambiguous argument 'origin/stable'"), and not a plain # `reset --hard FETCH_HEAD` (that would leave the OLD local branch, e.g. main, pointing at # a stable commit → `git status` reports it as diverged from origin/main). `checkout -f -B` # makes the local branch BE $KLONKT_BRANCH at the fetched tip: robust, forced, no divergence. git -C "$KLONKT_DIR" checkout -qf -B "$KLONKT_BRANCH" FETCH_HEAD else [ -e "$KLONKT_DIR" ] && [ -n "$(ls -A "$KLONKT_DIR" 2>/dev/null)" ] && die "$KLONKT_DIR already exists and is not a git checkout. Pick --dir, or clean it up." mkdir -p "$KLONKT_DIR" git clone --depth 1 --branch "$KLONKT_BRANCH" "$KLONKT_REPO" "$KLONKT_DIR" fi chown -R "$KLONKT_USER:$KLONKT_USER" "$KLONKT_DIR" ok "code in $KLONKT_DIR" # --- where this instance keeps its data ------------------------------------- # New installs put data in /var/lib/klonkt/ so the checkout stays free of # user data and can be shared by more instances later. An install that already # has its .env inside the checkout is left exactly as it is: re-running the # installer must never move a live database. Convert those deliberately with # scripts/klonkt-migrate-data.sh. if [ -z "$KLONKT_SLUG" ]; then KLONKT_SLUG="$(printf '%s' "${KLONKT_DOMAIN:-default}" | sed 's/^www\.//' | cut -d. -f1 \ | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9._-')" [ -n "$KLONKT_SLUG" ] || KLONKT_SLUG=default fi # A database in the checkout but no .env is too ambiguous to guess at: refuse, # rather than start a fresh empty instance beside data nobody is reading. if [ ! -f "$KLONKT_DIR/.env" ] && [ -f "$KLONKT_DIR/storage/database.sqlite" ]; then die "found $KLONKT_DIR/storage/database.sqlite but no .env next to it. Put the .env back and re-run, or move the old storage/ aside first." fi if [ -f "$KLONKT_DIR/.env" ]; then LAYOUT=legacy ENV="$KLONKT_DIR/.env" DATA_DIR="$KLONKT_DIR/storage" SERVICE="klonkt" mkdir -p "$DATA_DIR/media" "$DATA_DIR/audio" chown -R "$KLONKT_USER:$KLONKT_USER" "$DATA_DIR" ok "existing layout kept (data inside $KLONKT_DIR; split it with scripts/klonkt-migrate-data.sh)" else LAYOUT=split DATA_DIR="$KLONKT_DATA_ROOT/$KLONKT_SLUG" ENV="$DATA_DIR/.env" SERVICE="klonkt@${KLONKT_SLUG}" mkdir -p "$DATA_DIR/media" "$DATA_DIR/audio" chown -R "$KLONKT_USER:$KLONKT_USER" "$DATA_DIR" chmod 750 "$DATA_DIR" ok "data in $DATA_DIR (instance '$KLONKT_SLUG')" fi log "Installing dependencies (npm ci)…" as_klonkt bash -c "cd '$KLONKT_DIR' && npm ci --omit=dev" ok "node_modules" log ".env…" if [ ! -f "$ENV" ]; then SECRET="$(openssl rand -hex 32)" { echo "NODE_ENV=production" echo "PORT=${KLONKT_PORT}" # Bind to loopback only: Caddy (this host) reaches it; the internet cannot # hit the app directly on its port, bypassing HTTPS. echo "HOST=127.0.0.1" echo "SESSION_SECRET=${SECRET}" # Absolute, so the app does not depend on its working directory and the # data can sit outside the checkout. Media subdirectories (avatars, # post-images, ...) follow MEDIA_PATH by themselves. echo "DATABASE_PATH=${DATA_DIR}/database.sqlite" echo "MEDIA_PATH=${DATA_DIR}/media" echo "AUDIO_PATH=${DATA_DIR}/audio" echo "PUBLIC_BASE_URL=https://${KLONKT_DOMAIN}" [ -n "$KLONKT_LANG" ] && echo "KLONKT_DEFAULT_LANG=${KLONKT_LANG}" } > "$ENV" chown "$KLONKT_USER:$KLONKT_USER" "$ENV"; chmod 600 "$ENV" ok "new .env (random SESSION_SECRET, app bound to 127.0.0.1)" else # sync the port in an existing .env with the chosen port if grep -q '^PORT=' "$ENV"; then sed -i "s/^PORT=.*/PORT=${KLONKT_PORT}/" "$ENV"; fi # harden older installs: bind to loopback if not already configured grep -q '^HOST=' "$ENV" || echo "HOST=127.0.0.1" >> "$ENV" ok "kept existing .env (port synced, bound to 127.0.0.1)" fi log "systemd service…" NODE_BIN="$(command -v node)" if [ "$LAYOUT" = split ]; then # One template, one service per instance. Adding a site later is a data # directory plus an .env, with no second copy of the code. sed -e "s#^User=klonkt\$#User=${KLONKT_USER}#" \ -e "s#^Group=klonkt\$#Group=${KLONKT_USER}#" \ -e "s#^WorkingDirectory=/opt/klonkt\$#WorkingDirectory=${KLONKT_DIR}#" \ -e "s#^EnvironmentFile=/var/lib/klonkt/%i/.env\$#EnvironmentFile=${KLONKT_DATA_ROOT}/%i/.env#" \ -e "s#^ReadWritePaths=/var/lib/klonkt/%i\$#ReadWritePaths=${KLONKT_DATA_ROOT}/%i#" \ -e "s#^ExecStart=/usr/bin/node src/server.js\$#ExecStart=${NODE_BIN} src/server.js#" \ "$KLONKT_DIR/deploy/klonkt@.service" > /etc/systemd/system/klonkt@.service chmod 0644 /etc/systemd/system/klonkt@.service systemctl daemon-reload systemctl enable --now "klonkt@${KLONKT_SLUG}" ok "klonkt@${KLONKT_SLUG} running on 127.0.0.1:${KLONKT_PORT}" else cat > /etc/systemd/system/klonkt.service < "$CADDY" elif ! grep -q "^${KLONKT_DOMAIN} {" "$CADDY"; then printf '\n%s\n' "$SITE_BLOCK" >> "$CADDY" fi caddy validate --config "$CADDY" --adapter caddyfile >/dev/null 2>&1 || die "Caddy config invalid — check $CADDY" systemctl reload caddy 2>/dev/null || systemctl restart caddy ok "caddy serving ${KLONKT_DOMAIN}" fi log "Update command 'klonkt-update'…" # Generated by the shared script so an install and a later layout migration # can never drift apart on what the updater restarts. KLONKT_DIR="$KLONKT_DIR" KLONKT_USER="$KLONKT_USER" \ KLONKT_DATA_ROOT="$KLONKT_DATA_ROOT" KLONKT_BRANCH="$KLONKT_BRANCH" \ bash "$KLONKT_DIR/scripts/klonkt-refresh-updater.sh" ok "klonkt-update" echo echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " Klonkt is running! 🎉" echo if [ -n "$NO_CADDY" ]; then echo " Klonkt listens on: http://127.0.0.1:${KLONKT_PORT}" if [ "$FOREIGN_PROXY" = 1 ]; then echo " A webserver is already running on 80/443 — put Klonkt behind it." fi echo " Example nginx:" echo " location / { proxy_pass http://127.0.0.1:${KLONKT_PORT}; proxy_set_header Host \$host;" echo " proxy_set_header X-Forwarded-Proto \$scheme; }" echo " Example Caddy:" echo " ${KLONKT_DOMAIN} { reverse_proxy 127.0.0.1:${KLONKT_PORT} }" else echo " • Open your site: https://${KLONKT_DOMAIN}" fi echo " • First run: go to /auth/register and create your admin account." echo echo " Manage: systemctl status ${SERVICE} · journalctl -u ${SERVICE} -f · klonkt-update" echo " Lost password: cd ${KLONKT_DIR} && runuser -u ${KLONKT_USER} -- env HOME=${KLONKT_DIR} npm run reset-admin" if [ "$LAYOUT" = split ]; then echo echo " Code: ${KLONKT_DIR} shared, nothing of yours lives here" echo " Data: ${DATA_DIR} database, uploads and .env — back up this one" echo " Another site on this server, sharing the same code:" echo " sudo bash ${KLONKT_DIR}/scripts/klonkt-add-instance.sh " fi echo echo " DNS: make sure A + AAAA of ${KLONKT_DOMAIN} point to this server." echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"