Index: deploy/DEPLOY.md
===================================================================
--- deploy/DEPLOY.md	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
+++ deploy/DEPLOY.md	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
@@ -198,4 +198,29 @@
 (`/sites/<slug>/`) and its own PWA scope, so installing the PWA from one
 site won't navigate into another.
+
+These sites live inside one Klonkt, sharing one database and one process. For
+separate domains with separate databases, see the next section instead.
+
+---
+
+## 9b. Several independent Klonkts on one server
+
+A different question from the one above: not several sites inside one Klonkt,
+but several Klonkts side by side, each with its own domain, database and
+uploads, all sharing a single copy of the code.
+
+That layout keeps user data out of the checkout:
+
+```
+/opt/klonkt/                 the code, shared by every instance
+/var/lib/klonkt/<slug>/      one instance: .env, database, uploads
+```
+
+Adding a site is then a directory plus an `.env`, and `klonkt-update` moves all
+of them to the new code in one step. Existing single site installs can be
+converted with `scripts/klonkt-migrate-data.sh`.
+
+See **[MULTI-INSTANCE.md](MULTI-INSTANCE.md)** for the layout, the migration,
+the file ownership and the backup routine.
 
 ---
Index: deploy/MULTI-INSTANCE.md
===================================================================
--- deploy/MULTI-INSTANCE.md	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
+++ deploy/MULTI-INSTANCE.md	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
@@ -0,0 +1,179 @@
+# Running several Klonkts on one server
+
+One copy of the code, one directory of data per site. Adding a site is a
+directory and an `.env` file; updating is one command for all of them.
+
+```
+/opt/klonkt/                  the code. Shared. Replaceable. No user data.
+/var/lib/klonkt/<slug>/       one instance: its .env, database, uploads.
+    .env
+    database.sqlite
+    media/
+    audio/
+```
+
+A *slug* is just a short name for an instance: `boiert`, `blog`, `label`. It is
+the directory name under `/var/lib/klonkt` and the systemd instance name
+(`klonkt@boiert`). It never appears in a URL.
+
+New installs get this layout on their own; the installer derives the slug from
+the domain (`boiert.eu` becomes `boiert`) unless you set `KLONKT_SLUG`. Servers
+installed before this existed keep their old layout untouched until you convert
+them, which is the section below.
+
+## Why the split
+
+When the database and uploads sit inside the checkout, the code directory holds
+live user data. That has three consequences you feel sooner or later:
+
+- **Updates get risky.** Anything that cleans up the checkout can reach your
+  uploads. Deleting and re-cloning the code becomes impossible.
+- **Backups get vague.** You either back up the code as well or you have to pick
+  the data out from between it.
+- **A second site needs a second copy of everything**, including `node_modules`,
+  and every copy has to be updated separately.
+
+After the split the checkout is disposable: throw it away, clone it again, and
+every instance still has its data. Backing up `/var/lib/klonkt/<slug>` backs up
+the whole instance, configuration included.
+
+## Who owns what
+
+Everything runs as the unprivileged `klonkt` system user, which is created by
+the installer and cannot log in.
+
+| Path | Owner | Mode | Written by |
+|---|---|---|---|
+| `/opt/klonkt` | `klonkt` | `0755` | `klonkt-update`, never the running app |
+| `/var/lib/klonkt/<slug>` | `klonkt` | `0750` | the app |
+| `/var/lib/klonkt/<slug>/.env` | `klonkt` | `0600` | you |
+
+Root is needed to install the systemd unit, create the directory and edit the
+web server config. The application itself never runs as root.
+
+The service unit narrows this further:
+
+```ini
+ProtectSystem=strict
+ReadWritePaths=/var/lib/klonkt/%i
+```
+
+The whole filesystem is read-only to the process except its own data directory.
+An instance cannot write into the code, and it cannot reach another instance's
+data, even if something inside the application goes wrong.
+
+## Migrating an existing install
+
+For a server that was installed the single site way, with everything under
+`/opt/klonkt`. Update the code first: the split needs a build where every media
+subdirectory follows `MEDIA_PATH`, and the script refuses to run on anything
+older.
+
+```bash
+klonkt-update
+sudo bash /opt/klonkt/scripts/klonkt-migrate-data.sh <slug> --dry-run
+sudo bash /opt/klonkt/scripts/klonkt-migrate-data.sh <slug>
+```
+
+Pick a slug that describes the site, for example `boiert` for boiert.eu. The
+script stops the service (so SQLite writes out its log), moves `storage/` and
+`.env` to `/var/lib/klonkt/<slug>/`, rewrites the three data paths, installs the
+systemd template, and switches from `klonkt.service` to `klonkt@<slug>`.
+
+Your web server config does not change. The instance keeps the same port,
+because the port comes from the same `.env`.
+
+**Rolling back.** The old `klonkt.service` is disabled but not deleted. To go
+back, move the data into `/opt/klonkt/storage`, restore the relative paths in
+`.env`, and run `systemctl enable --now klonkt`.
+
+## Adding an instance
+
+```bash
+sudo bash /opt/klonkt/scripts/klonkt-add-instance.sh blog blog.example.com
+```
+
+That creates `/var/lib/klonkt/blog`, writes an `.env` with a fresh random
+`SESSION_SECRET` and a free port, starts `klonkt@blog`, and adds a Caddy block
+for the domain. Pass a port as a third argument to choose it yourself, or
+`--no-caddy` if you run your own proxy.
+
+Point the DNS at the server first, otherwise the certificate cannot be issued.
+
+Doing it by hand comes down to the same four things:
+
+```bash
+sudo mkdir -p /var/lib/klonkt/blog
+sudo tee /var/lib/klonkt/blog/.env >/dev/null <<'ENV'
+NODE_ENV=production
+PORT=3001
+HOST=127.0.0.1
+SESSION_SECRET=<openssl rand -hex 32>
+PUBLIC_BASE_URL=https://blog.example.com
+DATABASE_PATH=/var/lib/klonkt/blog/database.sqlite
+MEDIA_PATH=/var/lib/klonkt/blog/media
+AUDIO_PATH=/var/lib/klonkt/blog/audio
+ENV
+sudo chown -R klonkt:klonkt /var/lib/klonkt/blog
+sudo chmod 750 /var/lib/klonkt/blog && sudo chmod 600 /var/lib/klonkt/blog/.env
+sudo systemctl enable --now klonkt@blog
+```
+
+Every instance needs its own `PORT` and its own `PUBLIC_BASE_URL`. Give each one
+its own `SESSION_SECRET` as well: sharing it would make sessions from one site
+valid on another.
+
+The three data paths are the only ones you need. Media subdirectories such as
+`media/avatars` and `media/post-images` follow `MEDIA_PATH` on their own.
+
+## Updating them all at once
+
+```bash
+sudo klonkt-update
+```
+
+That pulls the code once into `/opt/klonkt`, reinstalls dependencies only when
+`package-lock.json` changed, and restarts every instance it finds under
+`/var/lib/klonkt`. There is one copy of the code, so no instance can lag behind.
+
+Watch one instance while it comes back:
+
+```bash
+journalctl -u klonkt@blog -f
+```
+
+## Backups
+
+One directory per instance:
+
+```bash
+systemctl stop klonkt@blog
+tar czf blog-$(date +%F).tar.gz -C /var/lib/klonkt blog
+systemctl start klonkt@blog
+```
+
+Stopping first keeps the SQLite file consistent. To back up without downtime,
+use `sqlite3 database.sqlite ".backup snapshot.db"` and archive the snapshot
+together with `media/` and `audio/`.
+
+There is nothing to back up in `/opt/klonkt`: it is a checkout of a public
+repository and `klonkt-update` recreates it.
+
+## Removing an instance
+
+```bash
+sudo systemctl disable --now klonkt@blog
+sudo rm -rf /var/lib/klonkt/blog      # this deletes the site's data
+```
+
+Then remove its block from the web server config and reload it.
+
+## Everyday commands
+
+| | |
+|---|---|
+| `systemctl status klonkt@<slug>` | is it running |
+| `journalctl -u klonkt@<slug> -f` | follow the log |
+| `systemctl restart klonkt@<slug>` | restart one instance |
+| `klonkt-update` | update the code, restart all instances |
+| `ls /var/lib/klonkt` | which instances exist |
Index: deploy/klonkt@.service
===================================================================
--- deploy/klonkt@.service	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
+++ deploy/klonkt@.service	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
@@ -0,0 +1,45 @@
+#  Klonkt: one service per instance, one shared copy of the code.
+#
+#  Install as /etc/systemd/system/klonkt@.service, then start an instance with
+#  its slug:
+#
+#      systemctl enable --now klonkt@boiert
+#
+#  %i is the slug. Code is shared and read-only at runtime; everything the
+#  instance writes lives under /var/lib/klonkt/<slug>/. Adding an instance is
+#  therefore a directory plus an .env file, nothing else.
+
+[Unit]
+Description=Klonkt (%i)
+Documentation=https://github.com/roboburr/klonkt
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=klonkt
+Group=klonkt
+
+# Shared code. Never instance specific, never written to at runtime.
+WorkingDirectory=/opt/klonkt
+
+# All configuration for this instance. Port, domain, secret and the data paths
+# live here, which is what keeps instances apart.
+EnvironmentFile=/var/lib/klonkt/%i/.env
+Environment=NODE_ENV=production
+
+ExecStart=/usr/bin/node src/server.js
+Restart=always
+RestartSec=3
+
+# The whole filesystem is read-only to this process except its own data
+# directory. An instance therefore cannot write into the code, nor into another
+# instance's data, even if something goes wrong inside the app.
+NoNewPrivileges=true
+ProtectSystem=strict
+ProtectHome=true
+PrivateTmp=true
+ReadWritePaths=/var/lib/klonkt/%i
+
+[Install]
+WantedBy=multi-user.target
Index: scripts/install.sh
===================================================================
--- scripts/install.sh	(revision e2c3d09c54bd06c4a04e4c72e4ff59aa9374b705)
+++ scripts/install.sh	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
@@ -28,4 +28,10 @@
 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@<slug>). Derived from the domain when left empty.
