Skip to content

Installation

Run Sancho on your own server behind a single subdomain (e.g. sancho.example.com) using pre-built GHCR images and Docker Compose.

File Purpose
docker-compose.yaml Service definitions (postgres, rabbitmq, redis, api, app, caddy + optional postgres-backup, garage)
Caddyfile Outer Caddy config — TLS termination + reverse proxy
garage.toml Single-node Garage config; used only when the garage profile is active
.env.example Runtime env-var template; copy to .env
sancho-update.service Optional systemd unit: pulls and restarts the api and app containers
sancho-update.timer Optional systemd timer: fires the update service daily at 04:00 UTC

Core services (always running): postgres, rabbitmq, redis, api, app, caddy. Optional profiles (off by default, opt in via COMPOSE_PROFILES in .env):

  • backup — daily pg_dump to S3 via the postgres-backup service. See Backups.
  • garage — in-stack S3 backend, when you don’t want an external provider. See In-stack S3 backend (Garage).

Set COMPOSE_PROFILES=backup,garage (or any subset) in .env before docker compose up -d.

Browser
│ https://sancho.example.com
┌─────────────────┐
│ Caddy (80/443) │ auto-TLS via Let's Encrypt
└────────┬────────┘
┌─────────┼──────────┐
│ │ │
/api/* /v1/* /analytics/* everything else
│ │ │
▼ ▼ ▼
api:3100 PostHog app:80
│ (EU) (static SPA)
┌─────┼─────┐
│ │ │
▼ ▼ ▼
postgres rabbitmq redis
api → external: SMTP relay (or Cloudflare Email Send), Google OAuth, Nextcloud OAuth, S3, Parseur, [Stripe]

Single subdomain, path-based routing. Cookies are scoped to DEPLOY_HOSTNAME; HTTPS is mandatory (session cookies are SameSite=Strict; Secure; HttpOnly).

  • Linux server, Docker 24.0.6+ with Docker Compose v2.20+ (required: false in depends_on was added in Compose v2.20)
  • Public domain with an A/AAAA DNS record pointing at the server
  • Ports 80 and 443 open (port 80 is needed for the ACME HTTP challenge)
  • ≥ 4 vCPU / 16 GB RAM (recommended)
Service Purpose Required?
Google Cloud OAuth User sign-in Required if using Google SSO
Nextcloud OAuth (built-in) User sign-in via your Nextcloud (built-in OAuth 2.0 client) Optional (alternative to Google)
Nextcloud OIDC User sign-in via Nextcloud with a third-party OIDC app Optional (alternative to the OAuth block)
SMTP relay (or Cloudflare Email Send) Transactional email (password reset, verification) One of the two is required
S3-compatible storage Avatar + delivery-note image storage Required (external provider or in-stack garage profile)
Parseur Delivery-note OCR Only for OCR_PROVIDER=parseur
Anthropic Delivery-note OCR on a Claude model Only for OCR_PROVIDER=anthropic
PostHog Product analytics Optional
Axiom Log aggregation Optional
Terminal window
openssl rand -hex 32 # API_SESSION_SECRET
openssl rand -hex 32 # API_COOKIE_SECRET
openssl rand -base64 24 # PG_PASSWORD, RABBITMQ_ADMIN_PASSWORD, ROOT_PASSWORD
Terminal window
cp .env.example .env

Lock down permissions before pasting any secrets. Default umask leaves .env world-readable (0644). The file will hold ROOT_PASSWORD, session / cookie secrets, the Postgres and RabbitMQ passwords, S3 keys, OAuth client secrets, and the Cloudflare/Parseur/Axiom tokens in cleartext — restrict it before you paste any of those in.

Terminal window
chmod 600 .env
chown root:root .env

chmod 600 keeps the file readable only by its owner; group / other get nothing. Under 0600 the group ownership is irrelevant, so root:root is fine even if other accounts are in the docker group. After writing your secrets, verify with ls -l .env — the output must start with -rw-------.

The template groups variables by purpose. Fill each group in order.

  • DEPLOY_HOSTNAME — the public FQDN, e.g. sancho.example.com. The api also uses this value to scope session cookies and to build OAuth callback / email URLs, so it must match the URL users visit.
  • ACME_EMAIL — the contact address Caddy registers with Let’s Encrypt for expiry warnings.
  • SANCHO_CHANNEL — release channel. Defaults to prod (master images: sancho-wms-{api,app}-prod). Set to dev to track develop-branch pre-releases (sancho-wms-{api,app}-dev).
  • DEPLOY_ENV — S3 bucket-name suffix (sancho-env-${DEPLOY_ENV} and sancho-env-${DEPLOY_ENV}-account-1). Defaults to prod. Do not change after the first boot — the bucket names are derived from it and are not migrated.

All these secrets are mandatory — the api’s config schema rejects missing values at startup and the container exits immediately. Paste outputs from Generate secrets.

  • API_SESSION_SECRET — hex value (min. 32 chars), signs session cookies.
  • API_COOKIE_SECRET — hex value (min. 32 chars), signs other cookies.
  • PG_PASSWORD — base64 value, Postgres user password.
  • RABBITMQ_ADMIN_PASSWORD — base64 value, RabbitMQ admin password.
  • ROOT_PASSWORD — base64 value, seeds the root user on first boot. Must stay set on every restart (config is re-validated); rotating it in .env only affects future seeds, not the existing root account’s password.

Keep the defaults unless you have a reason to change them.

  • PG_USER — defaults to sancho.
  • PG_DB — defaults to sancho.
  • RABBITMQ_ADMIN_USER — defaults to sancho.

Both variables are required — a missing one stops the api at startup.

  • API_LOG_LEVEL — api log level: trace, debug, info, warn, error, or fatal. The template ships info.
  • API_LOG_PRETTYtrue formats logs human-readably; keep false (JSON) in production.

If both are set, Cloudflare wins.

  • SMTP_HOST — relay hostname.
  • SMTP_PORT — defaults to 587.
  • SMTP_USER — optional; leave blank for relays that authenticate by password alone.
  • SMTP_PASSWORD — required with SMTP_HOST.

Email/password and Google sign-in are configured in .env — a blank block hides the matching sign-in option, and the web app discovers which methods are enabled from the api at runtime, so toggling only needs an api restart — no image rebuild. Nextcloud sign-in (OAuth and OIDC) is instead configured in the Sancho UI (Account → Configuration) — credentials, email domain allowlists, and group-to-role mapping are stored in the database, and the sign-in button appears automatically, with no .env variables and no restart. Nextcloud sign-in must start from Sancho’s email-first sign-in form: the api requires the resolved email as login_hint and rejects callbacks where Nextcloud returns a different email. Pick one Nextcloud path (OAuth or OIDC); enabling both works but shows two identical buttons and risks a providerId collision if the same user signs in via both.

  • ENABLE_EMAIL_AUTHtrue keeps email/password sign-in on; set false to force SSO.
  • GOOGLE_ID / GOOGLE_SECRET — OAuth client from Google Cloud Console → APIs & Services → Credentials → Create OAuth client → Web application.
  • Authorized redirect URI: https://<DEPLOY_HOSTNAME>/api/login/google/callback.
  • Both values must be set for the button to appear.

S3 (required) — pick one of two backends

Section titled “S3 (required) — pick one of two backends”
  • External provider (recommended for production) — AWS S3, Cloudflare R2, Backblaze B2, MinIO, etc. Two ways to provision the buckets:

    • Pre-create the buckets and grant the access key object-level access on them.
    • Or grant the key CreateBucket permission and let the api provision them on first boot.
  • In-stack Garage — set COMPOSE_PROFILES=garage and S3_ENDPOINT=http://garage:3900. Generate S3_API_KEY / S3_API_SECRET / GARAGE_RPC_SECRET with openssl rand. No extra commands — the api creates its buckets on first boot. See In-stack S3 backend (Garage).

Variables:

  • S3_ENDPOINT — S3-compatible endpoint URL (http://garage:3900 for in-stack Garage).
  • S3_API_KEY — access key.
  • S3_API_SECRET — secret key.
  • GARAGE_RPC_SECRET — required only when garage is in COMPOSE_PROFILES. Generate with openssl rand -hex 32.

OCR_PROVIDER picks the recognition engine: parseur (default), custom — your own HTTP service, or anthropic — extraction on an Anthropic Claude model. The contract for custom is documented in Custom OCR.

For OCR_PROVIDER=parseur, sign up at parseur.com, create a parser tailored to your suppliers’ delivery-note layouts, then copy the API key and parser ID from the parser settings.

  • PARSEUR_API_KEY — from the Parseur dashboard. Required only for OCR_PROVIDER=parseur.
  • PARSEUR_GOODS_ISSUED_NOTE_PARSER_ID — target parser ID. Leave blank to disable delivery-note OCR.

For OCR_PROVIDER=custom, OCR_CUSTOM_BASE_URL is required; OCR_CUSTOM_SUBMIT_PATH, OCR_CUSTOM_STATUS_PATH and OCR_CUSTOM_API_KEY are optional. An invalid combination stops the API from starting.

For OCR_PROVIDER=anthropic, ANTHROPIC_API_KEY is required; OCR_ANTHROPIC_MODEL (default claude-opus-5) is optional. There is no parser setup — document scans are sent to the Anthropic API for extraction.

Activate via COMPOSE_PROFILES (CSV: backup, garage, or both).

  • COMPOSE_PROFILES — e.g. backup, garage, or backup,garage. Blank = only the core services run.
  • BACKUP_S3_BUCKET — bucket name for the daily pg_dump. Required only when backup is in COMPOSE_PROFILES. The api does not create this bucket — pre-create it on your external S3, or for in-stack Garage run docker compose exec garage /garage bucket create $BACKUP_S3_BUCKET once. See Backups.

Single-account model with domain-allowlisted signup

Section titled “Single-account model with domain-allowlisted signup”

Self-hosted Sancho runs as a single tenant: SSO sign-ins (Google and Nextcloud) land in the root account. Additional users join the root account in one of two ways:

  • Domain allowlist — configure in the Sancho UI: Account → Configuration → Allowed email domains (values are stored in the database, not in .env). Users signing in via Google or Nextcloud are auto-added to the root account with the Member (non-admin) role. When the list is non-empty, the email domain must match — attempts from outside the list are rejected with a redirect to /sign-in?error=signup_disabled. An empty list means no domain restriction for SSO.
  • Invitations — the root admin can invite any email address from the in-app members page; the recipient accepts via the standard invitation link and joins the root account with the role chosen by the admin. Invitations bypass the domain allowlist.

Member users have no admin policy — they can use the application but cannot run root-only operations. The root admin can promote them to a different role from the members page. Email + password registration is controlled by ENABLE_EMAIL_AUTH (the domain allowlist does not apply to it) — set it to false to restrict sign-in to SSO only.

Leave blank to disable.

  • ROOT_EMAIL — overrides the root user’s email address; defaults to root_${DEPLOY_ENV}@sancho-wms.pl (with DEPLOY_ENV=prod: root_prod@sancho-wms.pl). Set it before the first boot — the seed creates the root user only once.
  • POSTHOG_KEY — product analytics key served to the web app.
  • AXIOM_DATASET — Axiom log dataset name.
  • AXIOM_TOKEN — Axiom API token.

The api and app images are published to ghcr.io/sancho-wms/* as private packages, so docker compose pull needs credentials — both for the first deploy and every subsequent update. Sancho issues you a read-only token scoped to these packages; you don’t need to create a GitHub PAT yourself.

Log in on the host as the user who will run docker compose. The systemd update timer runs as root, so if you plan to enable it, run this as root:

Terminal window
echo <TOKEN> | docker login ghcr.io -u sancho-wms --password-stdin

Use the username and token Sancho provided. Credentials land in ~/.docker/config.json and persist across reboots — re-run only if the token is rotated.

  1. Pull the images:

    Terminal window
    docker compose pull
  2. Bring up the stack:

    Terminal window
    docker compose up -d

The api automatically applies migrations and seeds the root user on every boot. The first visit to https://<DEPLOY_HOSTNAME> triggers Caddy to request a Let’s Encrypt certificate — usually 10–30 seconds.

The seed creates a root user at root_prod@sancho-wms.pl (the default is root_${DEPLOY_ENV}@sancho-wms.pl; overridable via ROOT_EMAIL in .env set before the first boot) with the ROOT_PASSWORD you set. For everyday users, prefer Google or Nextcloud SSO.

Set POSTHOG_KEY in .env to your PostHog project key, then docker compose up -d to restart the api. The key is served to the web app at runtime (via /api/trpc/getAppConfig) — no rebuild needed, and unsetting it later disables analytics on the next api boot. The outer Caddy already proxies /analytics/* to PostHog EU, so no other configuration is required.

See In-stack S3 backend (Garage) — full guide: when to use it, generating secrets, activating the profile, verification, credential rotation, restoring, and troubleshooting.

All persistent state lives under ./data/ next to the compose file. Docker auto-creates these dirs on first up; the postgres / rabbitmq / redis entrypoints chown them to the right uid on first boot, so no manual setup is required. Files end up owned by the uid each container runs as (postgres=999, redis=999, rabbitmq=100, caddy=root), so sudo is usually needed for host-side tar / rsync.

  • Directorydata/
    • Directorypostgres/
    • Directoryrabbitmq/
    • Directoryredis/
    • Directorycaddy/
    • Directorycaddy-config/

See Backups — automated S3 backup service, ad-hoc dumps, and restore procedures.

See Updating — release-channel selection, manual upgrade, and the systemd auto-update timer.

  • .env must be chmod 600 and owned by the user that runs docker compose. See Secure file permissions.
  • Change every default password in .env (Postgres, RabbitMQ, root user).
  • Only expose ports 80 and 443 on your firewall. Postgres, RabbitMQ, Redis, and the api are reachable only within the compose network.
  • RabbitMQ’s management UI (port 15672) is not exposed by the compose file. If you need it, add a handler in the Caddyfile with basic_auth.
  • Rotating ROOT_PASSWORD in .env only changes the seed for fresh installs — for an existing deployment, change it via the in-app password change UI as the root user. The value must stay set in .env even after rotation, because the api re-validates config on every restart.
  • Rotating API_SESSION_SECRET or API_COOKIE_SECRET invalidates every active session — users will be signed out.
  • Rotating PG_PASSWORD / RABBITMQ_ADMIN_PASSWORD must happen first inside the database / broker, then in .env, then docker compose up -d. Rotating only .env will leave the api unable to authenticate.
  • Back up ./data/caddy; losing the ACME account key can delay re-issuance of certificates.