| 1 | #!/usr/bin/env bash
|
|---|
| 2 | #
|
|---|
| 3 | # Klonkt — installer for a Debian/Ubuntu VPS.
|
|---|
| 4 | # Installs Node 20, Caddy (automatic HTTPS) and Klonkt as a systemd service.
|
|---|
| 5 | #
|
|---|
| 6 | # Safe on a server that ALREADY runs things: it won't upgrade your system Node,
|
|---|
| 7 | # auto-picks a free port, and skips Caddy if a webserver/reverse-proxy is already
|
|---|
| 8 | # listening on port 80/443 (you then get instructions to put Klonkt behind your
|
|---|
| 9 | # own proxy).
|
|---|
| 10 | #
|
|---|
| 11 | # Usage (as root), non-interactive:
|
|---|
| 12 | # curl -fsSL https://raw.githubusercontent.com/roboburr/klonkt/main/scripts/install.sh \
|
|---|
| 13 | # | sudo bash -s -- --domain klonkt.example.com
|
|---|
| 14 | # Or interactively from a downloaded file:
|
|---|
| 15 | # sudo bash install.sh
|
|---|
| 16 | #
|
|---|
| 17 | # Re-running on the same server = update (git pull + restart).
|
|---|
| 18 | # Fully isolated alternative: Docker (see docker-compose.yml in the repo).
|
|---|
| 19 | #
|
|---|
| 20 | set -euo pipefail
|
|---|
| 21 |
|
|---|
| 22 | # ── Settings (override via env var or flag) ────────────────────────────────
|
|---|
| 23 | KLONKT_REPO="${KLONKT_REPO:-https://github.com/roboburr/klonkt.git}"
|
|---|
| 24 | # `stable` = the release channel: it only moves forward to a version that has been verified,
|
|---|
| 25 | # so a self-host auto-update (klonkt-update) never pulls work-in-progress. Use `--branch main`
|
|---|
| 26 | # for the bleeding-edge dev branch instead.
|
|---|
| 27 | KLONKT_BRANCH="${KLONKT_BRANCH:-stable}"
|
|---|
| 28 | KLONKT_DIR="${KLONKT_DIR:-/opt/klonkt}"
|
|---|
| 29 | KLONKT_USER="${KLONKT_USER:-klonkt}"
|
|---|
| 30 | KLONKT_PORT="${KLONKT_PORT:-3000}"
|
|---|
| 31 | KLONKT_DOMAIN="${KLONKT_DOMAIN:-}"
|
|---|
| 32 | KLONKT_LANG="${KLONKT_DEFAULT_LANG:-}"
|
|---|
| 33 | NODE_MAJOR="${NODE_MAJOR:-20}"
|
|---|
| 34 | NO_CADDY="${KLONKT_NO_CADDY:-}" # set to 1 to NEVER install Caddy (own proxy)
|
|---|
| 35 | NODE_FORCE="${NODE_FORCE:-}" # set to 1 to (re)install system Node anyway
|
|---|
| 36 | PORT_EXPLICIT=0
|
|---|
| 37 |
|
|---|
| 38 | while [ $# -gt 0 ]; do
|
|---|
| 39 | case "$1" in
|
|---|
| 40 | --domain) KLONKT_DOMAIN="$2"; shift 2;;
|
|---|
| 41 | --repo) KLONKT_REPO="$2"; shift 2;;
|
|---|
| 42 | --branch) KLONKT_BRANCH="$2"; shift 2;;
|
|---|
| 43 | --dir) KLONKT_DIR="$2"; shift 2;;
|
|---|
| 44 | --port) KLONKT_PORT="$2"; PORT_EXPLICIT=1; shift 2;;
|
|---|
| 45 | --lang) KLONKT_LANG="$2"; shift 2;;
|
|---|
| 46 | --no-caddy) NO_CADDY=1; shift;;
|
|---|
| 47 | --force-node) NODE_FORCE=1; shift;;
|
|---|
| 48 | -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
|
|---|
| 49 | *) echo "Unknown option: $1" >&2; exit 1;;
|
|---|
| 50 | esac
|
|---|
| 51 | done
|
|---|
| 52 |
|
|---|
| 53 | log() { printf '\n\033[1;33m▸ %s\033[0m\n' "$*"; }
|
|---|
| 54 | ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; }
|
|---|
| 55 | warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; }
|
|---|
| 56 | die() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
|
|---|
| 57 | as_klonkt() { runuser -u "$KLONKT_USER" -- env HOME="$KLONKT_DIR" "$@"; }
|
|---|
| 58 | port_busy() { ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${1}$"; }
|
|---|
| 59 |
|
|---|
| 60 | [ "$(id -u)" = 0 ] || die "Run this as root (sudo bash install.sh)."
|
|---|
| 61 | command -v apt-get >/dev/null || die "Debian/Ubuntu only (apt). On other systems use the Docker route."
|
|---|
| 62 |
|
|---|
| 63 | if [ -z "$KLONKT_DOMAIN" ]; then
|
|---|
| 64 | read -rp "Domain for Klonkt (e.g. klonkt.example.com): " KLONKT_DOMAIN </dev/tty || true
|
|---|
| 65 | fi
|
|---|
| 66 | [ -n "$KLONKT_DOMAIN" ] || die "No domain given (--domain or KLONKT_DOMAIN)."
|
|---|
| 67 | case "$KLONKT_REPO" in
|
|---|
| 68 | *OWNER/*) die "Set the real repo URL first: --repo https://github.com/<you>/klonkt.git (or KLONKT_REPO=...).";;
|
|---|
| 69 | esac
|
|---|
| 70 |
|
|---|
| 71 | export DEBIAN_FRONTEND=noninteractive
|
|---|
| 72 |
|
|---|
| 73 | # ── Preflight: see what's already running, adapt instead of clobbering ──────
|
|---|
| 74 | log "Preflight (what's already running?)…"
|
|---|
| 75 | apt-get update -y >/dev/null
|
|---|
| 76 | apt-get install -y iproute2 >/dev/null 2>&1 || true
|
|---|
| 77 |
|
|---|
| 78 | # Port: busy? With --port → error. Otherwise auto-pick a free one.
|
|---|
| 79 | if port_busy "$KLONKT_PORT"; then
|
|---|
| 80 | if [ "$PORT_EXPLICIT" = 1 ]; then
|
|---|
| 81 | die "Port ${KLONKT_PORT} is already in use. Pick a free port with --port."
|
|---|
| 82 | fi
|
|---|
| 83 | picked=""
|
|---|
| 84 | for p in $(seq "$KLONKT_PORT" $((KLONKT_PORT+30))); do
|
|---|
| 85 | port_busy "$p" || { picked="$p"; break; }
|
|---|
| 86 | done
|
|---|
| 87 | [ -n "$picked" ] || die "No free port found near ${KLONKT_PORT}. Provide one with --port."
|
|---|
| 88 | warn "port ${KLONKT_PORT} busy → Klonkt uses ${picked}"
|
|---|
| 89 | KLONKT_PORT="$picked"
|
|---|
| 90 | else
|
|---|
| 91 | ok "port ${KLONKT_PORT} free"
|
|---|
| 92 | fi
|
|---|
| 93 |
|
|---|
| 94 | # Webserver on 80/443 that isn't Caddy? → skip Caddy, own-proxy mode.
|
|---|
| 95 | FOREIGN_PROXY=0
|
|---|
| 96 | if [ -z "$NO_CADDY" ] && command -v ss >/dev/null 2>&1; then
|
|---|
| 97 | if ss -ltnpH 2>/dev/null | grep -E '[:.](80|443) ' | grep -viq 'caddy'; then
|
|---|
| 98 | NO_CADDY=1; FOREIGN_PROXY=1
|
|---|
| 99 | warn "something is already listening on port 80/443 (not Caddy) → NOT installing Caddy; you'll get proxy instructions"
|
|---|
| 100 | fi
|
|---|
| 101 | fi
|
|---|
| 102 |
|
|---|
| 103 | # ── Node: respect an existing version, don't silently upgrade ──────────────
|
|---|
| 104 | log "Node ${NODE_MAJOR}.x…"
|
|---|
| 105 | if command -v node >/dev/null 2>&1 && [ -z "$NODE_FORCE" ]; then
|
|---|
| 106 | CUR="$(node -v | sed 's/v//;s/\..*//')"
|
|---|
| 107 | if [ "$CUR" -lt "$NODE_MAJOR" ]; then
|
|---|
| 108 | die "Node $(node -v) is already installed on this server; Klonkt needs ≥${NODE_MAJOR}.
|
|---|
| 109 | I will NOT auto-upgrade your system Node — that could break other apps.
|
|---|
| 110 | Options: (a) use the Docker route (own Node, touches nothing), or
|
|---|
| 111 | (b) upgrade Node yourself, or (c) force with NODE_FORCE=1 (at your own risk)."
|
|---|
| 112 | fi
|
|---|
| 113 | ok "using existing node $(node -v)"
|
|---|
| 114 | else
|
|---|
| 115 | curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
|
|---|
| 116 | apt-get install -y nodejs
|
|---|
| 117 | ok "node $(node -v) installed"
|
|---|
| 118 | fi
|
|---|
| 119 |
|
|---|
| 120 | log "Other packages…"
|
|---|
| 121 | apt-get install -y curl ca-certificates git gnupg openssl build-essential python3
|
|---|
| 122 | apt-get install -y webp >/dev/null 2>&1 || true # cwebp = image→WebP (optional)
|
|---|
| 123 | ok "base packages"
|
|---|
| 124 |
|
|---|
| 125 | if [ -z "$NO_CADDY" ]; then
|
|---|
| 126 | log "Caddy (reverse proxy + auto-HTTPS)…"
|
|---|
| 127 | if ! command -v caddy >/dev/null 2>&1; then
|
|---|
| 128 | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
|---|
| 129 | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list
|
|---|
| 130 | apt-get update -y
|
|---|
| 131 | apt-get install -y caddy
|
|---|
| 132 | fi
|
|---|
| 133 | ok "caddy present"
|
|---|
| 134 | fi
|
|---|
| 135 |
|
|---|
| 136 | log "Service user '${KLONKT_USER}'…"
|
|---|
| 137 | id -u "$KLONKT_USER" >/dev/null 2>&1 || useradd --system --home-dir "$KLONKT_DIR" --shell /usr/sbin/nologin "$KLONKT_USER"
|
|---|
| 138 | ok "user"
|
|---|
| 139 |
|
|---|
| 140 | log "Fetching Klonkt source…"
|
|---|
| 141 | if [ -d "$KLONKT_DIR/.git" ]; then
|
|---|
| 142 | git -C "$KLONKT_DIR" remote set-url origin "$KLONKT_REPO"
|
|---|
| 143 | git -C "$KLONKT_DIR" fetch --depth 1 origin "$KLONKT_BRANCH"
|
|---|
| 144 | git -C "$KLONKT_DIR" reset --hard "origin/$KLONKT_BRANCH"
|
|---|
| 145 | else
|
|---|
| 146 | [ -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."
|
|---|
| 147 | mkdir -p "$KLONKT_DIR"
|
|---|
| 148 | git clone --depth 1 --branch "$KLONKT_BRANCH" "$KLONKT_REPO" "$KLONKT_DIR"
|
|---|
| 149 | fi
|
|---|
| 150 | mkdir -p "$KLONKT_DIR/storage/media" "$KLONKT_DIR/storage/audio"
|
|---|
| 151 | chown -R "$KLONKT_USER:$KLONKT_USER" "$KLONKT_DIR"
|
|---|
| 152 | ok "code in $KLONKT_DIR"
|
|---|
| 153 |
|
|---|
| 154 | log "Installing dependencies (npm ci)…"
|
|---|
| 155 | as_klonkt bash -c "cd '$KLONKT_DIR' && npm ci --omit=dev"
|
|---|
| 156 | ok "node_modules"
|
|---|
| 157 |
|
|---|
| 158 | log ".env…"
|
|---|
| 159 | ENV="$KLONKT_DIR/.env"
|
|---|
| 160 | if [ ! -f "$ENV" ]; then
|
|---|
| 161 | SECRET="$(openssl rand -hex 32)"
|
|---|
| 162 | {
|
|---|
| 163 | echo "NODE_ENV=production"
|
|---|
| 164 | echo "PORT=${KLONKT_PORT}"
|
|---|
| 165 | # Bind to loopback only: Caddy (this host) reaches it; the internet cannot
|
|---|
| 166 | # hit the app directly on its port, bypassing HTTPS.
|
|---|
| 167 | echo "HOST=127.0.0.1"
|
|---|
| 168 | echo "SESSION_SECRET=${SECRET}"
|
|---|
| 169 | echo "DATABASE_PATH=./storage/database.sqlite"
|
|---|
| 170 | echo "MEDIA_PATH=./storage/media"
|
|---|
| 171 | echo "AUDIO_PATH=./storage/audio"
|
|---|
| 172 | echo "PUBLIC_BASE_URL=https://${KLONKT_DOMAIN}"
|
|---|
| 173 | [ -n "$KLONKT_LANG" ] && echo "KLONKT_DEFAULT_LANG=${KLONKT_LANG}"
|
|---|
| 174 | } > "$ENV"
|
|---|
| 175 | chown "$KLONKT_USER:$KLONKT_USER" "$ENV"; chmod 600 "$ENV"
|
|---|
| 176 | ok "new .env (random SESSION_SECRET, app bound to 127.0.0.1)"
|
|---|
| 177 | else
|
|---|
| 178 | # sync the port in an existing .env with the chosen port
|
|---|
| 179 | if grep -q '^PORT=' "$ENV"; then sed -i "s/^PORT=.*/PORT=${KLONKT_PORT}/" "$ENV"; fi
|
|---|
| 180 | # harden older installs: bind to loopback if not already configured
|
|---|
| 181 | grep -q '^HOST=' "$ENV" || echo "HOST=127.0.0.1" >> "$ENV"
|
|---|
| 182 | ok "kept existing .env (port synced, bound to 127.0.0.1)"
|
|---|
| 183 | fi
|
|---|
| 184 |
|
|---|
| 185 | log "systemd service…"
|
|---|
| 186 | NODE_BIN="$(command -v node)"
|
|---|
| 187 | cat > /etc/systemd/system/klonkt.service <<EOF
|
|---|
| 188 | [Unit]
|
|---|
| 189 | Description=Klonkt
|
|---|
| 190 | After=network-online.target
|
|---|
| 191 | Wants=network-online.target
|
|---|
| 192 |
|
|---|
| 193 | [Service]
|
|---|
| 194 | Type=simple
|
|---|
| 195 | User=${KLONKT_USER}
|
|---|
| 196 | WorkingDirectory=${KLONKT_DIR}
|
|---|
| 197 | ExecStart=${NODE_BIN} src/server.js
|
|---|
| 198 | Environment=NODE_ENV=production
|
|---|
| 199 | Restart=always
|
|---|
| 200 | RestartSec=3
|
|---|
| 201 | NoNewPrivileges=true
|
|---|
| 202 | ProtectSystem=full
|
|---|
| 203 | PrivateTmp=true
|
|---|
| 204 |
|
|---|
| 205 | [Install]
|
|---|
| 206 | WantedBy=multi-user.target
|
|---|
| 207 | EOF
|
|---|
| 208 | systemctl daemon-reload
|
|---|
| 209 | systemctl enable --now klonkt
|
|---|
| 210 | ok "klonkt.service running on 127.0.0.1:${KLONKT_PORT}"
|
|---|
| 211 |
|
|---|
| 212 | if [ -z "$NO_CADDY" ]; then
|
|---|
| 213 | log "Caddy config for ${KLONKT_DOMAIN}…"
|
|---|
| 214 | CADDY=/etc/caddy/Caddyfile
|
|---|
| 215 | SITE_BLOCK="${KLONKT_DOMAIN} {
|
|---|
| 216 | reverse_proxy 127.0.0.1:${KLONKT_PORT}
|
|---|
| 217 | encode gzip zstd
|
|---|
| 218 | }"
|
|---|
| 219 | touch "$CADDY"
|
|---|
| 220 | if grep -q '/usr/share/caddy' "$CADDY"; then
|
|---|
| 221 | cp "$CADDY" "${CADDY}.bak.$(date +%s)"
|
|---|
| 222 | printf '%s\n' "$SITE_BLOCK" > "$CADDY"
|
|---|
| 223 | elif ! grep -q "^${KLONKT_DOMAIN} {" "$CADDY"; then
|
|---|
| 224 | printf '\n%s\n' "$SITE_BLOCK" >> "$CADDY"
|
|---|
| 225 | fi
|
|---|
| 226 | caddy validate --config "$CADDY" --adapter caddyfile >/dev/null 2>&1 || die "Caddy config invalid — check $CADDY"
|
|---|
| 227 | systemctl reload caddy 2>/dev/null || systemctl restart caddy
|
|---|
| 228 | ok "caddy serving ${KLONKT_DOMAIN}"
|
|---|
| 229 | fi
|
|---|
| 230 |
|
|---|
| 231 | log "Update command 'klonkt-update'…"
|
|---|
| 232 | cat > /usr/local/bin/klonkt-update <<EOF
|
|---|
| 233 | #!/usr/bin/env bash
|
|---|
| 234 | set -euo pipefail
|
|---|
| 235 | D="${KLONKT_DIR}"
|
|---|
| 236 | B=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD 2>/dev/null || true)
|
|---|
| 237 | runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --depth 1 origin ${KLONKT_BRANCH}
|
|---|
| 238 | runuser -u ${KLONKT_USER} -- git -C "\$D" reset --hard origin/${KLONKT_BRANCH}
|
|---|
| 239 | A=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD)
|
|---|
| 240 | if [ "\$B" = "\$A" ]; then
|
|---|
| 241 | echo "Klonkt is already up to date (\$A) — nothing to do."
|
|---|
| 242 | exit 0
|
|---|
| 243 | fi
|
|---|
| 244 | if ! runuser -u ${KLONKT_USER} -- git -C "\$D" diff --quiet "\$B" "\$A" -- package-lock.json 2>/dev/null; then
|
|---|
| 245 | runuser -u ${KLONKT_USER} -- env HOME="\$D" bash -c "cd '\$D' && npm ci --omit=dev"
|
|---|
| 246 | fi
|
|---|
| 247 | systemctl restart klonkt
|
|---|
| 248 | echo "Klonkt updated (\$A) + restarted."
|
|---|
| 249 | EOF
|
|---|
| 250 | chmod +x /usr/local/bin/klonkt-update
|
|---|
| 251 | ok "klonkt-update"
|
|---|
| 252 |
|
|---|
| 253 | echo
|
|---|
| 254 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|---|
| 255 | echo " Klonkt is running! 🎉"
|
|---|
| 256 | echo
|
|---|
| 257 | if [ -n "$NO_CADDY" ]; then
|
|---|
| 258 | echo " Klonkt listens on: http://127.0.0.1:${KLONKT_PORT}"
|
|---|
| 259 | if [ "$FOREIGN_PROXY" = 1 ]; then
|
|---|
| 260 | echo " A webserver is already running on 80/443 — put Klonkt behind it."
|
|---|
| 261 | fi
|
|---|
| 262 | echo " Example nginx:"
|
|---|
| 263 | echo " location / { proxy_pass http://127.0.0.1:${KLONKT_PORT}; proxy_set_header Host \$host;"
|
|---|
| 264 | echo " proxy_set_header X-Forwarded-Proto \$scheme; }"
|
|---|
| 265 | echo " Example Caddy:"
|
|---|
| 266 | echo " ${KLONKT_DOMAIN} { reverse_proxy 127.0.0.1:${KLONKT_PORT} }"
|
|---|
| 267 | else
|
|---|
| 268 | echo " • Open your site: https://${KLONKT_DOMAIN}"
|
|---|
| 269 | fi
|
|---|
| 270 | echo " • First run: go to /auth/register and create your admin account."
|
|---|
| 271 | echo
|
|---|
| 272 | echo " Manage: systemctl status klonkt · journalctl -u klonkt -f · klonkt-update"
|
|---|
| 273 | echo " Lost password: cd ${KLONKT_DIR} && runuser -u ${KLONKT_USER} -- env HOME=${KLONKT_DIR} npm run reset-admin"
|
|---|
| 274 | echo
|
|---|
| 275 | echo " DNS: make sure A + AAAA of ${KLONKT_DOMAIN} point to this server."
|
|---|
| 276 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|---|