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)
docker-compose.yml docker-compose.api.yml docker-compose.dokploy.yml
Contains Gateway + web client + API + PostgreSQL API + PostgreSQL Gateway + web client + API + PostgreSQL
Use it when You want the browser client — the default Your users are on the mobile app only, or you bring your own front end You deploy with Dokploy
Public surface One origin, HTTPS handled for you The API port, behind your own proxy One origin, terminated by Dokploy’s Traefik

The full stack is the default file, so docker compose up -d with no -f gives you the whole thing. API-only is supported and not deprecated — a server whose users are all on the mobile app has nothing to gain from shipping a browser bundle. The web client currently supports desktop and tablet widths; phone browsers show a download gate for the native app.

No clone or build needed — both the API (frgtndev/musubi) and the web client (frgtndev/musubi-web) ship as prebuilt images. Grab the Compose file, the gateway config 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
mkdir -p ops/gateway && curl -o ops/gateway/Caddyfile \
https://raw.githubusercontent.com/frgtn-dot-dev/musubi/main/ops/gateway/Caddyfile

Running API-only? One file, no gateway config — and every docker compose command below then needs -f docker-compose.api.yml:

Terminal window
curl -O https://raw.githubusercontent.com/frgtn-dot-dev/musubi/main/docker-compose.api.yml

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; production rejects placeholders and values shorter than 32 characters
BETTER_AUTH_URL The public URL of your API, e.g. https://musubi.yourdomain.com
ENVIRONMENT Set to prod for production; only dev, test, and prod are accepted
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
S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY Optional remote media storage. Leave S3_BUCKET blank to use the persistent local media volume; Compose does not launch an S3 service. S3_ENDPOINT supports remote S3-compatible providers
GOOGLE_WEB_CLIENT_ID, GOOGLE_CLIENT_SECRET Google OAuth credentials — needed for Google sign-in (the browser redirect flow needs the secret; the phone’s native flow needs only the client id) and Google Calendar sync. Add {BETTER_AUTH_URL}/api/auth/callback/google as an authorized Web redirect URI
MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET Microsoft Entra ID app credentials — needed for Microsoft sign-in on the web and 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 Sign in with Apple in the phone app — set it to the app’s bundle id — set to the app bundle id (dev.frgtn.musubi); the server verifies the identity token’s audience against it
APPLE_SERVICES_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY Sign in with Apple in a browser, which is a separate registration from the app’s: a Services ID whose return URL is {BETTER_AUTH_URL}/api/auth/callback/apple, plus a “Sign in with Apple” key. The server signs Apple’s short-lived client secret from the .p8 itself, so nothing expires behind your back. All three (and APPLE_TEAM_ID) must be set before the web login offers the button
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, email-confirmation and account-deletion emails. Port 465 uses implicit TLS; other ports require STARTTLS. The API verifies SMTP once before listening; restart it after changing these values
REQUIRE_EMAIL_VERIFICATION true refuses sign-in until the address is confirmed. Needs SMTP; the API refuses to boot without it. See Requiring confirmed email addresses
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT Web Push, so the server can send event reminders to a browser that is closed. Generate a pair with npx web-push generate-vapid-keys; they identify this server to the browser vendors’ push services, so they are per-install and the private one is a secret. VAPID_SUBJECT must be a mailto: address or an https URL — push services reject anything else. The public and private keys must be set together: the API refuses to boot with one of them, because advertising a key it cannot sign with would let browsers subscribe to reminders that then fail silently on every send
CALDAV_ENC_KEY Encrypts stored CalDAV passwords and federated Musubi member tokens — required before anyone connects a CalDAV account or a federated calendar
ADMIN_EMAILS Comma-separated emails allowed to write announcements — short product-news messages shown once to every signed-in user. Turns on the web admin panel and its write endpoints; empty (the default) keeps the panel and its endpoints closed to everyone
PUBLIC_HOST The hostname the default gateway serves (e.g. musubi.example.com for automatic HTTPS, or :80 behind another proxy). Dokploy sets its gateway to :80 internally; configure its public hostname as a UI Domain
FEDERATION_ALLOW_PRIVATE_HOSTS Allows federating with a Musubi server on a private address. Off in production: the API fetches other servers on a user’s behalf, so private targets are refused to prevent internal network probing. Set true only if you genuinely federate servers on a LAN or the same box

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 and web images, starts them alongside PostgreSQL behind the Caddy gateway, and runs migrations automatically. The API 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. Caddy obtains and renews the certificate for PUBLIC_HOST on its own — point the DNS record at the server before starting, or the first issue attempt fails.

