Installation
Run Sancho on your own server behind a single subdomain (e.g. sancho.example.com) using pre-built GHCR images and Docker Compose.
Requirements
Section titled “Requirements”- docker
- Docker Compose plugin (v2.20+)
Directory contents
Section titled “Directory contents”| 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— dailypg_dumpto S3 via thepostgres-backupservice. 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.
Architecture
Section titled “Architecture” 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).
Prerequisites
Section titled “Prerequisites”- Linux server, Docker 24.0.6+ with Docker Compose v2.20+ (
required: falseindepends_onwas 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)
Third-party services
Section titled “Third-party services”| 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 |
Generate secrets
Section titled “Generate secrets”openssl rand -hex 32 # API_SESSION_SECRETopenssl rand -hex 32 # API_COOKIE_SECRETopenssl rand -base64 24 # PG_PASSWORD, RABBITMQ_ADMIN_PASSWORD, ROOT_PASSWORDConfigure the .env file
Section titled “Configure the .env file”cp .env.example .envSecure file permissions
Section titled “Secure file permissions”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.
chmod 600 .envchown root:root .envchmod 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.
Core (required)
Section titled “Core (required)”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 toprod(master images:sancho-wms-{api,app}-prod). Set todevto track develop-branch pre-releases (sancho-wms-{api,app}-dev).DEPLOY_ENV— S3 bucket-name suffix (sancho-env-${DEPLOY_ENV}andsancho-env-${DEPLOY_ENV}-account-1). Defaults toprod. Do not change after the first boot — the bucket names are derived from it and are not migrated.
Secrets (required)
Section titled “Secrets (required)”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.envonly affects future seeds, not the existing root account’s password.
Database / queue (required)
Section titled “Database / queue (required)”Keep the defaults unless you have a reason to change them.
PG_USER— defaults tosancho.PG_DB— defaults tosancho.RABBITMQ_ADMIN_USER— defaults tosancho.
Logging (required)
Section titled “Logging (required)”Both variables are required — a missing one stops the api at startup.
API_LOG_LEVEL— api log level:trace,debug,info,warn,error, orfatal. The template shipsinfo.API_LOG_PRETTY—trueformats logs human-readably; keepfalse(JSON) in production.
Email transport (one of the two required)
Section titled “Email transport (one of the two required)”If both are set, Cloudflare wins.
SMTP_HOST— relay hostname.SMTP_PORT— defaults to587.SMTP_USER— optional; leave blank for relays that authenticate by password alone.SMTP_PASSWORD— required withSMTP_HOST.
CLOUDFLARE_EMAIL_ACCOUNT_ID— Cloudflare account ID.CLOUDFLARE_EMAIL_API_TOKEN— account-scoped token with theEmail Routing: Sendpermission.
Auth methods
Section titled “Auth methods”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_AUTH—truekeeps email/password sign-in on; setfalseto 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.
Built-in OAuth 2.0 client — ships with every Nextcloud, no extra app needed. The profile is fetched via the OCS provisioning API; the access token is a full-account Bearer.
- Configure in the Sancho UI: Account → Configuration — enter the Nextcloud base URL (e.g.
https://cloud.example.com) and the OAuth 2.0 client credentials. Settings are stored in the database — no.envvariables and no api restart. - Create the client in Nextcloud: Admin settings → Security → OAuth 2.0 clients → Add client.
- Redirection URI:
https://<DEPLOY_HOSTNAME>/api/login/nextcloud/<accountName>/callback(the Sancho account name; on self-hosted — the root account’s).
For Nextcloud installs running a third-party OIDC app (oidc or user_oidc, installed via the app catalogue). Register the client in that app’s admin UI, not in the built-in OAuth 2.0 clients page — the two stores are separate. The api requests scopes openid profile email groups offline_access, validates the id_token, and reads the standard /userinfo endpoint. Discovery is lazy on the first sign-in click against <Nextcloud URL>/.well-known/openid-configuration, so a cold Nextcloud does not block api boot.
- Configure in the Sancho UI: Account → Configuration — enter the Nextcloud base URL and the client credentials from the OIDC app’s panel. Settings are stored in the database — no
.envvariables and no api restart. - Redirection URI:
https://<DEPLOY_HOSTNAME>/api/login/nextcloud-oidc/<accountName>/callback(the Sancho account name; on self-hosted — the root account’s). - Automatic role assignment from groups — an option in the same configuration. When enabled, the api reads the
groups[]claim on every OIDC sign-in and syncs the user’s role: the first custom Sancho role whose name matches a group name is assigned; if no group matches, the user is reverted to the built-inMemberrole.SuperAdminaccounts are never modified, and built-in role names (Member,SuperAdmin) are skipped during matching. Create the matching custom roles in Sancho first.
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
CreateBucketpermission and let the api provision them on first boot.
-
In-stack Garage — set
COMPOSE_PROFILES=garageandS3_ENDPOINT=http://garage:3900. GenerateS3_API_KEY/S3_API_SECRET/GARAGE_RPC_SECRETwithopenssl 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:3900for in-stack Garage).S3_API_KEY— access key.S3_API_SECRET— secret key.GARAGE_RPC_SECRET— required only whengarageis inCOMPOSE_PROFILES. Generate withopenssl 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 forOCR_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.
Optional profiles
Section titled “Optional profiles”Activate via COMPOSE_PROFILES (CSV: backup, garage, or both).
COMPOSE_PROFILES— e.g.backup,garage, orbackup,garage. Blank = only the core services run.BACKUP_S3_BUCKET— bucket name for the daily pg_dump. Required only whenbackupis inCOMPOSE_PROFILES. The api does not create this bucket — pre-create it on your external S3, or for in-stack Garage rundocker compose exec garage /garage bucket create $BACKUP_S3_BUCKETonce. 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 theMember(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.
Optional
Section titled “Optional”Leave blank to disable.
ROOT_EMAIL— overrides the root user’s email address; defaults toroot_${DEPLOY_ENV}@sancho-wms.pl(withDEPLOY_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.
Registry login
Section titled “Registry login”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:
echo <TOKEN> | docker login ghcr.io -u sancho-wms --password-stdinUse the username and token Sancho provided. Credentials land in ~/.docker/config.json and persist across reboots — re-run only if the token is rotated.
First deploy
Section titled “First deploy”-
Pull the images:
Terminal window docker compose pull -
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.
First sign-in
Section titled “First sign-in”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.
Optional features
Section titled “Optional features”PostHog analytics
Section titled “PostHog analytics”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.
In-stack S3 backend (Garage)
Section titled “In-stack S3 backend (Garage)”See In-stack S3 backend (Garage) — full guide: when to use it, generating secrets, activating the profile, verification, credential rotation, restoring, and troubleshooting.
Operations
Section titled “Operations”Persistent data
Section titled “Persistent data”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/
- …
Backups
Section titled “Backups”See Backups — automated S3 backup service, ad-hoc dumps, and restore procedures.
Updating
Section titled “Updating”See Updating — release-channel selection, manual upgrade, and the systemd auto-update timer.
Troubleshooting
Section titled “Troubleshooting”Security notes
Section titled “Security notes”.envmust bechmod 600and owned by the user that runsdocker 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_PASSWORDin.envonly 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.enveven after rotation, because the api re-validates config on every restart. - Rotating
API_SESSION_SECRETorAPI_COOKIE_SECRETinvalidates every active session — users will be signed out. - Rotating
PG_PASSWORD/RABBITMQ_ADMIN_PASSWORDmust happen first inside the database / broker, then in.env, thendocker compose up -d. Rotating only.envwill leave the api unable to authenticate. - Back up
./data/caddy; losing the ACME account key can delay re-issuance of certificates.