+KLONKT_SLUG="${KLONKT_SLUG:-}"
 KLONKT_USER="${KLONKT_USER:-klonkt}"
 KLONKT_PORT="${KLONKT_PORT:-3000}"
@@ -164,7 +170,42 @@
   git clone --depth 1 --branch "$KLONKT_BRANCH" "$KLONKT_REPO" "$KLONKT_DIR"
 fi
-mkdir -p "$KLONKT_DIR/storage/media" "$KLONKT_DIR/storage/audio"
 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/<slug> 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)…"
@@ -173,5 +214,4 @@
 
 log ".env…"
-ENV="$KLONKT_DIR/.env"
 if [ ! -f "$ENV" ]; then
   SECRET="$(openssl rand -hex 32)"
@@ -183,7 +223,10 @@
     echo "HOST=127.0.0.1"
     echo "SESSION_SECRET=${SECRET}"
-    echo "DATABASE_PATH=./storage/database.sqlite"
-    echo "MEDIA_PATH=./storage/media"
-    echo "AUDIO_PATH=./storage/audio"
+    # 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}"
@@ -201,5 +244,20 @@
 log "systemd service…"
 NODE_BIN="$(command -v node)"
-cat > /etc/systemd/system/klonkt.service <<EOF
+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 <<EOF
 [Unit]
 Description=Klonkt
