A small, self-hosted web app for researchers who run group experiments and need a way to publish session slots and register participant signups. One SQLite file, ICS feeds for participants and researchers (including custom reminder events)
- Define experiments with required participant-info fields and inclusion/exclusion criteria.
- Create session slots either one-off or as recurrence templates (weekly, etc.) that you "generate" into concrete sessions.
- Participants book a session without an account; they get a self-manage URL containing an opaque token (cancel / update from that link).
- Participants can also submit a preference for multiple sessions, and the researcher can assign them to a session
- Dashboard, session grid, and per-session detail pages highlight below-minimum, full, and at-capacity state.
- Three ICS feeds per experiment: a experiment feed whose event titles show live
(n/cap)counts, and a researcher feed that also includes reminder events defined per rule (e.g. "email participants 1 day before" or "consider cancelling — still below minimum"), and a participant feed that shows the status of the session (e.g. confirmed, cancelled, etc.). - Mark attendance (attended / no-show); a future booking from the same email on the same experiment can be automatically blocked if you don't want to allow participants to attend multiple times.
pnpm install
cp .env.example .env
# edit .env: set ORIGIN, BETTER_AUTH_SECRET, CLINIC_TZ, ADMIN_EMAIL, ADMIN_PASSWORD
pnpm db:push # apply the schema to the SQLite file in DATABASE_URL
pnpm seed:admin # creates the single admin user, then locks signup
pnpm devnote: BASE_URL does not automatically apply when you run the dev server, if you want to specify
a subdirectory for local testing, you can run the pnpm dev command with the environment variable set:
BASE_URL=/booking pnpm devVisit https://localhost:5173/login and sign in with the admin credentials.
The project uses @sveltejs/adapter-node, so pnpm build produces a Node
server under build/. Typical deployment:
pnpm build
NODE_ENV=production node buildPut it behind a reverse proxy that terminates TLS and sets X-Forwarded-For,
and set TRUSTED_PROXY=proxy so the rate limiter keys on the real client IP
(without it every request appears to come from the proxy). The app reads all configuration from
environment variables at runtime — see .env.example for the full list.
A docker-compose.yml is provided. Copy .env.example to .env, fill in the
values (set ORIGIN to your public URL, e.g. https://booking.example.org),
then:
docker compose up -d # standalone, port 3000 bound to 127.0.0.1The first start applies the schema and seeds the admin account automatically if
ADMIN_EMAIL / ADMIN_PASSWORD are set. The SQLite file lives in ./data/.
An nginx reverse-proxy service is included as an optional Compose profile (see
Nginx via Docker below). If you already run nginx on the
host, skip the profile and proxy directly to 127.0.0.1:3000 instead.
The app and anonymize containers run unprivileged, with all Linux capabilities
dropped. The only thing they write is the database directory, so ./data on
the host must be owned by the uid the containers run as.
That uid is APP_UID / APP_GID in .env, defaulting to 1000:1000. On many
hosts uid 1000 is the first human account, so the files would show up as
belonging to that person. To avoid that, create a dedicated service account
and use its ids instead:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin booking
id booking # note the uid= and gid= values# .env
APP_UID=<uid>
APP_GID=<gid>mkdir -p data
sudo chown -R booking:booking dataOnly the numeric ids matter to Docker; the account just gives them a name on
the host. If ./data isn't writable the app container exits at startup with a
message saying which uid needs access. If you set LOG_FILE, point it
somewhere under /data as well.
Upgrading an instance that used to run as root — the existing database files are owned by root, so fix the ownership once while the stack is down:
docker compose down
sudo cp -a data data.bak # backup, including any -wal / -shm files
sudo chown -R <uid>:<gid> data # the APP_UID / APP_GID from .env
docker compose build
docker compose up -d
docker compose logs -f app # should reach "Starting Group Booking Tool"If you already run nginx on the host (e.g. managing multiple sites on a VPS),
proxy directly to 127.0.0.1:3000. Assuming TLS certificates are already in
place (e.g. from certbot):
server {
listen 80;
listen [::]:80;
server_name booking.example.org;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name booking.example.org;
ssl_certificate /etc/letsencrypt/live/booking.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/booking.example.org/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Set ORIGIN=https://booking.example.org in .env so better-auth scopes its
cookies correctly and ICS feed URLs are rendered with the right base. ORIGIN
is also baked into the build (SvelteKit's paths.origin, used for CSRF checks),
so rebuild after changing it.
To also run nginx as a container alongside the app:
# Edit nginx/nginx.conf — replace booking.example.org with your domain.
docker compose --profile proxy up -dCertificates are expected at /etc/letsencrypt on the host (mounted read-only
into the nginx container). For HTTP-only local testing, swap in
nginx/nginx-insecure.conf via the volumes mount in docker-compose.yml.
DATABASE_URL is a single SQLite file. Back it up. There is no other state.
The app ships a pnpm anonymize script that anonymises participant data that
has passed its retention window (90 days by default, or DATA_RETENTION_DAYS
from .env). With Docker Compose the included anonymize service runs it
automatically at 02:00 UTC every night — no extra setup required.
If you are not using Docker Compose, add a system cron entry on the host
(run crontab -e or drop a file in /etc/cron.d/):
# Run the anonymisation sweep daily at 02:00; adjust the path to match your
# installation directory.
0 2 * * * cd /path/to/group-booking-tool && pnpm anonymize >> /var/log/group-booking-anonymize.log 2>&1
The script loads DATABASE_URL and DATA_RETENTION_DAYS from .env
automatically, so no extra environment setup is needed beyond what is already
in your .env file.
pnpm exec playwright install
pnpm test # unit + browser tests
pnpm test:unit # unit only
pnpm check # svelte-check + typescript
pnpm lint- SvelteKit 3 + Svelte 5, Tailwind v4
- SQLite via
better-sqlite3anddrizzle-orm(schema insrc/lib/server/db/schema.ts) better-authfor admin usericsandrrulefor feed generation and recurrence expansion- Vitest (server + browser environments) and Playwright
- All mutating public routes, and sign-in, go through an in-memory token-bucket rate limit keyed by client IP. Forwarding headers are only trusted when
TRUSTED_PROXYis set:proxy(your own reverse proxy; right-mostX-Forwarded-Forvalue) orcloudflare(CF-Connecting-IP). Unset, the socket address is used. - Every route under
(admin)— page loads and form actions — requires a signed-in session, enforced insrc/hooks.server.ts. - Participant self-manage tokens are 256-bit URL-safe random strings; only their SHA-256 hash is stored in the database.
- Public routes are served with a strict CSP (nonce-based)
- ICS feed URLs use tokens, as such, should never be shared
- There is no email integration, if you lose the admin password, you can delete the user row and re-run
pnpm seed:admin.