Index: deploy/MULTI-INSTANCE.md
===================================================================
--- deploy/MULTI-INSTANCE.md	(revision 1294c366038b50b2a53606422d23389aa0f0c12d)
+++ deploy/MULTI-INSTANCE.md	(revision 0f09302c3179e8e70a3c0bff61a1b7099bbd2c76)
@@ -210,2 +210,20 @@
 | `klonkt-update` | update the code, restart all instances |
 | `ls /var/lib/klonkt` | which instances exist |
+| `cd /opt/klonkt && npm run reset-admin -- --instance <slug>` | reset a forgotten admin password |
+
+### Why the password reset needs `--instance`
+
+Every other command here names its instance, and this one is no different — but
+the reason is easy to miss, because the app's own help page says only "run
+`npm run reset-admin` from the project folder". That advice is written for the
+one-instance layout, where the `.env` sits next to the code.
+
+Here it does not. The code is shared and read-only at `/opt/klonkt`; the
+configuration lives at `/var/lib/klonkt/<slug>/.env`, and **systemd** reads it
+through `EnvironmentFile=`. A script started by hand gets none of that: it looks
+for an `.env` beside the code, finds nothing, and is left without
+`DATABASE_PATH`. `--instance` reads the same file systemd does.
+
+Without it the script now stops and says which path it wanted, instead of
+creating an empty database inside the shared code directory. If your data lives
+somewhere other than `/var/lib/klonkt`, set `KLONKT_DATA_ROOT`.
Index: scripts/reset-admin.mjs
===================================================================
--- scripts/reset-admin.mjs	(revision 1294c366038b50b2a53606422d23389aa0f0c12d)
+++ scripts/reset-admin.mjs	(revision 0f09302c3179e8e70a3c0bff61a1b7099bbd2c76)
@@ -4,30 +4,124 @@
 //
 // Usage:
-//   npm run reset-admin                       # reset the (first) god user, print new password
-//   npm run reset-admin -- <user|email>       # reset a specific user, print new password
-//   npm run reset-admin -- <user|email> <pw>  # set a chosen password
+//   npm run reset-admin                            # reset the (first) god user, print new password
+//   npm run reset-admin -- <user|email>            # reset a specific user, print new password
+//   npm run reset-admin -- <user|email> <pw>       # set a chosen password
+//   npm run reset-admin -- --instance <slug> [...] # pick an instance (shared code, /var/lib/klonkt)
 //
-// Run from the project root so DATABASE_PATH/.env is loaded correctly.
+// Run from the project root so DATABASE_PATH/.env is loaded correctly, or name
+// the instance with --instance.
 
-import 'dotenv/config';
+import fs from 'fs';
+import path from 'path';
 import crypto from 'crypto';
+import { fileURLToPath } from 'url';
+import dotenv from 'dotenv';
 import bcrypt from 'bcryptjs';
-import db from '../src/config/database.js';
 