@@ -222,7 +280,8 @@
 WantedBy=multi-user.target
 EOF
-systemctl daemon-reload
-systemctl enable --now klonkt
-ok "klonkt.service running on 127.0.0.1:${KLONKT_PORT}"
+  systemctl daemon-reload
+  systemctl enable --now klonkt
+  ok "klonkt.service running on 127.0.0.1:${KLONKT_PORT}"
+fi
 
 if [ -z "$NO_CADDY" ]; then
@@ -261,6 +320,19 @@
   runuser -u ${KLONKT_USER} -- env HOME="\$D" bash -c "cd '\$D' && npm ci --omit=dev"
 fi
-systemctl restart klonkt
-echo "Klonkt updated (\$A) + restarted."
+# Restart every instance. Each directory under the data root with an .env is one
+# instance sharing this checkout. An install that has not been split yet has no
+# such directories and still runs the single klonkt.service.
+N=0
+for d in ${KLONKT_DATA_ROOT}/*/; do
+  [ -f "\$d/.env" ] || continue
+  s=\$(basename "\$d")
+  systemctl restart "klonkt@\$s" && N=\$((N+1))
+done
+if [ "\$N" = 0 ]; then
+  systemctl restart klonkt
+  echo "Klonkt updated (\$A) + restarted."
+else
+  echo "Klonkt updated (\$A) + restarted \$N instance(s)."
+fi
 EOF
 chmod +x /usr/local/bin/klonkt-update
@@ -286,6 +358,13 @@
 echo "  • First run:       go to /auth/register and create your admin account."
 echo
-echo "  Manage:  systemctl status klonkt · journalctl -u klonkt -f · klonkt-update"
+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 <slug> <domain>"
+fi
 echo
 echo "  DNS: make sure A + AAAA of ${KLONKT_DOMAIN} point to this server."
Index: scripts/klonkt-add-instance.sh
===================================================================
--- scripts/klonkt-add-instance.sh	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
+++ scripts/klonkt-add-instance.sh	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+#
+# Add a Klonkt instance. An instance is a data directory and an .env file; the
+# code in /opt/klonkt is shared with every other instance and is not copied.
+#
+#     sudo bash scripts/klonkt-add-instance.sh <slug> <domain> [port]
+#     sudo bash scripts/klonkt-add-instance.sh blog blog.example.com --no-caddy
+#
+# Leave the port out and a free one is chosen. Run klonkt-update once and every
+# instance on the machine moves to the new code together.
+
+set -euo pipefail
+
+KLONKT_DIR="${KLONKT_DIR:-/opt/klonkt}"
+KLONKT_USER="${KLONKT_USER:-klonkt}"
+DATA_ROOT="${KLONKT_DATA_ROOT:-/var/lib/klonkt}"
+NO_CADDY="${KLONKT_NO_CADDY:-}"
+LANG_DEFAULT="${KLONKT_DEFAULT_LANG:-}"
+
+SLUG=""; DOMAIN=""; PORT=""
+for arg in "$@"; do
+  case "$arg" in
+    --no-caddy) NO_CADDY=1 ;;
+    -*) echo "unknown option: $arg" >&2; exit 2 ;;
+    *) if   [ -z "$SLUG" ];   then SLUG="$arg"
+       elif [ -z "$DOMAIN" ]; then DOMAIN="$arg"
+       elif [ -z "$PORT" ];   then PORT="$arg"
+       fi ;;
+  esac
+done
+
+say()  { printf '  %s\n' "$*"; }
+step() { printf '\n== %s\n' "$*"; }
+die()  { printf '\nERROR: %s\n' "$*" >&2; exit 1; }
+
+[ "$(id -u)" = 0 ] || die "run this as root (sudo)."
+[ -n "$SLUG" ] && [ -n "$DOMAIN" ] || die "usage: $0 <slug> <domain> [port] [--no-caddy]"
+[[ "$SLUG" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || die "slug must be lowercase letters, digits, dot, dash or underscore."
+
+DATA_DIR="$DATA_ROOT/$SLUG"
+ENV_FILE="$DATA_DIR/.env"
+
+step "Preflight"
+[ -d "$KLONKT_DIR" ] || die "no shared code at $KLONKT_DIR. Install Klonkt first."
+[ -f "$KLONKT_DIR/src/config/paths.js" ] || die \
+  "this build is too old to share one checkout between instances.
+   Update first: klonkt-update"
+id -u "$KLONKT_USER" >/dev/null 2>&1 || die "user $KLONKT_USER does not exist"
+[ -e "$DATA_DIR" ] && die "$DATA_DIR already exists. Pick another slug."
+[ -f /etc/systemd/system/klonkt@.service ] || {
+  [ -f "$KLONKT_DIR/deploy/klonkt@.service" ] || die "missing $KLONKT_DIR/deploy/klonkt@.service"
+  install -m 0644 "$KLONKT_DIR/deploy/klonkt@.service" /etc/systemd/system/klonkt@.service
+  systemctl daemon-reload
+  say "installed the systemd template (first instance on this machine)"
+}
+
+# Pick a port nobody is listening on and no other instance has claimed.
+if [ -z "$PORT" ]; then
+  for p in $(seq 3000 3099); do
+    grep -rqs "^PORT=${p}$" "$DATA_ROOT"/*/.env && continue
+    ss -ltnH "sport = :$p" 2>/dev/null | grep -q . && continue
+    PORT="$p"; break
+  done
+  [ -n "$PORT" ] || die "no free port found in 3000-3099; pass one explicitly."
+fi
+say "slug $SLUG, domain $DOMAIN, port $PORT"
+
+step "Creating $DATA_DIR"
+mkdir -p "$DATA_DIR"
+
+step "Writing .env"
+SECRET="$(openssl rand -hex 32)"
+{
+  echo "NODE_ENV=production"
+  echo "PORT=${PORT}"
+  # Loopback only: the reverse proxy reaches it, the internet cannot bypass HTTPS.
+  echo "HOST=127.0.0.1"
+  echo "SESSION_SECRET=${SECRET}"
+  echo "PUBLIC_BASE_URL=https://${DOMAIN}"
+  echo "DATABASE_PATH=${DATA_DIR}/database.sqlite"
+  echo "MEDIA_PATH=${DATA_DIR}/media"
+  echo "AUDIO_PATH=${DATA_DIR}/audio"
+  [ -n "$LANG_DEFAULT" ] && echo "KLONKT_DEFAULT_LANG=${LANG_DEFAULT}"
+} > "$ENV_FILE"
+say "random SESSION_SECRET, data paths under $DATA_DIR"
+
+step "Ownership and permissions"
+chown -R "$KLONKT_USER:$KLONKT_USER" "$DATA_DIR"
+chmod 750 "$DATA_DIR"
+chmod 600 "$ENV_FILE"
+say "owned by $KLONKT_USER; .env readable only by that user"
+
+step "Starting klonkt@$SLUG"
+systemctl enable --now "klonkt@$SLUG"
+sleep 3
+systemctl is-active --quiet "klonkt@$SLUG" || {
+  echo; journalctl -u "klonkt@$SLUG" -n 30 --no-pager || true
+  die "klonkt@$SLUG did not start."
+}
+curl -fsS --max-time 8 -o /dev/null "http://127.0.0.1:${PORT}/" \
+  && say "responding on 127.0.0.1:${PORT}" \
+  || say "WARNING: no answer yet on 127.0.0.1:${PORT}; check journalctl -u klonkt@$SLUG -f"
+
+if [ -z "$NO_CADDY" ] && command -v caddy >/dev/null 2>&1; then
+  step "Caddy"
+  CADDY=/etc/caddy/Caddyfile
+  if grep -q "^${DOMAIN} {" "$CADDY" 2>/dev/null; then
+    say "a block for ${DOMAIN} already exists, left untouched"
+  else
+    cp "$CADDY" "${CADDY}.bak.$(date +%s)" 2>/dev/null || true
+    printf '\n%s {\n    reverse_proxy 127.0.0.1:%s\n    encode gzip zstd\n}\n' "$DOMAIN" "$PORT" >> "$CADDY"
+    caddy validate --config "$CADDY" --adapter caddyfile >/dev/null 2>&1 \
+      || die "Caddy config invalid after adding ${DOMAIN} — check $CADDY (a .bak was made)"
+    systemctl reload caddy 2>/dev/null || systemctl restart caddy
+    say "serving ${DOMAIN}"
+  fi
+fi
+
+cat <<EOF
+
+Instance ready.
+
+  data   $DATA_DIR
+  unit   klonkt@$SLUG
+  port   127.0.0.1:$PORT
+  code   $KLONKT_DIR  (shared with every other instance)
+
+Next: open https://${DOMAIN}/auth/register and create the admin account.
+
+  status   systemctl status klonkt@$SLUG
+  logs     journalctl -u klonkt@$SLUG -f
+  update   klonkt-update          # updates the code once, restarts all instances
+  backup   $DATA_DIR              # this directory is the whole instance
+EOF
Index: scripts/klonkt-migrate-data.sh
===================================================================
--- scripts/klonkt-migrate-data.sh	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
+++ scripts/klonkt-migrate-data.sh	(revision 2dd1dc40db2f7dab2c095eeb309e5aaa02369b4b)
@@ -0,0 +1,176 @@
+#!/usr/bin/env bash
+#
+# Move an existing Klonkt install to the split layout:
+#
+#     /opt/klonkt/                shared code, read-only at runtime
+#     /var/lib/klonkt/<slug>/     this instance's data and .env
+#
+# Before, an instance kept its database, uploads and .env inside the checkout.
+# That made the code directory undeletable (it held live user data), made
+# backups awkward, and meant a second instance needed a second copy of the code.
+#
+# Run as root on the server. Safe to re-run: it stops at the first step that is
+# already done rather than moving anything twice.
+#
+#     sudo bash scripts/klonkt-migrate-data.sh <slug>
+#     sudo bash scripts/klonkt-migrate-data.sh <slug> --dry-run
+#
+# The slug names the instance and nothing else: it is the directory under
+# /var/lib/klonkt and the systemd instance name (klonkt@<slug>).
+
+set -euo pipefail
+
+KLONKT_DIR="${KLONKT_DIR:-/opt/klonkt}"
+KLONKT_USER="${KLONKT_USER:-klonkt}"
+DATA_ROOT="${KLONKT_DATA_ROOT:-/var/lib/klonkt}"
+OLD_UNIT="klonkt.service"
+
+SLUG=""
+DRY=0
+for arg in "$@"; do
+  case "$arg" in
+    --dry-run) DRY=1 ;;
+    -*) echo "unknown option: $arg" >&2; exit 2 ;;
+    *) SLUG="$arg" ;;
+  esac
+done
+
+say()  { printf '  %s\n' "$*"; }
+step() { printf '\n== %s\n' "$*"; }
+die()  { printf '\nERROR: %s\n' "$*" >&2; exit 1; }
+run()  { if [ "$DRY" = 1 ]; then printf '  [dry-run] %s\n' "$*"; else eval "$@"; fi; }
+
+[ "$(id -u)" = 0 ] || die "run this as root (sudo)."
+[ -n "$SLUG" ]      || die "usage: $0 <slug> [--dry-run]   e.g. $0 boiert"
+[[ "$SLUG" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || die "slug must be lowercase letters, digits, dot, dash or underscore."
+
+DATA_DIR="$DATA_ROOT/$SLUG"
+ENV_OLD="$KLONKT_DIR/.env"
+ENV_NEW="$DATA_DIR/.env"
+
+step "Preflight"
+[ -d "$KLONKT_DIR" ]  || die "no install at $KLONKT_DIR"
+[ -f "$ENV_OLD" ] || [ -f "$ENV_NEW" ] || die "no .env at $ENV_OLD (already migrated elsewhere?)"
+id -u "$KLONKT_USER" >/dev/null 2>&1 || die "user $KLONKT_USER does not exist"
+
+# The split only works on code where every media subdirectory derives from
+# MEDIA_PATH. On older code the subdirectories fall back into the checkout, so
+# the app would quietly recreate storage/ next to the code and uploads would
+# land there.
+[ -f "$KLONKT_DIR/src/config/paths.js" ] || die \
+  "this build is too old for the split layout: src/config/paths.js is missing.
+   Update first (git pull in $KLONKT_DIR), then run this again."
+say "code at $KLONKT_DIR supports MEDIA_PATH-derived subdirectories"
+
+if [ -d "$DATA_DIR" ] && [ -n "$(ls -A "$DATA_DIR" 2>/dev/null)" ]; then
+  die "$DATA_DIR already exists and is not empty. Remove it or pick another slug."
+fi
+say "target $DATA_DIR is free"
+[ "$DRY" = 1 ] && say "DRY RUN: nothing will be changed"
+
+step "Stopping the service"
+if systemctl is-active --quiet "$OLD_UNIT"; then
+  run "systemctl stop $OLD_UNIT"
+  say "stopped $OLD_UNIT (SQLite checkpoints its write-ahead log on shutdown)"
+else
+  say "$OLD_UNIT was not running"
+fi
+
+step "Creating the data directory"
+run "mkdir -p '$DATA_DIR'"
+
+step "Moving data out of the checkout"
+if [ -d "$KLONKT_DIR/storage" ]; then
+  say "found $(find "$KLONKT_DIR/storage" -type f 2>/dev/null | wc -l) files in storage/ ($(du -sh "$KLONKT_DIR/storage" 2>/dev/null | cut -f1))"
+  # Everything, including database.sqlite plus its -wal and -shm siblings.
+  run "shopt -s dotglob nullglob; for f in '$KLONKT_DIR/storage/'*; do mv \"\$f\" '$DATA_DIR/'; done"
+  run "rmdir '$KLONKT_DIR/storage' 2>/dev/null || true"
+  say "moved to $DATA_DIR"
+else
+  say "no storage/ directory (already moved?)"
+fi
+
+if [ -f "$ENV_OLD" ]; then
+  run "mv '$ENV_OLD' '$ENV_NEW'"
+  say "moved .env to $ENV_NEW"
+fi
+
+step "Pointing the data paths at the new location"
+# Replace when present, append when absent, so this works regardless of which
+# variables the original install wrote.
+set_env() {
+  local key="$1" val="$2"
+  if [ "$DRY" = 1 ]; then printf '  [dry-run] %s=%s\n' "$key" "$val"; return; fi
+  if grep -q "^${key}=" "$ENV_NEW" 2>/dev/null; then
+    sed -i "s#^${key}=.*#${key}=${val}#" "$ENV_NEW"
+  else
+    printf '%s=%s\n' "$key" "$val" >> "$ENV_NEW"
+  fi
+  printf '  %s=%s\n' "$key" "$val"
+}
+set_env DATABASE_PATH "$DATA_DIR/database.sqlite"
+set_env MEDIA_PATH    "$DATA_DIR/media"
+set_env AUDIO_PATH    "$DATA_DIR/audio"
+
+step "Ownership and permissions"
+run "chown -R '$KLONKT_USER:$KLONKT_USER' '$DATA_DIR'"
+run "chmod 750 '$DATA_DIR'"
+run "chmod 600 '$ENV_NEW'"
+say "data owned by $KLONKT_USER, .env readable only by that user"
+
+step "Installing the systemd template"
+if [ -f "$KLONKT_DIR/deploy/klonkt@.service" ]; then
+  run "install -m 0644 '$KLONKT_DIR/deploy/klonkt@.service' /etc/systemd/system/klonkt@.service"
+  say "installed /etc/systemd/system/klonkt@.service"
+else
+  die "template not found at $KLONKT_DIR/deploy/klonkt@.service"
+fi
+run "systemctl daemon-reload"
+
+step "Switching to klonkt@$SLUG"
+if systemctl is-enabled --quiet "$OLD_UNIT" 2>/dev/null; then
+  run "systemctl disable --now $OLD_UNIT"
+  say "disabled $OLD_UNIT (file kept, so you can roll back)"
+fi
+run "systemctl enable --now 'klonkt@$SLUG'"
+
+step "Verifying"
+if [ "$DRY" = 1 ]; then
+  say "dry run: skipping verification"
+  exit 0
+fi
+sleep 3
+systemctl is-active --quiet "klonkt@$SLUG" || {
+  echo
+  journalctl -u "klonkt@$SLUG" -n 30 --no-pager || true
+  die "klonkt@$SLUG did not start. Roll back with: systemctl enable --now $OLD_UNIT"
+}
+say "klonkt@$SLUG is running"
+
+PORT="$(grep -m1 '^PORT=' "$ENV_NEW" | cut -d= -f2- | tr -d '\r')"
+if [ -n "$PORT" ]; then
+  if curl -fsS --max-time 8 -o /dev/null "http://127.0.0.1:${PORT}/"; then
+    say "responding on 127.0.0.1:${PORT}"
+  else
+    say "WARNING: no answer on 127.0.0.1:${PORT} yet; check: journalctl -u klonkt@$SLUG -f"
+  fi
+fi
+
+if [ -e "$KLONKT_DIR/storage" ]; then
+  say "WARNING: $KLONKT_DIR/storage came back. That means this build still writes"
+  say "         next to its code. Report it; do not delete the directory."
+else
+  say "the checkout no longer holds user data"
+fi
+
+cat <<EOF
+
+Done. This instance now looks like:
+
+  code   $KLONKT_DIR              shared, replaceable, no user data
+  data   $DATA_DIR    database, uploads and .env
+  unit   klonkt@$SLUG
+
+Back up $DATA_DIR and you have the whole instance.
+Add another instance with: klonkt-add-instance.sh <slug> <domain> <port>
+EOF
