Environment variables
The API loads the repository-root .env through @musubi/config. Docker
Compose also reads that file, then overrides DATABASE_URL inside the API
container so it can reach the db service.
Minimal development configuration
Section titled “Minimal development configuration”POSTGRES_USER=musubiPOSTGRES_DB=musubiPOSTGRES_PASSWORD=local-development-onlyDATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}
API_SERVER_PORT=7531ENVIRONMENT=devBETTER_AUTH_URL=http://localhost:7531BETTER_AUTH_SECRET=replace-with-a-long-random-valueGenerate the auth secret with openssl rand -base64 32.
Boot and runtime
Section titled “Boot and runtime”| Variable | Required | Default | Used by |
|---|---|---|---|
DATABASE_URL |
Yes | — | API, Drizzle, migrations |
ENVIRONMENT |
Yes | — | Closed set: dev, test, or prod; API feature gates and dev origins |
BETTER_AUTH_URL |
Yes | — | Better Auth base URL, emails, invites, avatar URLs |
BETTER_AUTH_SECRET |
Yes | — | Session signing and OAuth-token encryption; outside dev, at least 32 non-placeholder characters |
API_SERVER_PORT |
No | 7531 |
Public API listener |
EXTERNAL_SYNC_INTERVAL_MIN |
No | 5 |
Provider polling interval; 0 disables scheduled sync |
LOG_LEVEL |
No | info |
debug, info, warn, error, or silent |
METRICS_PORT |
No | 9464 |
Separate Prometheus listener; 0 disables it |
ADMIN_EMAILS |
No | empty (no admin) | Comma-separated, case-insensitive list of accounts allowed to write server announcements; gates the admin write endpoints and the web admin panel link. Empty means this server has no admin — the admin endpoints refuse everyone, not the other way around |
BETTER_AUTH_URL must be an origin, with no /api suffix:
BETTER_AUTH_URL=https://calendar.example.comIt affects security-sensitive and user-visible URLs. In production, use the same public HTTPS origin users enter in the app.
Docker Compose
Section titled “Docker Compose”These values configure the bundled PostgreSQL service rather than the API directly:
| Variable | Required for Compose | Default / notes |
|---|---|---|
POSTGRES_USER |
Yes | Example uses musubi |
POSTGRES_DB |
Yes | Example uses musubi |
POSTGRES_PASSWORD |
Yes | Compose refuses to start when missing |
MUSUBI_TAG |
No | latest; pin a published image tag for controlled upgrades. One tag covers both the API and web images, so they can’t end up mismatched |
PUBLIC_HOST |
Default full stack | Hostname Caddy serves — a domain gets automatic HTTPS; :80 serves HTTP behind another proxy. Dokploy fixes the internal gateway to :80 and takes its public hostname from the UI Domain |
MARKETING_UPSTREAM |
No | host:port of a landing page for /, reachable from the gateway’s network. Unset, / opens the app: the web client redirects it to /app. The app’s own routes are never handed over, so only the apex and unknown paths reach it |
APPLE_SERVICES_ID |
No | Services ID for Sign in with Apple in a browser. With APPLE_KEY_ID, APPLE_PRIVATE_KEY and APPLE_TEAM_ID it turns the web Apple button on; the phone’s native flow uses APPLE_CLIENT_ID and needs none of them |
APPLE_KEY_ID |
No | Key ID of the “Sign in with Apple” .p8 key |
APPLE_PRIVATE_KEY |
No | Contents of that .p8. Escaped newlines (\n) are accepted so it fits one env line. The server signs Apple’s short-lived client secret from it per exchange — there is no six-month token to rotate |
REQUIRE_EMAIL_VERIFICATION |
No | false. true refuses sign-in until the address is confirmed, and sends the confirmation on sign-up and on every refused sign-in. Requires SMTP_HOST — the API refuses to boot otherwise. Applies to accounts that already exist, whose next sign-in is refused and mails them a link |
PUBLIC_HTTP_PORT, PUBLIC_HTTPS_PORT |
No | Host ports the gateway publishes; default 80 and 443 |
The default full stack (docker-compose.yml) publishes only the gateway: the web
and API containers stay on the Compose network, so there is no second origin that
could bypass it. docker-compose.api.yml publishes API_SERVER_PORT for your own
proxy to sit in front of. The Dokploy stack publishes nothing — its UI Domain
reaches only gateway:80 over dokploy-network; web and API remain internal.
Media storage
Section titled “Media storage”Media uses the persistent local media volume by default. Setting S3_BUCKET
switches storage to a remote S3 bucket; Compose never starts an object-storage
service. Existing database avatars move to the selected storage lazily on first
read. Keep the bucket private: clients always use the public API endpoint. Once
S3 is active, the API rejects disabling it or changing bucket/endpoint/region
until media has been migrated explicitly.
| Variable | Purpose |
|---|---|
S3_BUCKET |
Enables remote S3 storage when non-empty |
S3_REGION |
Required with S3_BUCKET |
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY |
Optional only when the runtime supplies AWS-role credentials; otherwise set both |
S3_ENDPOINT |
Optional endpoint for a remote S3-compatible provider; omit for AWS S3 |
S3_FORCE_PATH_STYLE |
Set true only when the remote provider requires path-style URLs |
MEDIA_DIR |
Local fallback path; host default is ./data/media, while Compose fixes it to /app/data/media and mounts the media volume |
All email settings are optional at boot, but the feature needs the complete set:
| Variable | Purpose |
|---|---|
SMTP_HOST |
SMTP hostname; /api/v1/server reports email: true only after the configured server accepts an SMTP handshake |
SMTP_PORT |
Commonly 587 (required STARTTLS) or 465 (implicit TLS) |
SMTP_USER |
SMTP username |
SMTP_PASS |
SMTP password/token |
FROM_EMAIL |
Envelope/from address |
Without SMTP, email/password sign-in still works, but forgotten-password
recovery and email-confirmed account deletion cannot send. The client adapts to
email: false.
Web Push
Section titled “Web Push”Optional. With keys, the server sends event reminders to a browser even when it
is closed; without them it never pushes and each client rings on its own while
it is open. Generate a pair with npx web-push generate-vapid-keys.
| Variable | Purpose |
|---|---|
VAPID_PUBLIC_KEY |
Identifies this server to the browser vendors’ push services. Served to clients as pushPublicKey on /api/v1/server; a browser cannot subscribe without it |
VAPID_PRIVATE_KEY |
Signs every push. A secret, and per-install — do not copy one between servers |
VAPID_SUBJECT |
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 the server cannot sign with would let browsers subscribe to reminders that then fail silently on every send.
Server runtime
Section titled “Server runtime”| Variable | Purpose |
|---|---|
GOOGLE_WEB_CLIENT_ID |
Better Auth web OAuth client and Google id-token audience |
GOOGLE_CLIENT_SECRET |
Server-side OAuth code exchange and token refresh |
GOOGLE_IOS_CLIENT_ID |
Reserved in config; the current server path does not consume it |
Both web id and secret are required for Google Calendar sync to appear in the
server’s syncProviders. A web id alone enables the Google entry in socials,
but a native build also needs the public values below.
Register this exact authorized Web redirect URI in Google Cloud:
{BETTER_AUTH_URL}/api/auth/callback/googleMobile build time
Section titled “Mobile build time”| Variable | Purpose |
|---|---|
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID |
Native Google sign-in id-token audience on both platforms |
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID |
iOS native client id and derived URL scheme |
EXPO_PUBLIC_IOS_APP_STORE_URL |
Direct App Store listing for the forced-update screen |
EXPO_PUBLIC_* values are compiled into the app. Changing them requires a new
native build; restarting the API is not enough. They are identifiers, not
secrets. EXPO_PUBLIC_IOS_APP_STORE_URL is optional for local/development
builds, which fall back to the Musubi download page. A production EAS build
requires a direct https://apps.apple.com/.../id<digits> URL and fails during
config evaluation if it is absent or malformed.
See Authentication and the Google provider guide.
Microsoft / Outlook
Section titled “Microsoft / Outlook”| Variable | Required for Outlook sync | Purpose |
|---|---|---|
MICROSOFT_CLIENT_ID |
Yes | Entra application id |
MICROSOFT_CLIENT_SECRET |
Yes | Server-side code exchange and refresh |
MICROSOFT_TENANT_ID |
No | Defaults to common; set a tenant id to restrict accounts |
Register this exact Web redirect URI:
{BETTER_AUTH_URL}/api/auth/callback/microsoftSee Microsoft / Outlook.
| Variable | Purpose |
|---|---|
APPLE_CLIENT_ID |
Enables native Sign in with Apple; use bundle id dev.frgtn.musubi |
APPLE_TEAM_ID |
Enables the root apple-app-site-association response for iOS invite universal links |
With no APPLE_TEAM_ID, /.well-known/apple-app-site-association returns 404.
The HTML hand-off page and musubi:// deep link still work.
CalDAV and federation secret storage
Section titled “CalDAV and federation secret storage”| Variable | Required when | Format |
|---|---|---|
CALDAV_ENC_KEY |
Connecting CalDAV accounts or storing federated Musubi connections | 32 random bytes as 64 hexadecimal characters |
FEDERATION_ALLOW_PRIVATE_HOSTS |
Only to federate with a Musubi server on a private address | true to allow; anything else refuses. Defaults to allowed only with ENVIRONMENT=dev |
Generate the key with:
openssl rand -hex 32The key protects CalDAV passwords and remote Musubi member tokens with AES-256-GCM. The database stores only ciphertext.
Capability discovery
Section titled “Capability discovery”The public GET /api/v1/server response lets one mobile build adapt to any
self-hosted server:
{ "minClientVersion": "0.1.1", "socials": ["google", "apple"], "syncProviders": ["google", "microsoft", "caldav"], "email": true}socialsdepends on runtime sign-in configuration.syncProvidersrequires the full credential/key pair for each adapter.emailis true only when the SMTP connection and authentication handshake succeeds during API startup. Restart the API after changing SMTP settings.
Use this endpoint during setup before debugging the client UI.
Production checklist
Section titled “Production checklist”-
BETTER_AUTH_URLis the final public HTTPS origin. -
ENVIRONMENTis notdev. - Database, S3, auth, SMTP, OAuth, and CalDAV secrets are stored outside Git.
-
MUSUBI_TAGis pinned when upgrades must be controlled. -
METRICS_PORTis reachable only from the monitoring network. -
CALDAV_ENC_KEYandBETTER_AUTH_SECRETare backed up separately from PostgreSQL. - Client
EXPO_PUBLIC_*ids match the server’s OAuth project and production bundle ids. - Production EAS has the final direct
EXPO_PUBLIC_IOS_APP_STORE_URL.