Skip to content

Self-Hosting

Musubi is designed to be self-hosted. If you want your calendar data on your own infrastructure, this is the path.

  • A server with Docker and Docker Compose installed
  • A domain name (optional but recommended for HTTPS)

No clone or build needed — the API ships as a prebuilt image (frgtndev/musubi). Grab the Compose file and an env template into an empty directory:

Terminal window
mkdir musubi && cd musubi
curl -O https://raw.githubusercontent.com/frgtn-dot-dev/musubi/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/frgtn-dot-dev/musubi/main/.env.example

Edit the .env you just downloaded and set your production values. Key variables:

Variable Description
POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB Database credentials — Compose starts Postgres with these and builds DATABASE_URL from them
DATABASE_URL PostgreSQL connection string (Compose overrides this to point at the db service)
BETTER_AUTH_SECRET Long random secret — generate with openssl rand -base64 32
BETTER_AUTH_URL The public URL of your API, e.g. https://musubi.yourdomain.com
ENVIRONMENT Set to anything other than dev for production
LOG_LEVEL Server verbosity: debug, info, warn, error, or silent; defaults to info
METRICS_PORT Internal Prometheus listener; defaults to 9464, set 0 to disable
GOOGLE_WEB_CLIENT_ID, GOOGLE_CLIENT_SECRET Google OAuth credentials — needed for Google sign-in and Google Calendar sync
MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET Microsoft Entra ID app credentials — needed for Outlook calendar sync. Add {BETTER_AUTH_URL}/api/auth/callback/microsoft as a Web redirect URI on the app registration; MICROSOFT_TENANT_ID defaults to common
APPLE_CLIENT_ID Enables native Sign in with Apple (iOS) — set to the app bundle id (dev.frgtn.musubi); the server verifies the identity token’s audience against it
APPLE_TEAM_ID Apple Developer Team ID — serves the apple-app-site-association file so iOS invite links open the app directly (universal links). Optional; unset → the Safari hand-off still works
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, FROM_EMAIL Outgoing mail for password-reset and account-deletion confirmation emails
CALDAV_ENC_KEY Encrypts stored CalDAV passwords — required before anyone connects a CalDAV account

Use the Environment variable reference for the complete list, exact defaults, build-time variables, and rotation consequences.

The API writes one JSON object per log line to stdout/stderr, which works directly with Docker, Dokploy, and most log collectors. Every HTTP response includes an x-request-id header; the same id appears in request, auth, and error logs. Set LOG_LEVEL=debug temporarily for per-account and per-calendar provider-sync counts and timings. Logs never include request bodies, passwords, or tokens.

The API starts a second HTTP listener for Prometheus on METRICS_PORT (default 9464). It serves only GET /metrics; the public API and reverse proxy remain on API_SERVER_PORT. Docker Compose does not publish the metrics port to the host. The Dokploy Compose service attaches the stable musubi-api alias to the shared Docker network, so a Prometheus container on that network can scrape it:

scrape_configs:
- job_name: musubi-api
static_configs:
- targets:
- musubi-api:9464

Metrics include Node.js/process health plus bounded HTTP request counts, in-flight requests, latency histograms, and external-calendar sync failures. HTTP labels use registered route patterns rather than concrete URLs, so user IDs, invite tokens, and other path parameters are not exposed. Provider and sync-stage labels are also constrained to known values.

The repository includes basic alert rules for an unavailable API, repeated 5xx responses, and Google/Outlook/CalDAV sync failures:

rule_files:
- /etc/prometheus/musubi-alerts.yml

Mount ops/prometheus/musubi-alerts.yml at that path and connect Prometheus to your Alertmanager notification receiver. The rules create alerts; Alertmanager is what delivers them by email, Slack, or another channel. Do not add a public domain or host port for the metrics listener. Set METRICS_PORT=0 if metrics are not needed.

Terminal window
docker compose up -d

This pulls the API image, starts it alongside PostgreSQL, publishes the API on API_SERVER_PORT, and runs migrations automatically. The image executes the Drizzle ORM migrator in packages/db/src/migrate.ts before starting Express; it does not need the development-only drizzle-kit CLI at runtime.

When users open Musubi for the first time, they can enter a custom server URL on the welcome screen. Give them your server’s address — e.g. https://musubi.yourdomain.com.

The welcome screen adapts to your server: it asks GET /api/v1/server which social logins are configured and shows only those buttons. If you didn’t set GOOGLE_WEB_CLIENT_ID, the “Continue with Google” button simply won’t appear — email/password still works.


Pull the newest image and restart — migrations run automatically on startup:

Terminal window
docker compose pull
docker compose up -d

Pin a specific version instead of latest by setting MUSUBI_TAG to the published image tag in .env (for example MUSUBI_TAG=1.2.0). Building from a clone? Review incoming changes first, then rebuild with docker compose up -d --build.


The only stateful piece is PostgreSQL. Keep at least one encrypted backup outside the server running Musubi; a dump on the same disk does not protect against disk or host loss.

For the repository’s default Dokploy Compose deployment, configure a scheduled backup for the db service in Dokploy’s Backups tab, select an S3-compatible destination, set a retention count, and run Test before relying on it. A daily schedule with at least 14 retained copies is a reasonable starting point. Dokploy documents both scheduled database backups and restoring an S3 backup.

After enabling it, confirm that a new object appears in the bucket and record the most recent successful run somewhere the team checks. Recheck the job after changing database credentials, storage credentials, or deployment names.

The bundled script creates a compressed custom-format dump, validates it before publishing it, and removes local dumps older than the retention window:

Terminal window
./ops/backup-postgres.sh

It defaults to docker-compose.yml, ./backups, and 14 days. Override those settings when scheduling it with cron or your hosting control panel:

Terminal window
MUSUBI_BACKUP_DIR=/var/backups/musubi \
MUSUBI_BACKUP_RETENTION_DAYS=30 \
./ops/backup-postgres.sh

Copy or sync that directory to a separate machine or object-storage bucket.

Run a recovery drill after setting up the job and then regularly. This command starts a temporary PostgreSQL 17 container with no published port, restores the dump, checks that public tables exist, and removes the container afterwards. It does not connect to or change the production database:

Terminal window
./ops/verify-postgres-backup.sh /path/to/downloaded-backup.sql.gz

Both the gzipped custom-format dump produced by Dokploy and the uncompressed .dump produced by the local script are supported.

For an actual recovery, stop the API first, restore into a clean database, run the current migrations, start the API, and verify login plus one calendar read before reopening traffic. Keep the old database untouched until that check has passed.