Skip to content

Deployment and Operations

hyper-calendar ships as a single Docker container serving both the API and the built frontend on port 3001. All state is one SQLite file.

Quick start

bash
docker compose build
docker compose up -d
docker compose logs -f hyper-calendar   # watch startup + migrations

Or plain Docker:

bash
docker build -t hyper-calendar:latest .
docker run -d --name hyper-calendar \
  -p 3001:3001 \
  -v hyper-calendar-data:/data \
  -e DATABASE_PATH=/data/hyper-calendar.db \
  hyper-calendar:latest

Migrations are applied on boot before the server starts listening, so first boot creates the schema and upgrades are automatic and idempotent.

Tagged builds are pushed by CI to the project's container registry as $CI_REGISTRY_IMAGE:<tag> and :latest.

What's actually in the image

Not the same as npm's dependencies / devDependencies split. The Dockerfile copies only web/dist (pre-built frontend), server/dist (an esbuild bundle), shared/, and server/package.json, then runs npm install --omit=dev. So the runtime image contains the server's runtime deps (kept external by esbuild --packages=external) plus what shared pulls in: hono + @hono/node-server, better-sqlite3, drizzle-orm, date-fns + @date-fns/tz, zod, rrule, nanoid, @simplewebauthn/server, web-push, node-ical, ical-generator.

Every web dependency is build-time only — React, idb, vosk-browser, Vite, Tailwind, Workbox — bundled into web/dist and never installed in the running image.

Configuration

VariableDefaultPurpose
DATABASE_PATH/app/server/data/hyper-calendar.dbSQLite file. Compose sets /data/hyper-calendar.db on a named volume.
PORT3001Listen port.
NODE_ENVSet to production in deployments (the image does). Gates HSTS, secure cookies, and static SPA serving.
TZsystemServer-local timezone; affects the demo seed and server-local defaults.
TRUST_PROXYunset1 behind a reverse proxy, so rate limiting reads the real client IP from X-Forwarded-For.
VAPID_PUBLIC_KEYunsetWeb Push public key (also served to clients).
VAPID_PRIVATE_KEYunsetWeb Push private key.
VAPID_SUBJECTunsetWeb Push contact (mailto: or https:).
WEBAUTHN_RP_IDrequest hostPasskey relying-party id. Set when behind a proxy or custom domain.
WEBAUTHN_ORIGINrequest originPasskey expected origin. Same.
CALDAV_SECRETunset32 bytes (hex or base64). Required to link a calendar to a remote CalDAV server.
AUTH_DEV_AUTOLOGINunsetDevelopment only. Treats session-less requests as a seeded dev user. Never set this in production.

HTTPS and reverse proxy

hyper-calendar handles its own authentication, so you can expose it directly — but PWA install, push notifications, and passkeys all require a secure origin. Put it behind something that terminates TLS (Traefik, Caddy, nginx). No forward-auth / IdP middleware is needed or expected.

yaml
labels:
  - "traefik.enable=true"
  - "traefik.http.routers.hyper-calendar.rule=Host(`cal.example.com`)"
  - "traefik.http.routers.hyper-calendar.entrypoints=websecure"
  - "traefik.http.routers.hyper-calendar.tls.certresolver=letsencrypt"
  - "traefik.http.services.hyper-calendar.loadbalancer.server.port=3001"

Behind a proxy, set TRUST_PROXY=1, and set WEBAUTHN_RP_ID / WEBAUTHN_ORIGIN if the public host differs from the Origin the browser sends.

First run

Registration is open self-registration by default, and the first account registered becomes the admin. Register your own account first, then — from Settings → Admin — close registration once everyone's accounts exist, so the instance isn't open to the world.

Push notifications

Opt-in per server. Without VAPID keys the reminder scheduler stays idle and the in-app toggle reports push isn't configured.

bash
npx web-push generate-vapid-keys

Set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT and restart. An in-process scheduler then checks roughly every 30 s for due reminders and pushes to each user's registered devices. reminder_log is the idempotency ledger that stops a reminder firing twice across restarts.

CalDAV linking

Two-way sync to Nextcloud, Radicale, Fastmail, … needs one secret, because the remote password has to be replayable while nobody is signed in:

bash
CALDAV_SECRET=$(openssl rand -base64 32)

Without it, linking is refused with an explanatory error rather than storing a password in the clear. The value seals passwords with AES-256-GCM (lib/security/secretBox); the sealed password is never returned by the API, exported, or included in backups.

Keep it with your other secrets. Losing it means re-entering every remote password; changing it invalidates all of them at once.

Point a link at a single calendar URL, not the server root — there is no principal discovery. Encrypted calendars can't be linked, since the server only ever holds ciphertext.

The Vosk voice model

The mic button runs a local Vosk WASM model offline — no API keys, nothing leaves the device. The ~40 MB tarball is not committed (git-ignored). To enable voice, fetch it into web/public/models/vosk/ before building the image; see web/public/models/vosk/README.md for the download-and-repackage steps. Without it, the app works normally and the mic shows a "model failed to load" toast.

Backup / restore

All state is the single SQLite file plus its -wal / -shm sidecars. Backing up is copying that file.

Automatic. The server writes a daily full-database snapshot via VACUUM INTO to <data>/backups/_db/hyper-calendar-<timestamp>.sqlite, keeping the 14 newest. Per-user .ics snapshots are written daily too (restorable from Settings → Data), but the DB snapshot is the complete physical backup.

An admin can drive it from the API:

bash
curl -X POST https://your-host/api/admin/db-backups          # snapshot now
curl        https://your-host/api/admin/db-backups           # list
curl -OJ    https://your-host/api/admin/db-backups/hyper-calendar-<timestamp>.sqlite

Manual backup — safe while running, because VACUUM INTO writes a single consistent file that already folds in the WAL:

bash
docker compose exec hyper-calendar \
  node -e "const db=require('better-sqlite3')(process.env.DATABASE_PATH); db.exec(\"VACUUM INTO '/data/backup.db'\")"
docker compose cp hyper-calendar:/data/backup.db ./hypercalendar-backup.db

Restore — stop the container, drop the file back in as hyper-calendar.db, remove any stale -wal / -shm, start again. Migrations re-run idempotently:

bash
docker compose stop hyper-calendar
docker compose cp ./hypercalendar-backup.db hyper-calendar:/data/hyper-calendar.db
docker compose start hyper-calendar

The app does not create off-host backups. Getting these snapshots somewhere else is the operator's job.

Observability

One structured JSON log line per request (method, path, status, latency, user) tagged with a correlation id, plus one JSON line per unhandled error carrying the same id — so a 500 in the logs can always be tied back to the request that caused it. GET /api/health returns {"status":"ok"} without auth and is the liveness probe.

Operator responsibilities

From SECURITY.md, worth repeating here:

  • Data at rest is not encrypted. SQLite stores plaintext on disk. Use an encrypted volume if the host is untrusted, and lock down the file's ownership and permissions. (The optional end-to-end encryption feature is a separate, per-calendar thing — see Security Model.)
  • Free/busy is shared across all users of an instance by design — opaque busy intervals, no event details. Only register accounts you trust.
  • Run behind HTTPS. Secure cookies and HSTS only engage when NODE_ENV=production.