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
docker compose build
docker compose up -d
docker compose logs -f hyper-calendar # watch startup + migrationsOr plain Docker:
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:latestMigrations 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
| Variable | Default | Purpose |
|---|---|---|
DATABASE_PATH | /app/server/data/hyper-calendar.db | SQLite file. Compose sets /data/hyper-calendar.db on a named volume. |
PORT | 3001 | Listen port. |
NODE_ENV | — | Set to production in deployments (the image does). Gates HSTS, secure cookies, and static SPA serving. |
TZ | system | Server-local timezone; affects the demo seed and server-local defaults. |
TRUST_PROXY | unset | 1 behind a reverse proxy, so rate limiting reads the real client IP from X-Forwarded-For. |
VAPID_PUBLIC_KEY | unset | Web Push public key (also served to clients). |
VAPID_PRIVATE_KEY | unset | Web Push private key. |
VAPID_SUBJECT | unset | Web Push contact (mailto: or https:). |
WEBAUTHN_RP_ID | request host | Passkey relying-party id. Set when behind a proxy or custom domain. |
WEBAUTHN_ORIGIN | request origin | Passkey expected origin. Same. |
CALDAV_SECRET | unset | 32 bytes (hex or base64). Required to link a calendar to a remote CalDAV server. |
AUTH_DEV_AUTOLOGIN | unset | Development 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.
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.
npx web-push generate-vapid-keysSet 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:
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:
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>.sqliteManual backup — safe while running, because VACUUM INTO writes a single consistent file that already folds in the WAL:
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.dbRestore — stop the container, drop the file back in as hyper-calendar.db, remove any stale -wal / -shm, start again. Migrations re-run idempotently:
docker compose stop hyper-calendar
docker compose cp ./hypercalendar-backup.db hyper-calendar:/data/hyper-calendar.db
docker compose start hyper-calendarThe 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.