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)
Which stack do you need?
Section titled “Which stack do you need?”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.
1. Download the files
Section titled “1. Download the files”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:
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.examplemkdir -p ops/gateway && curl -o ops/gateway/Caddyfile \ https://raw.githubusercontent.com/frgtn-dot-dev/musubi/main/ops/gateway/CaddyfileRunning API-only? One file, no gateway config — and every docker compose
command below then needs -f docker-compose.api.yml:
curl -O https://raw.githubusercontent.com/frgtn-dot-dev/musubi/main/docker-compose.api.yml2. 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; 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.
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 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:
docker compose -f docker-compose.api.yml up -dRequiring confirmed email addresses
Section titled “Requiring confirmed email addresses”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:
REQUIRE_EMAIL_VERIFICATION=trueSign-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.
How one origin is split
Section titled “How one origin is split”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 |
Dokploy
Section titled “Dokploy”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.
Using your own reverse proxy instead
Section titled “Using your own reverse proxy instead”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/streamthrough unbuffered. nginx needsproxy_buffering off;andproxy_read_timeoutlong enough (or0) 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.
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.
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.
Rolling back
Section titled “Rolling back”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.
MUSUBI_TAG=1.1.0 docker compose up -dBackups and recovery drills
Section titled “Backups and recovery drills”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.
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 (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:
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.