Skip to content

Latest commit

 

History

History
266 lines (233 loc) · 14.2 KB

deployment.md

File metadata and controls

266 lines (233 loc) · 14.2 KB

Self-hosting

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.

Quick start

Copy container.env.example to container.env and set:

  • APP_URL to the final HTTPS origin, such as https://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_SECRET from openssl rand -base64 32
  • SMTP_HOST, SMTP_PORT, and EMAIL_FROM
  • both SMTP_USER and SMTP_PASS for authenticated SMTP, or neither for a trusted anonymous relay
  • alternative to SMTP on hosts that filter outbound SMTP ports (Hetzner blocks 25/465 by default; Cloudflare SMTP only listens on 465): Cloudflare Email Sending over HTTPS (CF_EMAIL_ACCOUNT_ID plus CF_EMAIL_API_TOKEN with Email Sending:Edit, on an onboarded sending domain). Set both or neither; when set, REST on 443 is used instead of SMTP.
  • EMAIL_UNSUBSCRIBE_SECRET from openssl rand -hex 32
  • SSO-only instances only: set ENABLE_EMAIL_AUTH=false plus OIDC or social credentials instead — SMTP then becomes optional (see below).

Then start the project:

docker compose --env-file container.env up -d

The 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 worker

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

Ports and split-origin deployments

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

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

Optional features

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=false for SSO-only instances: the email form is hidden, password/recovery/verification endpoints return EMAIL_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 plus EMAIL_FROM set: SMTP_HOST, or Cloudflare REST via CF_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). Set SIGNUP_MODE=invite_only for 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 stable BETTER_AUTH_SECRET, and TRUST_PROXY=true behind a correctly configured trusted proxy. With SIGNUP_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=true and works for every account type — email, social/OIDC (including placeholder emails), and anonymous (no email needed). The WebAuthn relying-party ID is the hostname of WEB_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. Set ENABLE_PASSKEY_AUTH=false to 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=true and the required S3_UPLOAD_* values. Configure the bucket with a lifecycle rule that expires objects under tmp/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): methods GET, PUT, POST, DELETE, HEAD, headers *, expose ETag. Without it, browser presigned uploads (document attachments, AI receipt scans) fail at the preflight with CORS 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 with MAX_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 and AI_API_KEY. AI_PROVIDER, model names, and AI_BASE_URL are optional. Each purpose (receipt scan, category suggestion, voice extraction) can override the shared wire config with AI_{RECEIPT,CATEGORY,VOICE}_PROVIDER, AI_{RECEIPT,CATEGORY,VOICE}_BASE_URL, and AI_{RECEIPT,CATEGORY,VOICE}_API_KEY, each falling back to its shared counterpart when empty. The provider must match the endpoint's wire (openai speaks Responses, openai-compatible speaks Chat Completions, anthropic speaks Messages), and boot validation requires the resolved key (shared or per-purpose) for every enabled feature. Reasoning effort is operator-configured via AI_REASONING (shared default) and AI_{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 by AI_RECEIPT_TIMEOUT_SECONDS (default 120), AI_VOICE_TIMEOUT_SECONDS (default 120), and AI_CATEGORY_TIMEOUT_SECONDS (default 30); raise them when self-hosting a slow model. Receipt scans optionally use a dedicated OCR backend: AI_OCR_PROVIDER=ocrspace extracts 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 requires AI_OCRSPACE_API_KEY when receipt extract is enabled; AI_OCRSPACE_TIMEOUT_SECONDS (default 30) bounds the OCR request, and AI_OCR_REQUIRED=true fails the scan when OCR fails instead of falling back to the vision LLM (default false falls 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. Set TRACING_ENABLED=true to 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, default llm). The system-one engine needs AI_SYSTEM_ONE_API_KEY instead of AI_API_KEY, plus optional AI_SYSTEM_ONE_MODEL (default jev-latest), AI_SYSTEM_ONE_TIMEOUT_SECONDS (default 10), and AI_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 with CATEGORY_DICTIONARY_ENABLED / CATEGORY_HISTORY_ENABLED (default true) and tuned with CATEGORY_LOCAL_MIN_SCORE (default 0.8) and CATEGORY_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 dedicated ASSISTANT_CONFIRMATION_SECRET of 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.

Updates and rollback

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

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

Backups

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.

Hardening

  • 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=false until 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 with TRUST_PROXY=true.
  • Protect container.env, database backups, SMTP credentials, and auth secrets.
  • Configure SPF, DKIM, and DMARC for EMAIL_FROM.
  • Pin SPLIIT_TAG when you prefer scheduled upgrades over tracking latest.