Back to docs
Guide 09

Deployment

Simmetric Chat is air-gap capable and local-first. Five deployment targets: multi-container Docker Compose (recommended for production), a single all-in-one container (air-gapped environments), a Coolify-managed stack, dev overrides (hot-reload on Docker…

5 min read

Simmetric Chat is air-gap capable and local-first. Five deployment targets: multi-container Docker Compose (recommended for production), a single all-in-one container (air-gapped environments), a Coolify-managed stack, dev overrides (hot-reload on Docker), and the Tauri desktop app. All deployment artifacts live in docker/; the desktop shell in src-tauri/. Images published to GHCR are amd64-only (linux/amd64 - QEMU-emulated arm64 builds hung; on arm64, self-build locally on native hardware). Every container uses wget healthchecks (--spider): server GET /api/health, collector GET /api/health on 3210, widget GET /health on 3211, Postgres pg_isready, Redis redis-cli ping.

Prerequisites

  • Node.js ≥ 24, pnpm 12.8.1 (pinned), PostgreSQL 16.
  • For local embeddings: ~2 GB RAM for all-MiniLM-L6-v2 (Xenova, CPU).
  • For local LLMs: Ollama with pre-pulled model(s) (gemma4:latest default).
  • For vision OCR: Ollama with a vision model (glm-ocr:latest default).

Docker Compose (production)

The production compose (docker/docker-compose.yml) defines 7 active services: frontend (nginx, :80/:443), server (:3000), collector (:3210), widget (:3211), postgres (16, image pgvector/pgvector:pg16), redis (7-alpine, optional scaling cache), and `ollama` on by default (owner decision 2026-09-08 - the provider «fetch failed» error path and the Ollama Cloud login require a REAL ollama container on the compose network; its own ollama-data model volume, models pulled via the app UI or docker exec). The `llmster` service (LM Studio, lmstudio/llmster-preview:cpu) exists but is profile-gated (profiles: [lmstudio]). The qdrant service no longer exists in the compose (vector = LanceDB by default; VECTOR_DB_PROVIDER=qdrant remains a code-level remote provider).

bash
cp .env.example .env
# Edit .env: JWT_SECRET, COLLECTOR_SECRET, WIDGET_API_KEY, DATABASE_URL, LICENSE_KEY (if Enterprise)
docker compose -f docker/docker-compose.yml up -d

Critical production env (see 03 - Configuration):

  • JWT_SECRET - required, cryptographic material (derives ENCRYPTION_KEY when unset).
  • COLLECTOR_SECRET - required, must match between server and collector.
  • WIDGET_API_KEY - required for the widget, must match between widget and server.
  • DATABASE_URL - PostgreSQL 16.
  • LICENSE_KEY - Enterprise only (JWT RS256; the public key is embedded in the server source; the no-.env alternative = the admin-UI paste / PUT /api/license/instance).
  • ENCRYPTION_KEY (base64 32 bytes) - recommended for data-at-rest persistence beyond JWT_SECRET rotations.
Key rotation: set LEGACY_PREVIOUS_ENCRYPTION_KEYS (CSV of base64 32-byte keys)
to keep old backups/data decryptable during rotation. There is no automatic
re-encryption job: manual decrypt-old → re-encrypt-new procedure.

Key auto-provisioning (server entrypoint)

The production entrypoint (docker/entrypoint-server.sh) runs on every boot: provisioning of ENCRYPTION_KEY and API_KEY_HMAC_SECRET with precedence operator-supplied value > value persisted in the server-storage volume > generated once and persisted (never regenerated over existing material); then prisma migrate deploy + seed and the server start. Dev configurations (docker-compose.dev.yml) skip provisioning and keep the scrypt fallback.

Volumes & persistence

  • PostgreSQL: dedicated volume (pgdata).
  • LanceDB: ${STORAGE_PATH}/vectors/lancedb (collector).
  • File storage: ${STORAGE_PATH} (collector, documents/avatars/uploads).
  • Backups: destinations configured per job (disk/S3/Dropbox/...).

Post-deploy health check

Containers self-check via wget (see the intro). Equivalent manual checks:

bash
curl http://localhost:3000/api/health
# { "status": "ok", "checks": { "database": "ok", "collector": "ok", "disk": "ok" } }
curl http://localhost:3210/api/health   # collector
curl http://localhost:3211/health        # widget

Single-container all-in-one

docker/Dockerfile builds a single image supervised by supervisord running PostgreSQL 16 (pgvector compiled offline for air-gap), the server, the collector, and the widget. It exposes 3000/3210/3211, uses a localhost DATABASE_URL, and healthchecks /api/health. Built for air-gapped/offline/demo environments: the default env values are placeholders - override them via .env or -e.

Coolify

docker/docker-compose.coolify.yml + docker/nginx.coolify.conf automate image builds, internal networking, volumes, healthchecks, and the enterprise plugin mount (absolute host path ${ENTERPRISE_PLUGIN_PATH:-/opt/simmetric-enterprise}). Required secrets use the :? syntax (Coolify refuses to deploy when missing): JWT_SECRET, COLLECTOR_SECRET, WIDGET_API_KEY, POSTGRES_PASSWORD, APP_URL, ALLOWED_ORIGINS. Differences from the base compose: no container_name, no host ports on app services (ingress owned by Traefik, HTTP-only - Traefik terminates TLS), and ENCRYPTION_KEY/API_KEY_HMAC_SECRET deliberately not interpolated (auto-provisioned into the volume).

Local development

bash
pnpm install
pnpm db:generate      # Prisma client (+ Prisma/pnpm symlink fix)
pnpm --filter server db:migrate   # apply migrations
pnpm dev              # turborepo: server + collector + widget + frontend in parallel

Details in 10 - Development.

Dev overrides (Docker)

docker/docker-compose.dev.yml mounts sources on top of the production compose for hot-reload (NODE_ENV=development). docker-compose.infra.yml starts only Postgres/Qdrant with the app running on the host.

Desktop (Tauri)

src-tauri/ - desktop app wrapping the frontend. Build with pnpm tauri build. Configure src-tauri/tauri.conf.json for a remote server URL or a local bundle.

Reverse proxy (Nginx)

Key points:

  • SSE: disable buffering (proxy_buffering off;, X-Accel-Buffering: no already sent by the server/widget).
  • Timeouts: slow local LLMs → high proxy_read_timeout (≥ 600s, the agent watchdog default is 10 min).
  • Uploads: client_max_body_size ≥ 100 MB (multer document limit).
  • CORS: the allowlist is app-managed (ALLOWED_ORIGINS); the widget has dynamic per-origin CORS (allowedOrigins).

Production backups

  1. Create a destination (POST /api/backup-destinations).
  2. Create a job (POST /api/backup-jobs) with cron frequency/time + retentionDays.
  3. Verify with POST /backup-destinations/:id/test and a manual run POST /backup-jobs/:id/run.
  4. Restore: POST /api/backups/restore/:logId (dry-run recommended first: /restore/:logId/dry-run). Atomicity via psql --single-transaction.

See 05 - Feature Guide § Backup.

Production security

  • Change the bootstrap admin password; set ALLOW_REGISTRATION=false in prod (self-registration closed).
  • DLP_ENABLED defaults to "true" in v0.22 - input/output chat redaction over 10 built-in patterns (see 05 - Feature Guide); use DLP_BYPASS_ROLES to exempt selected roles.
  • Enable audit_log_immutable (Enterprise) for append-only audit.
  • HTTPS terminated at the reverse proxy; helmet headers active on the server (relaxed on the widget for embedding).
  • Rate limits are pre-configured (see 04 - API Reference § Rate limit): the chat limit is now enforced by the agent's budget tracker (per-user concurrency, wallclock, tokens) plus the global 200 req/min safety net.

v0.28.1 Provenance

StatementSource
7 active services + ollama on by default + the profile-gated llmster + qdrant gonedocker/docker-compose.yml (row-time probe 35-02)
pnpm 12.8.1docs/DEVELOPMENT.md (Prerequisites, row-time)

Operational reference - Simmetric Chat v0.28.1 (docs updated 2026-10-02, census SHA `aca5b8ff').