Self-Hosting
Musubi is designed to be self-hosted. If you want your calendar data on your own infrastructure, this is the path.
Prerequisites
Section titled “Prerequisites”- A server with Docker and Docker Compose installed
- A domain name (optional but recommended for HTTPS)
1. Download the files
Section titled “1. Download the files”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:
mkdir musubi && cd musubicurl -O https://raw.githubusercontent.com/frgtn-dot-dev/musubi/main/docker-compose.ymlcurl -o .env https://raw.githubusercontent.com/frgtn-dot-dev/musubi/main/.env.example2. Configure environment variables
Section titled “2. Configure environment variables”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.
Prometheus metrics
Section titled “Prometheus metrics”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:9464Metrics 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.ymlMount 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.
3. Start everything
Section titled “3. Start everything”docker compose up -dThis 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.
4. Point the mobile app at your server
Section titled “4. Point the mobile app at your server”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.
Updating
Section titled “Updating”Pull the newest image and restart — migrations run automatically on startup:
docker compose pulldocker compose up -dPin 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.
Backups and recovery drills
Section titled “Backups and recovery drills”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.
Dokploy
Section titled “Dokploy”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.
Plain Docker Compose
Section titled “Plain Docker Compose”The bundled script creates a compressed custom-format dump, validates it before publishing it, and removes local dumps older than the retention window:
./ops/backup-postgres.shIt defaults to docker-compose.yml, ./backups, and 14 days. Override
those settings when scheduling it with cron or your hosting control panel:
MUSUBI_BACKUP_DIR=/var/backups/musubi \MUSUBI_BACKUP_RETENTION_DAYS=30 \./ops/backup-postgres.shCopy or sync that directory to a separate machine or object-storage bucket.
Verify that a dump really restores
Section titled “Verify that a dump really restores”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:
./ops/verify-postgres-backup.sh /path/to/downloaded-backup.sql.gzBoth 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.