-const arg = process.argv[2];
-const pwArg = process.argv[3];
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const wortel = path.join(__dirname, '..');
+
+// De argumenten: --instance mag overal staan, de rest is stelling-afhankelijk.
+const argv = process.argv.slice(2);
+let slug = null;
+const rest = [];
+for (let i = 0; i < argv.length; i++) {
+  const a = argv[i];
+  if (a === '--instance' || a === '-i') { slug = argv[++i] || null; continue; }
+  if (a.startsWith('--instance=')) { slug = a.slice('--instance='.length); continue; }
+  rest.push(a);
+}
+const [arg, pwArg] = rest;
+
+if (slug !== null && !slug) {
+  console.error('--instance verwacht een slug, bijvoorbeeld: --instance boiert');
+  process.exit(1);
+}
+
+// WAAR DE CONFIGURATIE VANDAAN KOMT, en waarom dit meer dan één regel is.
+//
+// Bij één-instantie-per-checkout staat de .env naast de code en vindt dotenv
+// hem vanzelf. In de gedeelde opstelling van deploy/klonkt@.service niet: daar
+// is de code gedeeld op /opt/klonkt en staat de configuratie per instantie in
+// /var/lib/klonkt/<slug>/.env, waar SYSTEMD hem leest via EnvironmentFile.
+// Dotenv kijkt in de werkmap en vindt daar niets, dus zonder --instance draait
+// dit script met een lege DATABASE_PATH -- en dan wijst hij de verkeerde kant
+// op. Vandaar deze schakelaar.
+const DATA_ROOT = process.env.KLONKT_DATA_ROOT || '/var/lib/klonkt';
+if (slug) {
+  const envPad = path.join(DATA_ROOT, slug, '.env');
+  if (!fs.existsSync(envPad)) {
+    console.error(`Geen .env voor instantie "${slug}" op ${envPad}.`);
+    console.error(`Bestaande instanties: ${bestaandeInstanties().join(', ') || '(geen gevonden)'}`);
+    console.error('Staat je data ergens anders? Zet KLONKT_DATA_ROOT.');
+    process.exit(1);
+  }
+  // override: dotenv laat een bestaande variabele normaal met rust, maar wie
+  // --instance typt heeft die instantie AANGEWEZEN. Een DATABASE_PATH die nog
+  // in de shell hangt van een vorige instantie zou anders stilletjes winnen,
+  // en dan reset je het wachtwoord van de verkeerde site.
+  dotenv.config({ path: envPad, override: true });
+} else {
+  dotenv.config();
+}
+
+function bestaandeInstanties() {
+  try {
+    return fs.readdirSync(DATA_ROOT).filter((d) => fs.existsSync(path.join(DATA_ROOT, d, '.env')));
+  } catch { return []; }
+}
+
+// EERST KIJKEN, DAN PAS IMPORTEREN. src/config/database.js maakt zijn map en
+// zijn bestand aan zodra hij geladen wordt -- prima bij de eerste start van een
+// server, funest hier: sta je in de verkeerde map, dan legt hij een lege
+// database neer en klapt daarna op "no such table: users". Dat is een
+// stacktrace op de plek waar een aanwijzing hoort te staan. Dus dezelfde
+// padkeuze als database.js, maar dan alleen berekend.
+const dbPad = process.env.DATABASE_PATH || path.join(wortel, 'storage/database.sqlite');
+if (!fs.existsSync(dbPad)) {
+  console.error(`Geen database op ${dbPad}.`);
+  console.error('');
+  console.error('Dit script maakt er met opzet geen aan: een ontbrekende database betekent');
+  console.error('bijna altijd dat DATABASE_PATH niet geladen is, niet dat de site leeg is.');
+  console.error('');
+  if (!slug) {
+    const gevonden = bestaandeInstanties();
+    if (gevonden.length) {
+      console.error(`Gedeelde opstelling? Noem de instantie: npm run reset-admin -- --instance <slug>`);
+      console.error(`Gevonden in ${DATA_ROOT}: ${gevonden.join(', ')}`);
+    } else {
+      console.error('Draai dit vanuit de projectmap, zodat de .env ernaast geladen wordt,');
+      console.error('of geef het pad mee: DATABASE_PATH=/pad/naar/database.sqlite npm run reset-admin');
+    }
+  }
+  process.exit(1);
+}
+
+const { default: db } = await import('../src/config/database.js');
 
 let user;
