# Synaplan quickstart: the published image from this one file. # # docker compose up -d then open http://127.0.0.1:8000 # # The first visit opens /setup, which creates the first administrator and asks # for an AI provider key. A .env next to this file is optional (see # .env.example beside it); a Docker GUI can take this file alone. All data and # the generated secrets live in named volumes: back up the whole project. # Local AI, office documents, file work, local-network TLS and scripted backups # use deploy/compose.yaml instead. x-app-image: &app-image ghcr.io/metadist/synaplan:${SYNAPLAN_VERSION:-5.6.4} x-app-command: &app-command - | set -a . /run/synaplan-secrets/secrets.env set +a export DB_PASSWORD="$$MARIADB_PASSWORD" exec /usr/local/bin/docker-entrypoint.sh x-app-environment: &app-environment APP_ENV: prod APP_DEBUG: "0" 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} SYNAPLAN_PLATFORM: selfhost APP_VERSION: ${SYNAPLAN_VERSION:-5.6.4} APP_URL: ${SYNAPLAN_URL:-http://127.0.0.1:${SYNAPLAN_HTTP_PORT:-8000}} FRONTEND_URL: ${SYNAPLAN_URL:-http://127.0.0.1:${SYNAPLAN_HTTP_PORT:-8000}} # Space-separated. Without SYNAPLAN_URL both local spellings keep live chat # connected, since people type either one. REALTIME_ALLOWED_ORIGINS: ${SYNAPLAN_URL:-http://127.0.0.1:${SYNAPLAN_HTTP_PORT:-8000} http://localhost:${SYNAPLAN_HTTP_PORT:-8000}} BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-} GUEST_CHAT_ENABLED: ${GUEST_CHAT_ENABLED:-} FEATURE_FREEAPI_ENABLED: ${FEATURE_FREEAPI_ENABLED:-true} FREEAPI_BASE_URL: ${FREEAPI_BASE_URL:-https://web.synaplan.com} AI_DEFAULT_PROVIDER: ${AI_DEFAULT_PROVIDER:-groq} GROQ_API_KEY: ${GROQ_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} GOOGLE_GEMINI_API_KEY: ${GOOGLE_GEMINI_API_KEY:-} MAILER_DSN: ${MAILER_DSN:-null://null} APP_SENDER_EMAIL: ${APP_SENDER_EMAIL:-} APP_SENDER_NAME: Synaplan DB_HOST: db DB_PORT: "3306" DB_NAME: synaplan DB_USER: synaplan 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_PUBLIC_WS_URL: "" QDRANT_URL: http://qdrant:6333 TIKA_BASE_URL: http://tika:9998 SYNAPLAN_TTS_URL: http://tts:10200 OLLAMA_BASE_URL: "" AUTO_DOWNLOAD_MODELS: "false" WHISPER_ENABLED: "false" FFMPEG_BINARY: /usr/bin/ffmpeg LOG_FORMAT: json services: # Generates the eight deployment secrets once, with the same script as # deploy/compose.yaml. Each service then gets only the ones it uses. An # existing set is never rewritten. It exits when done, so Dockge lists the # stack as "exited" while everything else runs. 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:-} SECRETS_INIT_DB_DIR: /db SECRETS_INIT_FILE_LABEL: the secrets volume (secrets.env) volumes: - secrets:/data - secrets-app:/out/app - secrets-db:/out/db - secrets-realtime:/out/realtime - db:/db:ro 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 backend: image: *app-image entrypoint: ["/bin/bash", "-ec"] command: *app-command environment: <<: *app-environment SYNAPLAN_ROLE: web volumes: - uploads:/var/www/backend/var/uploads - secrets-app:/run/synaplan-secrets:ro 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 worker: image: *app-image entrypoint: ["/bin/bash", "-ec"] command: *app-command environment: <<: *app-environment SYNAPLAN_ROLE: worker volumes: - uploads:/var/www/backend/var/uploads - secrets-app:/run/synaplan-secrets:ro 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 scheduler: image: *app-image entrypoint: ["/bin/bash", "-ec"] command: *app-command environment: <<: *app-environment SYNAPLAN_ROLE: scheduler volumes: - uploads:/var/www/backend/var/uploads - secrets-app:/run/synaplan-secrets:ro 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 db: image: mariadb:13.0.2@sha256:f4054da26e3a9dd6c8b5cc97363e544408b15c0fb369d7359c25dc04826eb6e1 entrypoint: ["/bin/sh", "-ec"] command: - | set -a . /run/synaplan-secrets/secrets.env set +a exec docker-entrypoint.sh --max-allowed-packet=256M --innodb-buffer-pool-size=512M environment: MARIADB_DATABASE: synaplan MARIADB_USER: synaplan volumes: - db:/var/lib/mysql - secrets-db:/run/synaplan-secrets:ro depends_on: secrets-init: condition: service_completed_successfully healthcheck: 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 redis: image: redis:7.4-alpine@sha256:8da19c7399dca900c6822a1cf30f9d2285585e2c4b457ff4095b0f4500054d3f command: redis-server --appendonly yes --appendfsync everysec --maxmemory 256mb --maxmemory-policy allkeys-lru volumes: - redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 15s timeout: 5s retries: 5 start_period: 5s restart: unless-stopped 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: 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: ${SYNAPLAN_URL:-http://127.0.0.1:${SYNAPLAN_HTTP_PORT:-8000} http://localhost:${SYNAPLAN_HTTP_PORT:-8000}} volumes: - 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 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 qdrant: image: qdrant/qdrant:v1.20.0@sha256:9bfc89f9914fc14876a88c05270685921a55b8877b254a98bcc1cc2f0a424cd2 volumes: - 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 tts: image: ghcr.io/metadist/synaplan-tts:latest@sha256:4efc4535b90bf348b6c5598e3793ca5aa7b5bdb4c051456f74a1d2b0b1328388 environment: VOICES_DIR: /voices EXTRA_VOICES_DIR: /voices-extra DEFAULT_VOICE: en_US-lessac-medium restart: unless-stopped volumes: db: redis: qdrant: uploads: secrets: secrets-app: secrets-db: secrets-realtime: