Skip to content

Self-hosting hyper-calendar

Running your own instance with Docker. For day-two operations — backups, upgrades, monitoring — see Deployment and operations.

This part is for someone who wants to run their own hyper-calendar instance. It ships as a single Docker container that serves both the API and the built frontend on port 3001.

Quick start

With Docker Compose (recommended):

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

Or with 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

On startup the container applies any pending Drizzle migrations against the database before it begins listening, so first boot creates the schema automatically.

HTTPS & 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, so in practice you should put it behind a reverse proxy that terminates TLS (Traefik, Caddy, nginx, …). The proxy only needs to terminate TLS and forward traffic to port 3001 — no forward-auth / IdP is required.

If you run behind a proxy:

  • Set TRUST_PROXY=1 so per-IP rate limiting reads the real client IP from X-Forwarded-For instead of the proxy's address.
  • If the public host differs from what the browser sends as Origin, set WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN so passkeys validate correctly (e.g. WEBAUTHN_RP_ID=cal.example.com, WEBAUTHN_ORIGIN=https://cal.example.com).

Example Traefik labels (TLS termination only — no forward-auth middleware):

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"

Configuration (environment variables)

Every variable is validated at boot by core/src/lib/env.ts. A malformed value exits with an explanation rather than starting up misconfigured, and an optional feature whose key is missing is disabled with a clear message rather than silently degrading to something insecure.

VariableDefaultPurpose
DATABASE_PATH./data/hyper-calendar.dbSQLite file location. The Docker image sets /data/hyper-calendar.db on a named volume.
PORT3001Port the server listens on.
NODE_ENVdevelopmentSet to production for deployments (the image does this). Gates HSTS and secure cookies.
TZsystemServer-local timezone; affects the demo seed and server-local time defaults.
TRUST_PROXYunsetSet to 1 behind a reverse proxy so rate limiting uses the forwarded client IP.
LOG_LEVELinfo in prod, else debugVerbosity of the structured request/error log.
BACKUP_PATHbeside the databaseWhere daily .ics and database snapshots are written.
VAPID_PUBLIC_KEYunsetWeb Push public key (also served to clients). Must be set together with the private key.
VAPID_PRIVATE_KEYunsetWeb Push private key.
VAPID_SUBJECTunsetWeb Push contact URL (mailto: or https:).
WEBAUTHN_RP_IDrequest hostPasskey relying-party id; set when behind a proxy/custom domain.
WEBAUTHN_ORIGINrequest originPasskey expected origin; set when behind a proxy/custom domain.
CALDAV_SECRETunset32 bytes (hex or base64) encrypting stored CalDAV passwords. Absent ⇒ linking disabled.

AUTH_DEV_AUTOLOGIN=1 is a development-only convenience that treats requests without a session as a seeded dev user — never set it in production. (The server warns on startup if it sees this, or missing VAPID/WebAuthn config, while NODE_ENV=production.)

First run & admin

Registration is open self-registration by default. The first account you register becomes the admin. From Settings → Admin the admin can disable further registration once the household's accounts exist, so the instance isn't open to new sign-ups.

Enabling push notifications (server-side)

Web Push is opt-in per server. Without VAPID keys the reminder scheduler stays idle and the in-app toggle reports push isn't configured. Generate a key pair once:

bash
npx web-push generate-vapid-keys

Then set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT in the container's environment and restart. An in-process scheduler then checks roughly every 30s for due reminders and pushes them to each user's registered devices.

Connecting CalDAV clients

hyper-calendar is itself a CalDAV server at /dav (with /.well-known/caldav redirecting there), so Thunderbird, Apple Calendar and DAVx5 can read and write your calendars directly. Nothing needs enabling on the server side; a calendar is exposed per-calendar from its settings.

CalDAV clients authenticate with HTTP Basic, which means they cannot answer a TOTP prompt. Create an app password from the account menu instead — named, scoped read or read-write, and revocable on its own without touching your real password. Use your username plus that app password in the client. Encrypted calendars are never exposed over CalDAV; the server has nothing but ciphertext to hand out.

CalDAV sync (Nextcloud, …)

hyper-calendar can link a calendar to a remote CalDAV collection and keep the two in step both ways — Nextcloud, Radicale, Fastmail and anything else speaking CalDAV. Sidebar → Link CalDAV calendar…, then paste the address of a single calendar (not the server root; hyper-calendar does not do principal discovery) with a username and, ideally, an app password.

This is separate from two things hyper-calendar already did: subscriptions, which are read-only ICS feeds, and hyper-calendar's own CalDAV server at /dav, which lets other apps read hyper-calendar.

Linking needs one setting, because the password has to be stored in a form the server can replay while nobody is signed in:

bash
CALDAV_SECRET=$(openssl rand -base64 32)   # 32 bytes, hex or base64

Without it, linking is refused with an explanatory error rather than storing a password in the clear. The value is encrypted with AES-256-GCM (lib/secretBox) and is never returned by the API, exported, or included in backups. Keep it with your other secrets — losing it means re-entering the passwords, and changing it invalidates every stored one.

Conflicts merge field by field: edits to different fields of the same event both survive, and where both sides changed the same field the remote value wins. Deleting locally while the server edited the event resurrects it rather than discarding an edit that exists nowhere else. Encrypted calendars can't be linked — the server would only ever see ciphertext.

Encrypted calendars

Encryption is a per-user, per-calendar choice made in the app, and needs no server configuration — the key is derived in the browser from a passphrase and never reaches the server. As an operator there are three consequences worth knowing:

  • The database holds ciphertext for those calendars' titles, descriptions, locations and attachments. Times, calendar membership and attendees stay plaintext, so backups still reveal the shape of a schedule.
  • You cannot recover a forgotten passphrase. There is no reset path, by design. Say so before anyone enables it.
  • Encrypted calendars can't be linked to a remote CalDAV server or exposed over /dav, because the server would only ever have ciphertext to send.

npm run test:enc is a dedicated end-to-end suite that asserts the server's SQLite file contains only ciphertext.

The Vosk voice model

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

Backup & restore

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

Automatic snapshots. The server writes a daily full-database snapshot via VACUUM INTO to <data>/backups/_db/hyper-calendar-<timestamp>.sqlite (the 14 newest are kept). An admin can also trigger and download one from the API:

bash
# Create a snapshot now (admin session cookie required):
curl -X POST https://your-host/api/admin/db-backups
# List snapshots:
curl https://your-host/api/admin/db-backups
# Download one (name from the list):
curl -OJ https://your-host/api/admin/db-backups/hyper-calendar-<timestamp>.sqlite

Per-user .ics snapshots are written daily too (restorable from Settings → Data); the DB snapshot above is the complete physical backup.

Manual backup.

bash
# Backup (safe while running — 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.db

# Or, with the container stopped, just copy the volume's files:
docker compose cp hyper-calendar:/data/hyper-calendar.db ./hypercalendar-backup.db

To restore, stop the container, drop the file back into the volume as hyper-calendar.db (remove any stale hyper-calendar.db-wal / hyper-calendar.db-shm), and start again — migrations re-run idempotently on boot:

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