Skip to content

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.

POSTGRES_USER=musubi
POSTGRES_DB=musubi
POSTGRES_PASSWORD=local-development-only
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}
API_SERVER_PORT=7531
ENVIRONMENT=dev
BETTER_AUTH_URL=http://localhost:7531
BETTER_AUTH_SECRET=replace-with-a-long-random-value

Generate the auth secret with openssl rand -base64 32.

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.com

It affects security-sensitive and user-visible URLs. In production, use the same public HTTPS origin users enter in the app.

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 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.

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.

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/google
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.

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/microsoft

See 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.

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:

Terminal window
openssl rand -hex 32

The key protects CalDAV passwords and remote Musubi member tokens with AES-256-GCM. The database stores only ciphertext.

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
}
  • socials depends on runtime sign-in configuration.
  • syncProviders requires the full credential/key pair for each adapter.
  • email is 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.

  • BETTER_AUTH_URL is the final public HTTPS origin.
  • ENVIRONMENT is not dev.
  • Database, S3, auth, SMTP, OAuth, and CalDAV secrets are stored outside Git.
  • MUSUBI_TAG is pinned when upgrades must be controlled.
  • METRICS_PORT is reachable only from the monitoring network.
  • CALDAV_ENC_KEY and BETTER_AUTH_SECRET are 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.