Index: deploy/DEPLOY.md
===================================================================
--- deploy/DEPLOY.md	(revision 0ca7e5d0269a3bfcd24646fbaf5260ebaa6b49f1)
+++ 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