API-only is the same command with the other file:

Terminal window
docker compose -f docker-compose.api.yml up -d

Off by default, because a private instance among people who know each other gains nothing from it, and a server with no SMTP would create accounts that can never sign in. Turn it on for anything with open registration — an unconfirmed address is a typo at best and someone else’s inbox at worst:

Terminal window
REQUIRE_EMAIL_VERIFICATION=true

Sign-up then sends a confirmation link, and the link both verifies the address and signs the person in, landing them on the calendar (/email-verified on an API-only server). Links expire after an hour; a refused sign-in sends a fresh one, so nobody has to find a way to ask.

Changing an address works the same way from either client: a verified account is asked to approve the move from its current address, so a stolen session cannot walk off with the account; an unverified one confirms the new address instead. Either way the answer to “does that address already exist” is the same sentence, which is what keeps the form from being a way to test who has an account.

The default file runs four services: the API and database, the web client, and a Caddy gateway that fronts both. Only the gateway publishes ports — web and API are reachable only inside the Compose network, so there is no second origin that could bypass it. Use PUBLIC_HOST=:80 for plain HTTP when something else already terminates TLS.

Wherever the proxy lives, it has to make the same split:

Path Goes to Why
/api/stream API, unbuffered Server-sent events. Caddy needs flush_interval -1, or it buffers the response and live updates arrive in clumps, or never
/api/* API Better Auth, the REST surface, the federation gateway
/reset-password, /delete-account, /email-verified, /.well-known/apple-app-site-association API HTML hand-off pages for links arriving from email. They predate the web client and are shared with mobile, so they stay on the API
/invite/* Web client An invitation is a page you read and accept. On an API-only install the same path serves the API’s hand-off page instead, which deep-links into the phone app
everything else Web client The browser app

docker-compose.dokploy.yml keeps the same gateway. Create a Compose project, then add one Domain in Dokploy: choose service gateway, set container port 80, and enable HTTPS. Traefik sends the hostname to Caddy, which applies the checked-in split above. Set BETTER_AUTH_URL to that public HTTPS origin.

For a second, disposable instance off a branch there is docker-compose.dokploy.dev.yml: the same stack, but built from the checkout instead of pulling a published tag, with its own database volume and secrets so a bad branch cannot reach production data. It needs its own Domain and OAuth redirect URIs registered for that hostname.

Add exactly one Domain, on gateway:80. Do not expose web or api; doing so bypasses Caddy and can send /api/* to the TanStack catch-all.

SSE uses Caddy’s explicit unbuffered /api/stream route, the same as the default Compose stack.

If you already run nginx, Traefik or another proxy, you can skip the gateway service and route to the containers yourself. Reproduce the table above, and get these two right:

  • Send /api/stream through unbuffered. nginx needs proxy_buffering off; and proxy_read_timeout long enough (or 0) on that location; Traefik needs no special flag. Buffered SSE is the single most common self-host complaint, and it looks like “live updates are broken”, not like a proxy problem.
  • Keep one origin. Anything that puts the web client and the API on different hostnames breaks the session cookie.

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.

One MUSUBI_TAG pins the API and web images together, so they never end up on mismatched versions. Running API-only or Dokploy? Same commands with -f docker-compose.api.yml / -f docker-compose.dokploy.yml — on Dokploy, bump the variable and redeploy.

Image rollback is safe only while the database schema remains compatible. Pin production versions rather than tracking latest, but treat migrations as forward-only unless a release explicitly documents backward compatibility.

Terminal window
MUSUBI_TAG=1.1.0 docker compose up -d

Back up PostgreSQL plus the local media volume when S3_BUCKET is unset. With remote S3, protect the bucket through provider versioning/backups instead. Keep at least one encrypted copy outside the server running Musubi; a copy 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 (set MUSUBI_COMPOSE_FILE if you run one of the other files). 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.