# Published image. Copy this file and deploy/selfhost.env.example (as .env) # into an empty directory. Set SYNAPLAN_VERSION to a release tag, never latest. # Then: docker compose up -d # Open http://127.0.0.1:8000 unless you changed SYNAPLAN_HTTP_BIND or # SYNAPLAN_HTTP_PORT. This file does not build from source and does not need # the git checkout. Leave the eight secrets unset to generate them into # ./data/secrets.env on the first start. Back that file up with the database. # Just want it running, or deploying from a Docker GUI? quickstart/compose.yaml # starts from that one file alone, with no .env and no host directories. name: "${COMPOSE_PROJECT_NAME:-synaplan}" x-app-image: &app-image "${SYNAPLAN_IMAGE:-ghcr.io/metadist/synaplan}:${SYNAPLAN_VERSION:?Set SYNAPLAN_VERSION to a published compatible immutable SemVer release}" x-app-command: &app-command - | set -a . /run/synaplan-secrets/secrets.env set +a # The application reads DB_PASSWORD. The secrets file stores MARIADB_PASSWORD. # Compose leaves DB_PASSWORD empty when the value is generated inside this # stack, so map it after the file is loaded. export DB_PASSWORD="$$MARIADB_PASSWORD" # COMPOSE_PROFILES is a comma-separated list, so the profile is matched as a # list ELEMENT. An equality test against the whole value made # "local-ai,anything" start Ollama — Compose does read the list — while # leaving the app roles pointed at no local AI at all: a stack that looks # enabled and silently answers from the cloud provider instead. case ",$${COMPOSE_PROFILES:-}," in *,local-ai,*) export OLLAMA_BASE_URL=http://ollama:11434 export AUTO_DOWNLOAD_MODELS=true export WHISPER_ENABLED=true ;; *) export OLLAMA_BASE_URL= export AUTO_DOWNLOAD_MODELS=false export WHISPER_ENABLED=false ;; esac case ",$${COMPOSE_PROFILES:-}," in *,office,*) export OFFICE_CONVERT_URL=http://collabora:9980 ;; esac case ",$${COMPOSE_PROFILES:-}," in *,compute,*) export COMPUTE_URL=http://compute:8080 export FEATURE_COMPUTE_ENABLED=true export FEATURE_COMPUTE_WORKSPACES_ENABLED=true ;; esac exec /usr/local/bin/docker-entrypoint.sh x-app-environment: &app-environment APP_ENV: prod APP_DEBUG: "0" # Two PHP worker pools. The stream pool defaults to the api pool, so an # install that only set FRANKENPHP_WORKER_NUM keeps today's stream capacity # and gains a separate pool for short requests. Empty thread values stay # unset: Caddy rejects an empty num_threads. FRANKENPHP_WORKER_NUM: ${FRANKENPHP_WORKER_NUM:-16} FRANKENPHP_STREAM_WORKER_NUM: ${FRANKENPHP_STREAM_WORKER_NUM:-} FRANKENPHP_NUM_THREADS: ${FRANKENPHP_NUM_THREADS:-} FRANKENPHP_MAX_THREADS: ${FRANKENPHP_MAX_THREADS:-} FRANKENPHP_MAX_WAIT_TIME: ${FRANKENPHP_MAX_WAIT_TIME:-15s} # Deployment hint for the release notice: it decides which manual-update guide # the admin UI links to. Optional — an unset or unknown value means the # self-hosted guide, so nothing breaks when a deployment never sets it. SYNAPLAN_PLATFORM: "${SYNAPLAN_PLATFORM:-selfhost}" # The version the operator pinned. Overrides the value baked into the image so # the sidebar and the update check always show the release that is actually # configured — never a mutable registry tag such as `latest` that an older # image build may have recorded as APP_VERSION. APP_VERSION: "${SYNAPLAN_VERSION}" APP_URL: "${APP_URL:?Set APP_URL to the public HTTPS URL}" FRONTEND_URL: "${FRONTEND_URL:?Set FRONTEND_URL to the public HTTPS URL}" APP_SECRET: "${APP_SECRET:-}" TOKEN_SECRET: "${TOKEN_SECRET:-}" # Empty means the Secure flag follows the APP_URL scheme, which is what every # deployment following selfhost.env.example wants. Only a deployment whose TLS # terminates where APP_URL cannot show it needs an explicit value. AUTH_COOKIE_SECURE: "${AUTH_COOKIE_SECURE:-}" BOOTSTRAP_ADMIN_EMAIL: "${BOOTSTRAP_ADMIN_EMAIL:-}" BOOTSTRAP_ADMIN_PASSWORD: "${BOOTSTRAP_ADMIN_PASSWORD:-}" BOOTSTRAP_ADMIN_FORCE_PASSWORD_CHANGE: "${BOOTSTRAP_ADMIN_FORCE_PASSWORD_CHANGE:-false}" # Auth-surface switches (issues #462 / #1517). Empty keeps the application # default, which is enabled, so an existing deployment is unaffected. An # install reachable from the open internet that nobody watches wants # registration off; SSO-/OIDC-only deployments set both to "false". REGISTRATION_ENABLED: "${REGISTRATION_ENABLED:-}" # "open" (default) or "admission": pay for a plan or hold an invitation code. # Empty keeps the application default / the value set in the admin UI. REGISTRATION_MODE: "${REGISTRATION_MODE:-}" GUEST_CHAT_ENABLED: "${GUEST_CHAT_ENABLED:-}" # Feature-flag pins (docs/FEATURE_FLAGS.md). Every flag is ON by default and # managed under Operate > System configuration > Features; an empty value here # keeps it that way. "false" pins the feature off for this deployment and # locks the admin toggle. Module gates (FEATURE_MODULES_GATE_) are per # module and go into a compose override when a deployment needs one. # Exception: FEATURE_IAM_USER_SEARCH_ENABLED is OFF by default (#2060); a # company deployment pins it "true" to let the share picker find accounts. FEATURE_IAM_GROUPS_ENABLED: "${FEATURE_IAM_GROUPS_ENABLED:-}" FEATURE_IAM_SHARING_ENABLED: "${FEATURE_IAM_SHARING_ENABLED:-}" FEATURE_IAM_USER_SEARCH_ENABLED: "${FEATURE_IAM_USER_SEARCH_ENABLED:-}" FEATURE_IAM_GROUP_POLICIES_ENABLED: "${FEATURE_IAM_GROUP_POLICIES_ENABLED:-}" FEATURE_IAM_DIRECTORY_SYNC_ENABLED: "${FEATURE_IAM_DIRECTORY_SYNC_ENABLED:-}" FEATURE_AGENTS_ENABLED: "${FEATURE_AGENTS_ENABLED:-}" FEATURE_AGENTS_ROUTABLE_ENABLED: "${FEATURE_AGENTS_ROUTABLE_ENABLED:-}" FEATURE_BUNDLE_ENABLED: "${FEATURE_BUNDLE_ENABLED:-}" FEATURE_WORKFLOWS_BUILDER_ENABLED: "${FEATURE_WORKFLOWS_BUILDER_ENABLED:-}" FEATURE_MULTITASK_URL_FETCH_ENABLED: "${FEATURE_MULTITASK_URL_FETCH_ENABLED:-}" FEATURE_TOOLS_REGISTRY_ENABLED: "${FEATURE_TOOLS_REGISTRY_ENABLED:-}" FEATURE_TOOLS_APPROVALS_ENABLED: "${FEATURE_TOOLS_APPROVALS_ENABLED:-}" FEATURE_TOOLS_CUSTOM_HTTP_ENABLED: "${FEATURE_TOOLS_CUSTOM_HTTP_ENABLED:-}" FEATURE_DOCUMENT_TOOLS_ENABLED: "${FEATURE_DOCUMENT_TOOLS_ENABLED:-}" FEATURE_DESKTOP_AGENT_ENABLED: "${FEATURE_DESKTOP_AGENT_ENABLED:-}" FEATURE_PLATFORM_LINKS_ENABLED: "${FEATURE_PLATFORM_LINKS_ENABLED:-}" FEATURE_FREEAPI_ENABLED: "${FEATURE_FREEAPI_ENABLED:-}" FREEAPI_BASE_URL: "${FREEAPI_BASE_URL:-https://web.synaplan.com}" # First-run setup wizard. Empty keeps it enabled, which is what a deployment # without BOOTSTRAP_ADMIN_* needs: the API stays closed with 503 SETUP_REQUIRED # until someone creates the first administrator at /setup. It never appears once # the installation has a user, so an upgrade is unaffected either way. SETUP_WIZARD_ENABLED: "${SETUP_WIZARD_ENABLED:-}" # Browser-side (cloud-backed) speech-to-text; air-gapped deployments set "false". WEB_SPEECH_ENABLED: "${WEB_SPEECH_ENABLED:-}" DB_HOST: db DB_PORT: "3306" DB_NAME: "${MARIADB_DATABASE:-synaplan}" DB_USER: "${MARIADB_USER:-synaplan}" DB_PASSWORD: "${MARIADB_PASSWORD:-}" DB_SERVER_VERSION: mariadb-12.3.2 REDIS_DSN: redis://redis:6379 LOCK_DSN: redis://redis:6379 REALTIME_ENABLED: "true" REALTIME_API_URL: http://centrifugo:8000/api REALTIME_API_KEY: "${REALTIME_API_KEY:-}" REALTIME_TOKEN_SECRET: "${REALTIME_TOKEN_SECRET:-}" REALTIME_PUBLIC_WS_URL: "" REALTIME_ALLOWED_ORIGINS: "${REALTIME_ALLOWED_ORIGINS:-${APP_URL}}" QDRANT_URL: http://qdrant:6333 TIKA_BASE_URL: http://tika:9998 # Empty until the `office` profile sets it in x-app-command. OFFICE_CONVERT_URL: "${OFFICE_CONVERT_URL:-}" OFFICE_CONVERT_TIMEOUT_MS: "${OFFICE_CONVERT_TIMEOUT_MS:-60000}" SYNAPLAN_TTS_URL: "${SYNAPLAN_TTS_URL:-http://tts:10200}" COMPUTE_URL: "${COMPUTE_URL:-}" COMPUTE_TOKEN: "${COMPUTE_TOKEN:-}" COMPOSE_PROFILES: "${COMPOSE_PROFILES:-}" OLLAMA_BASE_URL: "" AUTO_DOWNLOAD_MODELS: "false" ENABLE_LOCAL_GPT_OSS: "${ENABLE_LOCAL_GPT_OSS:-false}" AI_DEFAULT_PROVIDER: "${AI_DEFAULT_PROVIDER:-groq}" WHISPER_BINARY: /usr/local/bin/whisper WHISPER_MODELS_PATH: /var/www/backend/var/whisper WHISPER_DEFAULT_MODEL: "${WHISPER_DEFAULT_MODEL:-tiny}" WHISPER_ENABLED: "false" FFMPEG_BINARY: /usr/bin/ffmpeg MAILER_DSN: "${MAILER_DSN:-null://null}" APP_SENDER_EMAIL: "${APP_SENDER_EMAIL:-}" APP_SENDER_NAME: "${APP_SENDER_NAME:-Synaplan}" PHP_MAX_FILE_UPLOADS: "${PHP_MAX_FILE_UPLOADS:-100}" LOG_FORMAT: "${LOG_FORMAT:-json}" OPENAI_API_KEY: "${OPENAI_API_KEY:-}" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" GROQ_API_KEY: "${GROQ_API_KEY:-}" GOOGLE_GEMINI_API_KEY: "${GOOGLE_GEMINI_API_KEY:-}" MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}" XAI_API_KEY: "${XAI_API_KEY:-}" META_API_KEY: "${META_API_KEY:-}" # Operator billing. Empty keys mean open-source mode: no plans, no quotas, no # upgrade prompts, and every tier gate open (see docs/BILLING_SELFHOST.md). # Setting them turns this install into a paid product run by YOU, billed # through YOUR Stripe account. STRIPE_SECRET_KEY: "${STRIPE_SECRET_KEY:-}" STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}" STRIPE_PRICE_PRO: "${STRIPE_PRICE_PRO:-}" STRIPE_PRICE_TEAM: "${STRIPE_PRICE_TEAM:-}" STRIPE_PRICE_BUSINESS: "${STRIPE_PRICE_BUSINESS:-}" STRIPE_PAYMENT_METHODS: "${STRIPE_PAYMENT_METHODS:-}" STRIPE_AUTOMATIC_TAX: "${STRIPE_AUTOMATIC_TAX:-false}" # Human check in front of sign-up (optional; reCAPTCHA is the fallback) and # the account-creation limits (always on; empty keeps the shipped defaults). TURNSTILE_SITE_KEY: "${TURNSTILE_SITE_KEY:-}" TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" SIGNUP_IP_LIMIT: "${SIGNUP_IP_LIMIT:-5}" SIGNUP_GLOBAL_LIMIT: "${SIGNUP_GLOBAL_LIMIT:-20}" CHECKOUT_IP_LIMIT: "${CHECKOUT_IP_LIMIT:-10}" PAID_SIGNUP_ALERT_LIMIT: "${PAID_SIGNUP_ALERT_LIMIT:-10}" # Empty default on purpose: a set value would pin the switch and the admin # toggle (BCONFIG SIGNUP.PAUSED) would no longer take effect. SIGNUP_PAUSED: "${SIGNUP_PAUSED:-}" APP_ADMIN_EMAIL: "${APP_ADMIN_EMAIL:-}" # Per-plan spending ceiling for AI usage. Only ever enforced together with # Stripe, because the budget is denominated in the plan a user pays for. COST_BUDGET_GATE_ENABLED: "${COST_BUDGET_GATE_ENABLED:-false}" x-app-volumes: &app-volumes - ./data/uploads:/var/www/backend/var/uploads - ./data/whisper:/var/www/backend/var/whisper:ro # Read-only: this directory holds every MariaDB dump and Qdrant snapshot, so # it carries all user data, password hashes and encrypted provider keys. The # backup artifacts are written by the HOST (pre-backup.sh redirects the dump # and each snapshot into it), and the only container-side access is # post-restore.sh reading one snapshot file back for upload. Mounting it # writable gave the internet-facing PHP container the ability to read, alter # or destroy the entire backup history for no functional gain. - ./data/backups:/var/www/backup:ro # Application secrets only. The database root password and the realtime # admin password live in other volumes this container does not mount. - synaplan-secrets-app:/run/synaplan-secrets:ro services: secrets-init: image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a restart: "no" environment: APP_SECRET: "${APP_SECRET:-}" TOKEN_SECRET: "${TOKEN_SECRET:-}" MARIADB_PASSWORD: "${MARIADB_PASSWORD:-}" MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:-}" REALTIME_API_KEY: "${REALTIME_API_KEY:-}" REALTIME_TOKEN_SECRET: "${REALTIME_TOKEN_SECRET:-}" REALTIME_ADMIN_PASSWORD: "${REALTIME_ADMIN_PASSWORD:-}" REALTIME_ADMIN_SECRET: "${REALTIME_ADMIN_SECRET:-}" volumes: - ./data:/data - synaplan-secrets-app:/out/app - synaplan-secrets-db:/out/db - synaplan-secrets-realtime:/out/realtime entrypoint: ["/bin/sh", "-ec"] command: - | # SECRETS_INIT_SCRIPT_START # Resolve the eight deployment secrets once, then publish a separate file for # each consumer. data/secrets.env is the backup copy and is never rewritten. # A replace-with-* example value is refused. Nothing is written in that case. # A file that already exists but is missing a key is refused too: a value that # lives only in .env would not be in the backup. # # The published files are sourced by the other containers, so each value is # written as a single-quoted shell literal. The backup file stays raw # KEY=value lines, which is what a restore reads back. # # The same script is inlined in deploy/compose.yaml and # deploy/quickstart/compose.yaml (every "$$" doubled) so neither install needs # this path. deploy/scripts/tests/test-secrets-init.sh fails when a copy differs. set -eu DATA="$${SECRETS_INIT_DATA:-/data}" SECRETS="$$DATA/secrets.env" # The quickstart keeps the database in its own volume, not under $$DATA. DB_DIR="$${SECRETS_INIT_DB_DIR:-$$DATA/mariadb}" # Where the person reading an error finds the backup copy. SECRETS_NAME="$${SECRETS_INIT_FILE_LABEL:-data/secrets.env}" # Each consumer mounts only its own directory. The web container never sees # the database root password or the realtime admin credentials. APP_OUT="$${SECRETS_INIT_APP_OUT:-/out/app/secrets.env}" DB_OUT="$${SECRETS_INIT_DB_OUT:-/out/db/secrets.env}" REALTIME_OUT="$${SECRETS_INIT_REALTIME_OUT:-/out/realtime/secrets.env}" KEYS="APP_SECRET TOKEN_SECRET MARIADB_PASSWORD MARIADB_ROOT_PASSWORD REALTIME_API_KEY REALTIME_TOKEN_SECRET REALTIME_ADMIN_PASSWORD REALTIME_ADMIN_SECRET" APP_KEYS="APP_SECRET TOKEN_SECRET MARIADB_PASSWORD REALTIME_API_KEY REALTIME_TOKEN_SECRET" DB_KEYS="MARIADB_PASSWORD MARIADB_ROOT_PASSWORD" REALTIME_KEYS="REALTIME_API_KEY REALTIME_TOKEN_SECRET REALTIME_ADMIN_PASSWORD REALTIME_ADMIN_SECRET" read_key() { file=$$1 key=$$2 val="" while IFS= read -r line || [ -n "$$line" ]; do case "$$line" in ''|\#*) continue ;; esac case "$$line" in "$$key"=*) val=$${line#*=} ;; esac done < "$$file" printf '%s' "$$val" } # One character at a time, so a secret containing *, ? or [ is not treated as a # pattern. A single quote becomes the four-character sequence '\'' . shell_quote() { input=$$1 quoted="'" while [ -n "$$input" ]; do case "$$input" in \'*) quoted="$${quoted}'\\''" ;; *) quoted="$${quoted}$${input%"$${input#?}"}" ;; esac input=$${input#?} done printf '%s' "$${quoted}'" } stack_initialised() { [ -d "$$DB_DIR" ] || return 1 find "$$DB_DIR" -mindepth 1 -print -quit 2>/dev/null | grep -q . } is_placeholder() { case "$$1" in replace-with-*) return 0 ;; esac return 1 } is_adoptable() { case "$$1" in *'$$'*|*' #'*) return 1 ;; \"*\"|\'*\') return 1 ;; [[:space:]]*|*[[:space:]]) return 1 ;; esac return 0 } generate_secret() { val=$$(hexdump -vn 32 -e '/1 "%02x"' /dev/urandom) case "$$val" in *[!0-9a-f]*) val="" ;; esac if [ "$${#val}" -ne 64 ]; then echo "Synaplan did not start and created nothing: a random secret could not be generated." >&2 exit 1 fi printf '%s' "$$val" } refuse() { echo "Synaplan did not start and created nothing: $$1" >&2 exit 1 } list_keys() { printf '%s' "$$1" | sed 's/^ //; s/ /, /g' } publish_quoted() { dest=$$1 key_list=$$2 dest_dir=$$(dirname "$$dest") mkdir -p "$$dest_dir" chmod 755 "$$dest_dir" umask 022 tmp="$$dest_dir/.secrets.env.$$$$" { printf '%s\n' '# Copy for this start. Do not edit.' for key in $$key_list; do printf '%s=%s\n' "$$key" "$$(shell_quote "$$(read_key "$$SECRETS" "$$key")")" done } > "$$tmp" mv "$$tmp" "$$dest" chmod 644 "$$dest" } file_existed=false if [ -f "$$SECRETS" ]; then file_existed=true fi initialised=false if stack_initialised; then initialised=true fi placeholders="" file_placeholders="" unadoptable="" unresolved="" assignments="" for key in $$KEYS; do val="" if [ "$$file_existed" = true ]; then val=$$(read_key "$$SECRETS" "$$key") if [ -z "$$val" ]; then unresolved="$$unresolved $$key" continue fi if is_placeholder "$$val"; then file_placeholders="$$file_placeholders $$key" continue fi else envval=$$(printenv "$$key" || true) if [ -n "$$envval" ]; then if is_placeholder "$$envval"; then placeholders="$$placeholders $$key" continue fi if ! is_adoptable "$$envval"; then unadoptable="$$unadoptable $$key" continue fi val=$$envval fi if [ -z "$$val" ]; then if [ "$$initialised" = true ]; then unresolved="$$unresolved $$key" continue fi val=$$(generate_secret) fi fi assignments="$${assignments}$${key}=$${val} " done if [ -n "$$placeholders" ]; then refuse "$$(list_keys "$$placeholders") still has the example value. Delete that line so Synaplan generates one, or set it to the output of \"openssl rand -hex 32\"." fi if [ -n "$$file_placeholders" ]; then refuse "$$(list_keys "$$file_placeholders") in $$SECRETS_NAME still has the example value. Replace it with the output of \"openssl rand -hex 32\". The file was not rewritten." fi if [ -n "$$unadoptable" ]; then refuse "$$(list_keys "$$unadoptable") cannot be read unchanged. Use only letters, digits, '-' and '_', or delete the line so Synaplan generates one." fi if [ -n "$$unresolved" ]; then if [ "$$file_existed" = true ]; then refuse "$$(list_keys "$$unresolved") has no value in $$SECRETS_NAME. That file was not changed. Add the original value there, or restore the file from a backup. A value that exists only in .env is not used once this file exists." fi refuse "$$(list_keys "$$unresolved") has no value, and this install already has a database. Restore $$SECRETS_NAME from the backup of this install, or put the original values back. A new password would not open the existing database." fi if [ "$$file_existed" = false ]; then mkdir -p "$$DATA" chmod 0700 "$$DATA" umask 077 tmp="$$DATA/.secrets.env.$$$$" { printf '%s\n' '# Synaplan deployment secrets. Generated once. Back this file up with the database. Do not commit it.' printf '%s' "$$assignments" } > "$$tmp" for key in $$KEYS; do got=$$(read_key "$$tmp" "$$key") if ! printf '%s\n' "$$assignments" | grep -Fxq "$${key}=$${got}"; then rm -f "$$tmp" refuse "the generated secrets file could not be read back. Nothing was kept." fi done mv "$$tmp" "$$SECRETS" chmod 600 "$$SECRETS" fi umask 077 publish_quoted "$$APP_OUT" "$$APP_KEYS" publish_quoted "$$DB_OUT" "$$DB_KEYS" publish_quoted "$$REALTIME_OUT" "$$REALTIME_KEYS" # SECRETS_INIT_SCRIPT_END networks: [internal] backend: image: *app-image pull_policy: "${SYNAPLAN_PULL_POLICY:-always}" entrypoint: ["/bin/bash", "-ec"] command: *app-command environment: <<: *app-environment SYNAPLAN_ROLE: web volumes: *app-volumes ports: - "${SYNAPLAN_HTTP_BIND:-127.0.0.1}:${SYNAPLAN_HTTP_PORT:-8000}:80" depends_on: secrets-init: condition: service_completed_successfully db: condition: service_healthy redis: condition: service_healthy centrifugo: condition: service_healthy tika: condition: service_healthy qdrant: condition: service_healthy healthcheck: test: ["CMD", "/usr/local/bin/container-healthcheck"] interval: 15s timeout: 10s retries: 5 start_period: 300s restart: unless-stopped networks: [internal] # Local network, off by default. Run deploy/scripts/local-tls.sh
# first: it writes deploy/data/tls and adds the local-tls profile. The # application port stays on 127.0.0.1. This proxy is the only listener on # the network. It runs as root so it can read the mode-0600 key; it does # not run the application. tls-proxy: image: caddy:2.11.7-alpine@sha256:eb4d35b7452d1ab6171fbdbe8d9275b2301dad6dad92115d91e19fb9b24d510f profiles: [local-tls] user: "0:0" ports: - "${SYNAPLAN_TLS_BIND:-0.0.0.0}:443:443" - "${SYNAPLAN_TLS_BIND:-0.0.0.0}:80:80" volumes: - ./local-network/Caddyfile:/etc/caddy/Caddyfile:ro - ./data/tls:/etc/caddy/selfsigned:ro depends_on: backend: condition: service_healthy healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "--no-check-certificate", "https://127.0.0.1/"] interval: 15s timeout: 5s retries: 5 start_period: 15s restart: unless-stopped networks: [internal] worker: image: *app-image pull_policy: "${SYNAPLAN_PULL_POLICY:-always}" entrypoint: ["/bin/bash", "-ec"] command: *app-command environment: <<: *app-environment SYNAPLAN_ROLE: worker volumes: *app-volumes depends_on: secrets-init: condition: service_completed_successfully backend: condition: service_healthy redis: condition: service_healthy healthcheck: test: ["CMD", "/usr/local/bin/container-healthcheck"] interval: 30s timeout: 5s retries: 3 start_period: 120s restart: unless-stopped networks: [internal] scheduler: image: *app-image pull_policy: "${SYNAPLAN_PULL_POLICY:-always}" entrypoint: ["/bin/bash", "-ec"] command: *app-command environment: <<: *app-environment SYNAPLAN_ROLE: scheduler volumes: *app-volumes depends_on: secrets-init: condition: service_completed_successfully backend: condition: service_healthy redis: condition: service_healthy healthcheck: test: ["CMD", "/usr/local/bin/container-healthcheck"] interval: 60s timeout: 5s retries: 3 start_period: 120s restart: unless-stopped networks: [internal] db: image: mariadb:13.0.2@sha256:f4054da26e3a9dd6c8b5cc97363e544408b15c0fb369d7359c25dc04826eb6e1 entrypoint: ["/bin/sh", "-ec"] command: - | set -a . /run/synaplan-secrets/secrets.env set +a export MARIADB_PASSWORD="$$MARIADB_PASSWORD" export MARIADB_ROOT_PASSWORD="$$MARIADB_ROOT_PASSWORD" exec docker-entrypoint.sh --max-allowed-packet=256M --innodb-buffer-pool-size=512M environment: MARIADB_DATABASE: "${MARIADB_DATABASE:-synaplan}" MARIADB_USER: "${MARIADB_USER:-synaplan}" MARIADB_PASSWORD: "${MARIADB_PASSWORD:-}" MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:-}" volumes: - ./data/mariadb:/var/lib/mysql - synaplan-secrets-db:/run/synaplan-secrets:ro depends_on: secrets-init: condition: service_completed_successfully healthcheck: # The password is expanded by the container's shell, so it MUST stay # quoted: an unquoted expansion splits a password containing whitespace # into several arguments, mariadb-admin never authenticates, db never turns # healthy, and backend, worker and scheduler wait on it forever. The # healthcheck is a new process, so it sources the secrets file itself. test: ["CMD-SHELL", "set -a; . /run/synaplan-secrets/secrets.env; set +a; mariadb-admin ping -h 127.0.0.1 -uroot -p\"$$MARIADB_ROOT_PASSWORD\" --silent"] interval: 15s timeout: 10s retries: 10 start_period: 60s restart: unless-stopped networks: [internal] redis: image: redis:7.4-alpine@sha256:8da19c7399dca900c6822a1cf30f9d2285585e2c4b457ff4095b0f4500054d3f command: redis-server --appendonly yes --appendfsync everysec --maxmemory 256mb --maxmemory-policy allkeys-lru volumes: - ./data/redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 15s timeout: 5s retries: 5 start_period: 5s restart: unless-stopped networks: [internal] centrifugo: image: centrifugo/centrifugo:v6.10.2@sha256:34b84f823515927266ce3abfb840fd9e0acabb58805e7e896f5de731217a627d entrypoint: ["/bin/sh", "-ec"] command: - | set -a . /run/synaplan-secrets/secrets.env set +a export CENTRIFUGO_CLIENT_TOKEN_HMAC_SECRET_KEY="$$REALTIME_TOKEN_SECRET" export CENTRIFUGO_HTTP_API_KEY="$$REALTIME_API_KEY" export CENTRIFUGO_ADMIN_PASSWORD="$$REALTIME_ADMIN_PASSWORD" export CENTRIFUGO_ADMIN_SECRET="$$REALTIME_ADMIN_SECRET" exec centrifugo environment: # Same settings as _docker/centrifugo/config.json, expressed as env so # this file does not mount a third file. Allowed origins stay the # deployment override. Namespace JSON is checked by # deploy/scripts/tests/test-centrifugo-env.sh. CENTRIFUGO_HTTP_SERVER_ADDRESS: "0.0.0.0" CENTRIFUGO_HTTP_SERVER_PORT: "8000" CENTRIFUGO_LOG_LEVEL: info CENTRIFUGO_ENGINE_TYPE: redis CENTRIFUGO_ENGINE_REDIS_ADDRESS: redis://redis:6379/3 CENTRIFUGO_CLIENT_USER_CONNECTION_LIMIT: "16" CENTRIFUGO_CLIENT_PING_INTERVAL: 25s CENTRIFUGO_CLIENT_STALE_CLOSE_DELAY: 60s CENTRIFUGO_ADMIN_ENABLED: "true" CENTRIFUGO_PROMETHEUS_ENABLED: "true" CENTRIFUGO_HEALTH_ENABLED: "true" CENTRIFUGO_CHANNEL_NAMESPACES: '[{"name":"widget","presence":false,"history_size":100,"history_ttl":"10m","force_recovery":true,"allow_subscribe_for_anonymous":false,"allow_subscribe_for_client":false,"allow_publish_for_subscriber":false,"allow_publish_for_client":false,"allow_publish_for_anonymous":false},{"name":"widgettyping","presence":false,"history_size":0,"history_ttl":"0s","force_recovery":false,"allow_subscribe_for_anonymous":false,"allow_subscribe_for_client":false,"allow_publish_for_subscriber":true,"allow_publish_for_client":false,"allow_publish_for_anonymous":false},{"name":"user","presence":true,"history_size":50,"history_ttl":"5m","force_recovery":true,"allow_subscribe_for_anonymous":false,"allow_subscribe_for_client":false,"allow_publish_for_subscriber":false,"allow_publish_for_client":false,"allow_publish_for_anonymous":false},{"name":"admin","presence":true,"history_size":50,"history_ttl":"5m","force_recovery":true,"allow_subscribe_for_anonymous":false,"allow_subscribe_for_client":false,"allow_publish_for_subscriber":false,"allow_publish_for_client":false,"allow_publish_for_anonymous":false},{"name":"system","presence":false,"history_size":10,"history_ttl":"1m","force_recovery":false,"allow_subscribe_for_anonymous":true,"allow_subscribe_for_client":true,"allow_publish_for_subscriber":false}]' CENTRIFUGO_CLIENT_ALLOWED_ORIGINS: "${REALTIME_ALLOWED_ORIGINS:-${APP_URL}}" volumes: - synaplan-secrets-realtime:/run/synaplan-secrets:ro depends_on: secrets-init: condition: service_completed_successfully redis: condition: service_healthy healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8000/health"] interval: 30s timeout: 10s retries: 3 start_period: 10s restart: unless-stopped networks: [internal] tika: image: apache/tika:3.3.3.0@sha256:326b238b37904c94ff61fed87afe0af72e6a29fa715933b10af97b7d56f1d4cb command: -h 0.0.0.0 -p 9998 healthcheck: test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/9998 && printf 'GET /version HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && read -r status <&3 && [[ $$status == *'200 OK'* ]]"] interval: 30s timeout: 10s retries: 5 start_period: 60s restart: unless-stopped networks: [internal] qdrant: image: qdrant/qdrant:v1.20.0@sha256:9bfc89f9914fc14876a88c05270685921a55b8877b254a98bcc1cc2f0a424cd2 volumes: - ./data/qdrant:/qdrant/storage healthcheck: test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/6333 && printf 'GET /readyz HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && read -r status <&3 && [[ $$status == *'200 OK'* ]]"] interval: 20s timeout: 5s retries: 10 start_period: 30s restart: unless-stopped networks: [internal] # Optional Collabora CODE — LibreOffice convert-to. Off by default so the # official 8 GB self-host floor stays valid. Enable with COMPOSE_PROFILES=office # (or `--profile office`). No published port. collabora: image: collabora/code:26.04.5.2.1@sha256:c7415b963e32eb0986a562583f154d36d3d7a461e1ddfb660a95e87094328acb profiles: [office] environment: extra_params: --o:ssl.enable=false --o:ssl.termination=false --o:num_prespawn_children=2 mem_limit: 2g # CODE 26.04 is distroless: no bash, so the /dev/tcp probe of 25.04 cannot # run here. coolwsd --probe is the upstream check (it is what the image's # own HEALTHCHECK runs) and needs the exec form — a plain string would go # through CMD-SHELL and fail for the same reason. healthcheck: test: ["CMD", "/usr/bin/coolwsd", "--probe", "--use-env-vars"] interval: 30s timeout: 10s retries: 5 start_period: 120s restart: unless-stopped networks: [internal] ollama: image: ollama/ollama:latest@sha256:b2f0122c476bc9fe7ce679d8bb4bd555351e25e2554eefb156f61cb0a89cf429 profiles: [local-ai] environment: OLLAMA_HOST: 0.0.0.0 volumes: - ./data/ollama:/root/.ollama healthcheck: test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/11434 && printf 'GET /api/tags HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && read -r status <&3 && [[ $$status == *'200 OK'* ]]"] interval: 30s timeout: 10s retries: 5 start_period: 90s restart: unless-stopped networks: [internal] whisper-models: image: alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a profiles: [local-ai] # The model name is passed as an environment variable and expanded by the # container's shell inside quotes, instead of being interpolated by Compose # into the script text. A Compose-interpolated value would become part of the # program: a name containing whitespace would split the arguments of `test` # and `curl`, and a name containing shell metacharacters would run as code. environment: WHISPER_DEFAULT_MODEL: "${WHISPER_DEFAULT_MODEL:-tiny}" command: - /bin/sh - -ec - | apk add --no-cache curl ca-certificates mkdir -p /models model="ggml-$$WHISPER_DEFAULT_MODEL.bin" test -s "/models/$$model" || curl -fL --retry 3 --max-time 900 \ -o "/models/$$model" \ "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/$$model" chmod 0644 /models/*.bin volumes: - ./data/whisper:/models restart: "no" networks: [internal] # Spoken answers. Published image, no host port. Stop the container to hide TTS. tts: image: ghcr.io/metadist/synaplan-tts:latest@sha256:4efc4535b90bf348b6c5598e3793ca5aa7b5bdb4c051456f74a1d2b0b1328388 restart: unless-stopped environment: VOICES_DIR: /voices EXTRA_VOICES_DIR: /voices-extra DEFAULT_VOICE: en_US-lessac-medium networks: [internal] # File work (T1). Off by default — needs the host Docker socket. # Enable with COMPOSE_PROFILES=compute. prepare.sh writes COMPUTE_TOKEN. compute-python: image: synaplan-compute-python:local pull_policy: build profiles: [compute] build: context: ../sidecars/synaplan-compute/images/python command: ["python", "--version"] network_mode: none restart: "no" compute-node: image: synaplan-compute-node:local pull_policy: build profiles: [compute] build: context: ../sidecars/synaplan-compute/images/node command: ["node", "--version"] network_mode: none restart: "no" compute: pull_policy: build profiles: [compute] build: context: ../sidecars/synaplan-compute user: "0:0" restart: unless-stopped depends_on: compute-python: condition: service_completed_successfully compute-node: condition: service_completed_successfully environment: COMPUTE_AUTH_TOKEN: "${COMPUTE_TOKEN:-}" COMPUTE_LISTEN: :8080 COMPUTE_EGRESS_ENABLED: "${COMPUTE_EGRESS_ENABLED:-false}" COMPUTE_SCRATCH_DIR: /scratch COMPUTE_WORKSPACES_DIR: /workspaces COMPUTE_MAX_CONCURRENT: "${COMPUTE_MAX_CONCURRENT:-2}" COMPUTE_ALLOW_LOCAL_IMAGES: "1" COMPUTE_IMAGE_PYTHON: synaplan-compute-python:local COMPUTE_IMAGE_NODE: synaplan-compute-node:local volumes: - /var/run/docker.sock:/var/run/docker.sock - ./data/compute/scratch:/scratch - ./data/compute/workspaces:/workspaces networks: [internal] volumes: synaplan-secrets-app: synaplan-secrets-db: synaplan-secrets-realtime: networks: internal: driver: bridge