-if (arg) {
-  user = db.prepare('SELECT * FROM users WHERE username = ? OR LOWER(email) = LOWER(?)').get(arg, arg);
-} else {
-  // No arg: pick the admin (god or admin role), otherwise the very first user.
-  user =
-    db.prepare("SELECT * FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1").get() ||
-    db.prepare('SELECT * FROM users ORDER BY created_at LIMIT 1').get();
+try {
+  if (arg) {
+    user = db.prepare('SELECT * FROM users WHERE username = ? OR LOWER(email) = LOWER(?)').get(arg, arg);
+  } else {
+    // No arg: pick the admin (god or admin role), otherwise the very first user.
+    user =
+      db.prepare("SELECT * FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1").get() ||
+      db.prepare('SELECT * FROM users ORDER BY created_at LIMIT 1').get();
+  }
+} catch (e) {
+  // Een database zonder users-tabel is geen lege site maar het verkeerde
+  // bestand -- of een checkout die nog nooit gestart is.
+  if (/no such table/i.test(e.message)) {
+    console.error(`De database op ${dbPad} heeft geen users-tabel.`);
+    console.error('Dat is een ander bestand dan je site, of een site die nooit gestart is.');
+    process.exit(1);
+  }
+  throw e;
 }
 
 if (!user) {
   console.error(arg ? `Geen user gevonden voor "${arg}".` : 'Geen god-user gevonden.');
+  console.error(`Gezocht in ${dbPad}.`);
   process.exit(1);
 }
@@ -45,4 +139,5 @@
 
 console.log(`Wachtwoord gereset voor ${user.username} <${user.email}> (rol: ${user.role}).`);
+console.log(`Database: ${dbPad}`);
 if (!pwArg) console.log(`Nieuw wachtwoord: ${newPw}`);
 console.log('Log nu in via /auth/login en wijzig het eventueel in je account.');
Index: test/reset-admin.test.js
===================================================================
--- test/reset-admin.test.js	(revision 0f09302c3179e8e70a3c0bff61a1b7099bbd2c76)
+++ test/reset-admin.test.js	(revision 0f09302c3179e8e70a3c0bff61a1b7099bbd2c76)
@@ -0,0 +1,141 @@
+// reset-admin is het noodpad: geen e-mail, geen sessie, alleen shell-toegang.
+// Juist daarom hoort hier de GESLAAGDE weg getest te worden en niet alleen de
+// weigering -- een break-glass die stilletjes de verkeerde database opent doet
+// precies wat hij niet mag: hij lijkt te werken.
+//
+// Deze tests starten het script als los proces, want de fout die we in
+// augustus vonden zat in de volgorde van imports: src/config/database.js maakt
+// zijn bestand aan zodra hij geladen wordt. Dat is alleen zichtbaar als je het
+// echt draait, niet als je functies importeert.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import Database from 'better-sqlite3';
+import bcrypt from 'bcryptjs';
+
+const wortel = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+const script = path.join(wortel, 'scripts/reset-admin.mjs');
+
+/** Een database met één god-user erin, op een weggooiplek. */
+function maakSite(dir, username = 'baas') {
+  fs.mkdirSync(dir, { recursive: true });
+  const pad = path.join(dir, 'database.sqlite');
+  const D = new Database(pad);
+  D.exec(`CREATE TABLE users (
+    id INTEGER PRIMARY KEY, username TEXT, email TEXT, role TEXT,
+    password_hash TEXT, reset_token TEXT, reset_token_expires TEXT,
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT
+  )`);
+  D.prepare('INSERT INTO users (username, email, role, password_hash) VALUES (?,?,?,?)')
+    .run(username, `${username}@voorbeeld.nl`, 'god', bcrypt.hashSync('oudwachtwoord', 4));
+  D.close();
+  return pad;
+}
+
+function draai(args, env = {}) {
+  // Een lege waarde WIST de variabele hier, in plaats van hem leeg te zetten:
+  // dotenv kijkt of de sleutel bestaat, niet of hij gevuld is, dus een lege
+  // DATABASE_PATH zou een .env-waarde tegenhouden en de test iets anders laten
+  // meten dan ze denkt te meten.
+  const kind = { ...process.env, ...env };
+  for (const [k, v] of Object.entries(kind)) if (v === '' || v === undefined) delete kind[k];
+  try {
+    return { code: 0, uit: execFileSync(process.execPath, [script, ...args], {
+      encoding: 'utf8', env: kind, cwd: wortel, stdio: 'pipe',
+    }) };
+  } catch (e) {
+    return { code: e.status ?? 1, uit: (e.stdout || '') + (e.stderr || '') };
+  }
+}
+
+test('reset-admin zet echt een nieuw wachtwoord (de geslaagde weg)', () => {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
+  const pad = maakSite(path.join(tmp, 'data'));
+
+  const r = draai([], { DATABASE_PATH: pad });
+  assert.equal(r.code, 0, r.uit);
+  assert.match(r.uit, /Wachtwoord gereset voor baas/);
+  assert.match(r.uit, /Nieuw wachtwoord: (\S+)/);
+  // Het pad hoort in de uitvoer: bij meerdere datamappen is "welke database"
+  // de enige vraag die ertoe doet.
+  assert.match(r.uit, new RegExp(`Database: ${pad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
+
+  // En het wachtwoord dat hij toont, werkt ook echt.
+  const nieuw = r.uit.match(/Nieuw wachtwoord: (\S+)/)[1];
+  const D = new Database(pad, { readonly: true });
+  const u = D.prepare('SELECT password_hash FROM users WHERE username = ?').get('baas');
+  assert.equal(bcrypt.compareSync(nieuw, u.password_hash), true, 'het getoonde wachtwoord moet geldig zijn');
+  fs.rmSync(tmp, { recursive: true, force: true });
+});
+
+test('een zelfgekozen wachtwoord wordt gezet, en te kort wordt geweigerd', () => {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
+  const pad = maakSite(path.join(tmp, 'data'));
+
+  assert.equal(draai(['baas', 'kort'], { DATABASE_PATH: pad }).code, 1);
+  const r = draai(['baas', 'eenlangwachtwoord'], { DATABASE_PATH: pad });
+  assert.equal(r.code, 0, r.uit);
+
+  const D = new Database(pad, { readonly: true });
+  const u = D.prepare('SELECT password_hash FROM users WHERE username = ?').get('baas');
+  assert.equal(bcrypt.compareSync('eenlangwachtwoord', u.password_hash), true);
+  fs.rmSync(tmp, { recursive: true, force: true });
+});
+
+test('--instance leest de .env van die instantie, zoals systemd dat doet', () => {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
+  // De opstelling uit deploy/klonkt@.service nagebouwd: gedeelde code, en de
+  // configuratie per instantie onder de dataroot.
+  const dataRoot = path.join(tmp, 'var-lib-klonkt');
+  const pad = maakSite(path.join(dataRoot, 'boiert'), 'opie');
+  fs.writeFileSync(path.join(dataRoot, 'boiert', '.env'), `DATABASE_PATH=${pad}\n`);
+
+  const r = draai(['--instance', 'boiert'], { KLONKT_DATA_ROOT: dataRoot, DATABASE_PATH: '' });
+  assert.equal(r.code, 0, r.uit);
+  assert.match(r.uit, /Wachtwoord gereset voor opie/);
+  fs.rmSync(tmp, { recursive: true, force: true });
+});
+
+test('zonder database maakt hij er GEEN aan, maar noemt het pad', () => {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
+  const pad = path.join(tmp, 'nergens', 'database.sqlite');
+
+  const r = draai([], { DATABASE_PATH: pad });
+  assert.equal(r.code, 1);
+  assert.match(r.uit, /Geen database op/);
+  assert.match(r.uit, new RegExp(pad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+  // De kern van de fix: geen lege database achterlaten waar je toevallig stond.
+  assert.equal(fs.existsSync(pad), false, 'er mag niets aangemaakt zijn');
+  assert.equal(fs.existsSync(path.dirname(pad)), false, 'ook de map niet');
+  fs.rmSync(tmp, { recursive: true, force: true });
+});
+
+test('een database zonder users-tabel is het verkeerde bestand, geen lege site', () => {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
+  const pad = path.join(tmp, 'vreemd.sqlite');
+  const D = new Database(pad); D.exec('CREATE TABLE iets (a INTEGER)'); D.close();
+
+  const r = draai([], { DATABASE_PATH: pad });
+  assert.equal(r.code, 1);
+  assert.match(r.uit, /geen users-tabel/);
+  assert.doesNotMatch(r.uit, /SqliteError/, 'een aanwijzing, geen stacktrace');
+  fs.rmSync(tmp, { recursive: true, force: true });
+});
+
+test('een onbekende instantie noemt wat er wel is', () => {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kl-reset-'));
+  const dataRoot = path.join(tmp, 'var-lib-klonkt');
+  maakSite(path.join(dataRoot, 'boiert'));
+  fs.writeFileSync(path.join(dataRoot, 'boiert', '.env'), 'DATABASE_PATH=/dev/null\n');
+
+  const r = draai(['--instance', 'bestaatniet'], { KLONKT_DATA_ROOT: dataRoot });
+  assert.equal(r.code, 1);
+  assert.match(r.uit, /Geen \.env voor instantie "bestaatniet"/);
+  assert.match(r.uit, /boiert/, 'noemt de instanties die er wel zijn');
+  fs.rmSync(tmp, { recursive: true, force: true });
+});
