The supported Docker deployment uses one public origin. The nginx-based web container serves the SPA and forwards the existing API paths to the private API container:
internet -> HTTPS reverse proxy -> web:3000 -> api:3001
|
+-> static SPA
PostgreSQL, migrations, the API, and the worker communicate only on the Compose
network. The default stack does not publish ports 3001, 3003, or 5432.
Copy container.env.example to container.env and set:
APP_URLto the final HTTPS origin, such ashttps://spliit.example.com(canonical form has no trailing slash; a trailing slash is tolerated and stripped with a startup warning — it used to break invite links with//groups/…, see issue #120)- a long random
POSTGRES_PASSWORD BETTER_AUTH_SECRETfromopenssl rand -base64 32SMTP_HOST,SMTP_PORT, andEMAIL_FROM- both
SMTP_USERandSMTP_PASSfor authenticated SMTP, or neither for a trusted anonymous relay - alternative to SMTP on hosts that filter outbound SMTP ports (Hetzner blocks
25/465by default; Cloudflare SMTP only listens on465): Cloudflare Email Sending over HTTPS (CF_EMAIL_ACCOUNT_IDplusCF_EMAIL_API_TOKENwith Email Sending:Edit, on an onboarded sending domain). Set both or neither; when set, REST on443is used instead of SMTP. EMAIL_UNSUBSCRIBE_SECRETfromopenssl rand -hex 32- SSO-only instances only: set
ENABLE_EMAIL_AUTH=falseplus OIDC or social credentials instead — SMTP then becomes optional (see below).
Then start the project:
docker compose --env-file container.env up -dThe web port binds to 127.0.0.1:3000. Point Caddy, nginx, Traefik, HAProxy, or
another TLS-terminating reverse proxy at that address. The proxy should preserve
the public Host header and set X-Forwarded-For, X-Real-IP, and
X-Forwarded-Proto. Spliit Cloud's web gateway preserves those values for the API.
Useful checks:
curl https://spliit.example.com/health/liveness
curl https://spliit.example.com/health/readiness
docker compose --env-file container.env ps
docker compose --env-file container.env logs api workerThe migrate container exiting successfully is expected.
The bundled database image is PostgreSQL 18 and stores its data below
/var/lib/postgresql/<major>/docker; the Compose volume is therefore mounted
at /var/lib/postgresql. If upgrading an existing deployment that used the
legacy /var/lib/postgresql/data mount, do not start the new image until the
database has been migrated. Take a backup first, then use PostgreSQL's
pg_upgrade procedure (or restore a pg_dump into a fresh PostgreSQL 18
volume). A volume containing the old data at the legacy path cannot be reused
by simply changing the image tag.
Change WEB_PORT to move the local web port. Keep BIND_ADDRESS=127.0.0.1
unless the reverse proxy runs on another host or cannot reach host loopback.
The optional API override publishes port 3001:
docker compose \
--env-file container.env \
-f compose.yaml \
-f compose.api-port.yaml \
up -dAPI_BIND_ADDRESS and API_PORT control that mapping. Direct API exposure is
not needed for the normal same-origin setup, including most MCP deployments,
because /auth, /.well-known, and /trpc are already available through the
web origin.
For a separately hosted SPA, build it with VITE_API_URL set to the public API
origin. Set WEB_ORIGINS to the SPA origin and BETTER_AUTH_URL to the API
origin; these override the same-origin APP_URL defaults. This is how the
project can continue serving the web app from Cloudflare Pages while exposing
the API from Dokploy or the API-port Compose override. No runtime config.js is
used.
When the browser is online but the API cannot be reached, the web app shows a
server-down warning instead of claiming the user is offline. Build the SPA with
VITE_STATUS_PAGE_URL set to link that warning (and the matching empty
states) to a status page — Spliit Cloud uses https://status.spliit.cloud/.
Leave it unset on self-hosted instances without a status page: the warning
still appears, just without an external link.
The example environment file contains every supported toggle. Optional values may remain empty.
- OAuth buttons are enabled automatically when both credentials for Google or GitHub are configured.
- Email sign-in (password + magic link) is enabled by default. Set
ENABLE_EMAIL_AUTH=falsefor SSO-only instances: the email form is hidden, password/recovery/verification endpoints returnEMAIL_AUTH_DISABLED, magic-link routes are unmounted, and SMTP becomes optional. At least one SSO provider (OIDC or Google/GitHub/X) is required or the API refuses to start. Existing email accounts cannot sign in while disabled. Email invitations still work without SMTP: the row is created, invite-only signup passes by email match, friends auto-join on SSO sign-in, and groups accept from the in-app pending list — tell the invitee to sign in via SSO, or share a link invite instead. Auth (ENABLE_EMAIL_AUTH) and delivery (a transport plusEMAIL_FROMset:SMTP_HOST, or Cloudflare REST viaCF_EMAIL_ACCOUNT_ID+CF_EMAIL_API_TOKEN) are independent: delivery can be omitted on any instance, and then no EMAIL-channel notification is planned, sent, or retried — stored email preferences are kept but stay inert until delivery returns. The UI reports this state: notification settings show a delivery warning with the Email option disabled, the push onboarding dialog stops offering email as a fallback, and the invite form keeps working with a warning banner. - Account registration defaults to
SIGNUP_MODE=open(anyone who can reach the instance can create an account). SetSIGNUP_MODE=invite_onlyfor a private instance. In that mode the first account on a fresh database can still register; after that, a visitor can create an account only with a pending group or friend email invitation, or by opening a live share-link invite. Existing users can always sign in. Inviting someone to a group or friend ledger is how they get an account. - Anonymous account creation requires
ENABLE_ANONYMOUS_AUTH=true, a stableBETTER_AUTH_SECRET, andTRUST_PROXY=truebehind a correctly configured trusted proxy. WithSIGNUP_MODE=invite_only, new anonymous accounts also require a live group share-link invite; the empty-instance first-account exception does not apply to anonymous accounts. The proxy must sanitize client IP headers so per-client signup and recovery limits are meaningful. Turning anonymous creation off later prevents new anonymous accounts but keeps recovery available for existing ones. Recovery links are permanent bearer credentials, so back up the secret; changing it makes interrupted, not-yet-confirmed setup links unreadable. The background worker permanently deletes anonymous accounts that do not start recovery-link setup within seven days, or leave setup unacknowledged for seven days. Acknowledged anonymous accounts are never removed by this sweep. - Passkey sign-in defaults to
ENABLE_PASSKEY_AUTH=trueand works for every account type — email, social/OIDC (including placeholder emails), and anonymous (no email needed). The WebAuthn relying-party ID is the hostname ofWEB_ORIGINS[0], so every configured web origin must share the same effective domain. Registering a passkey satisfies anonymous onboarding and spares the account from the cleanup sweep above. SetENABLE_PASSKEY_AUTH=falseto hide passkey UI and unmount the passkey endpoints. Enrolling a passkey requires a session younger than 30 days; older sessions are asked to sign in again first. - Expense documents require
PUBLIC_ENABLE_EXPENSE_DOCUMENTS=trueand the requiredS3_UPLOAD_*values. Configure the bucket with a lifecycle rule that expires objects undertmp/imports/after 24 hours. Import retries intentionally retain these temporary objects until the database transaction commits; the lifecycle rule removes abandoned browser sessions and interrupted uploads. The bucket also needs a CORS rule allowing the web origin (APP_URL/WEB_ORIGINS): methodsGET, PUT, POST, DELETE, HEAD, headers*, exposeETag. Without it, browser presigned uploads (document attachments, AI receipt scans) fail at the preflight withCORS Missing Allow Origin. Example for AWS S3 (aws s3api put-bucket-cors --bucket <name>with this--cors-configuration):{"CORSRules": [{"AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600}]}. The per-file attachment limit defaults to 2 MB and can be raised withMAX_EXPENSE_DOCUMENT_SIZE_MB(up to 50; uploads go directly to S3, so no proxy body-size change is needed, but larger values increase API memory use when exporting groups with documents). - AI features require their corresponding
PUBLIC_ENABLE_*flag andAI_API_KEY.AI_PROVIDER, model names, andAI_BASE_URLare optional. Each purpose (receipt scan, category suggestion, voice extraction) can override the shared wire config withAI_{RECEIPT,CATEGORY,VOICE}_PROVIDER,AI_{RECEIPT,CATEGORY,VOICE}_BASE_URL, andAI_{RECEIPT,CATEGORY,VOICE}_API_KEY, each falling back to its shared counterpart when empty. The provider must match the endpoint's wire (openaispeaks Responses,openai-compatiblespeaks Chat Completions,anthropicspeaks Messages), and boot validation requires the resolved key (shared or per-purpose) for every enabled feature. Reasoning effort is operator-configured viaAI_REASONING(shared default) andAI_{RECEIPT,CATEGORY,VOICE}_REASONING(per-purpose override;none|minimal|low|medium|high|xhigh, passed through to the provider). When both are empty the parameter is omitted and the provider decides — the safest setting for models without reasoning support. Provider calls are bounded byAI_RECEIPT_TIMEOUT_SECONDS(default 120),AI_VOICE_TIMEOUT_SECONDS(default 120), andAI_CATEGORY_TIMEOUT_SECONDS(default 30); raise them when self-hosting a slow model. Receipt scans optionally use a dedicated OCR backend:AI_OCR_PROVIDER=ocrspaceextracts raw text via OCR.space (Engine 2, language autodetect) and structures it with a fast text-only call to the receipt model, instead of the default single vision-LLM call (AI_OCR_PROVIDER=none). OCR requiresAI_OCRSPACE_API_KEYwhen receipt extract is enabled;AI_OCRSPACE_TIMEOUT_SECONDS(default 30) bounds the OCR request, andAI_OCR_REQUIRED=truefails the scan when OCR fails instead of falling back to the vision LLM (defaultfalsefalls back, so a free-tier quota error never breaks scanning). The free OCR.space tier caps files at 1 MB and reads at most 3 PDF pages. SetTRACING_ENABLED=trueto log one human-readable timing tree per AI scan/voice request (step durations, routing branch, token usage) to the API console; off by default, enable when debugging slow scans. Category suggestions run dictionary, then group history, then one AI engine (AI_CATEGORY_ENGINE=llm|system-one, defaultllm). The system-one engine needsAI_SYSTEM_ONE_API_KEYinstead ofAI_API_KEY, plus optionalAI_SYSTEM_ONE_MODEL(defaultjev-latest),AI_SYSTEM_ONE_TIMEOUT_SECONDS(default 10), andAI_SYSTEM_ONE_BASE_URL(default TypeSafe's endpoint; override for a self-hosted compatible server). A self-hosted endpoint must use HTTPS — plaintext HTTP is only accepted for local loopback development — otherwise boot fails fast. Both engines share one confidence floor,AI_CATEGORY_MIN_CONFIDENCE(default 0.5). The local stages can be switched off withCATEGORY_DICTIONARY_ENABLED/CATEGORY_HISTORY_ENABLED(default true) and tuned withCATEGORY_LOCAL_MIN_SCORE(default 0.8) andCATEGORY_LOCAL_SETTLEMENT_MIN_SCORE(default 0.95). Boot validation rejects a system-one engine without its key, so misconfiguration fails fast instead of silently suggesting nothing. - Web Push requires the public key, private key, and subject together.
- MCP requires
ENABLE_MCP=true,MCP_PUBLIC_URL, and a dedicatedASSISTANT_CONFIRMATION_SECRETof at least 32 bytes. Deploy the MCP service separately using the MCP guide.
The background worker is part of the base stack because it handles recurring expenses as well as notifications.
latest follows the most recent stable version release. Every release is
also published with its immutable version tag. To upgrade, set SPLIIT_TAG
to the new version before pulling:
export SPLIIT_TAG=v2.0.0
docker compose --env-file container.env pull
docker compose --env-file container.env up -dCheck the GitHub Release for that version first: it lists what changed and any breaking changes with migration steps.
The one-shot migration service applies pending database migrations before the
new API starts. Back up the database before upgrading. Roll back application
images by restoring the previous SPLIIT_TAG; if a release includes an
incompatible database migration, restore the matching database backup as well.
The postgres_data volume contains the primary application data. Use regular
off-host pg_dump backups or an equivalent PostgreSQL-aware backup system.
Periodically test a full restore into a separate PostgreSQL instance. Object
storage must be backed up separately when expense documents are enabled.
- Terminate HTTPS before the web container and enable HSTS at the outer proxy.
- Do not publish PostgreSQL or the worker health port.
- Leave the API private unless a split-origin deployment requires it.
- Keep
TRUST_PROXY=falseuntil the origin is restricted to trusted proxy traffic. Enable it only after that restriction is active; otherwise direct callers can spoof forwarded client-IP headers. For Cloudflare, apply the Cloudflare-source allowlist before deploying withTRUST_PROXY=true. - Protect
container.env, database backups, SMTP credentials, and auth secrets. - Configure SPF, DKIM, and DMARC for
EMAIL_FROM. - Pin
SPLIIT_TAGwhen you prefer scheduled upgrades over trackinglatest.