Deploying Simmetric Chat with Docker: A Complete Guide
The full deployment map for Simmetric Chat - a six-service Compose stack, a single all-in-one container for air-gapped hosts, and a one-click Coolify path.
One product, three deployment shapes
Simmetric Chat is a multi-service platform - an API server, a document-processing collector, an embeddable widget service, a frontend, and a PostgreSQL database - so the deployment story is about picking the right packaging for your environment, not about hand-wiring services. Three production paths exist, and all of the artifacts live in the repository's docker/ directory:
- Multi-container Compose - six services, each in its own container with healthchecks. The recommended production layout, with per-service isolation and independent restarts.
- Single-container all-in-one - one image that runs everything: Postgres, server, collector, and widget supervised by
supervisord. Built for air-gapped hosts and demo machines where moving multiple images across a perimeter is the expensive part. - Coolify - a self-hosted PaaS path with Traefik TLS termination, where the platform is cloned and built for you from a Compose file tuned for Coolify's conventions.
All images are multi-stage builds on Node 24 bases, and every service exposes a healthcheck the stack's startup ordering depends on. This article walks each path, the configuration model, and the details that bite in production.
The six-service Compose stack
The production Compose file defines six active services:
- frontend - built as a React 19 app by a Node builder, then served by nginx, which terminates TLS with self-signed certificates generated at image build, serves the SPA, and proxies
/apiand/widgetto the backend services. - server - the Express 5 API: authentication, RBAC, chat orchestration, and the ReAct agent. This is the service your users talk to.
- collector - the document pipeline: parsing, chunking, and embedding for uploads. Built on a slim Node base rather than Alpine, because its native dependencies (LanceDB, the local embedding transformers, canvas bindings) need glibc.
- widget - the embeddable chat-widget service for external-facing sites.
- postgres - PostgreSQL 16 with the pgvector extension bundled, so the default vector provider needs no separate vector database service.
- redis - the optional scaling cache, enabled with append-only persistence and an LRU eviction policy. Every consumer of Redis degrades gracefully in-memory, so this service is safe to leave in or take out.
Two services are deliberately disabled in the stock file rather than absent: ollama and qdrant. Their service blocks ship commented out, and enabling them is a one-line uncomment plus a variable - OLLAMA_BASE_URL=http://ollama:11434 for the local model host, or VECTOR_DB_PROVIDER=qdrant if you want a remote vector store instead of the default file-based LanceDB. The commented-out defaults keep a minimal install minimal.
Startup ordering is healthcheck-gated: wget-based probes hit GET /api/health on the server and collector, /health on the widget, pg_isready on Postgres, and redis-cli ping on Redis, and the frontend waits for the server to report healthy before it starts.
Bringing the stack up is a single command:
docker compose -f docker/docker-compose.yml up --build -dThe all-in-one single container
The second path compresses the whole platform into one image. A single Dockerfile builds PostgreSQL 16 with the pgvector extension compiled from source at image build, and supervisord runs the database, the server, the collector, and the widget together inside the container. Ports 3000, 3210, and 3211 are exposed, and the healthcheck probes the server's /api/health on localhost.
docker build -f docker/Dockerfile -t simmetric-chat-all-in-one .
docker run -d -p 3000:3000 -p 3210:3210 -p 3211:3211 simmetric-chat-all-in-oneThe all-in-one image is the air-gap story: one artifact to carry across a perimeter, no registry pull at install time, and everything from the database to the chat UI booting from a single docker run. The pgvector sources are compiled and installed offline during the build precisely so the resulting image has no install-time network dependency. Its environment defaults are scaffolding placeholders by design - an operator overrides the real secrets via .env or -e, and the image never pretends to be production-safe out of the box.
One boundary worth knowing: the enterprise plugin is never baked into any image. That is deliberate - it keeps the AGPL community images free of proprietary code and lets air-gapped customers receive the plugin as a separate, auditable tarball that is bind-mounted at runtime.
The Coolify path
Coolify turns the stack into a PaaS deployment: it clones the repository, builds the images, wires internal networking, and puts Traefik in front for TLS termination. A dedicated Compose file automates what stock Compose leaves to the operator - image builds, internal networking, volumes, healthchecks, and the enterprise plugin mount.
It also enforces required secrets at deploy time with :? syntax: JWT_SECRET, COLLECTOR_SECRET, WIDGET_API_KEY, POSTGRES_PASSWORD, APP_URL, and ALLOWED_ORIGINS. Coolify refuses to deploy when any of them is missing, which converts a class of half-configured deployments into a visible error before anything starts.
Differences from the stock stack follow from Traefik's presence: app services publish no host ports (assign your domain to the frontend service, port 80), the nginx config is HTTP-only because Traefik terminates TLS, and the Docker socket is not mounted by default - it is only needed for the Ollama Cloud login flow. A step-by-step runbook covers the manual steps: rsyncing the enterprise tree, filling secrets, and pasting the license key.
Configuration: one .env, mounted by Compose
Containers receive configuration through Compose's env_file, pointing at the repository-root .env - the same single runtime config the platform uses everywhere else. The stack boots with only the compose-level defaults if the file is absent, which is fine for a first smoke test and not for production.
Two secrets are strictly required, and the server exits without them:
# root .env - required in production
JWT_SECRET=... # openssl rand -hex 32
COLLECTOR_SECRET=... # openssl rand -hex 32ENCRYPTION_KEY and API_KEY_HMAC_SECRET do not need to be pre-provisioned at all: on production boots, the server entrypoint generates them once, persists them to the server-storage volume, restores them on every restart, and prefers an operator-supplied value when one exists. Provisioning never regenerates over existing key material and fails loudly before any database step, so a half-written volume cannot silently become a different key.
There is one Compose-specific trap worth naming because it silently downgrades deployments: do not add passthrough entries like LICENSE_KEY=${LICENSE_KEY:-} to the production services. Shell interpolation resolves an unset variable to the empty string, and an explicit empty environment entry overrides the value arriving via env_file - so an interpolated empty LICENSE_KEY turns an Enterprise deployment into Community, and interpolated empty ENCRYPTION_KEY overrides the entrypoint's auto-provisioning. Leave those keys to env_file alone.
The details that matter in production
- Collector needs glibc. The collector runs on a slim (Debian) base, not Alpine, because LanceDB, the local embedding transformers, and canvas all require glibc. Do not "simplify" it to Alpine - the build fails.
- Database URLs have three shapes. Inside the Compose network the server talks to
postgres:5432(hardcoded in the compose environment); the root.envdefault targetshost.docker.internalfor host-side servers; the all-in-one image useslocalhostbecause Postgres runs in the same container. - Migrations are additive-only. The server entrypoint runs
prisma migrate deployon every boot, so rolling images forward is safe by policy; rolling back means redeploying a known-good image tag, and code rollbacks do not revert applied migrations. - Scaling waits for provisioning. Secret auto-provisioning must complete once before the server scales beyond one replica - concurrent first-boot replicas could generate divergent keys. Single-instance is the documented default; multi-instance scaling has its own guide.
- Health endpoints are external.
/api/healthreturns a status object with per-dependency checks and is suitable for external uptime probes and load-balancer checks; structured logs go to stdout fordocker compose logs -f <service>.
Released images are published to GHCR (server, frontend, collector, widget, and all-in-one, tagged latest plus the version), so a deployment can pin a version instead of building locally - though docker compose up --build against the repository remains the fully self-hosted path.
From clone to running platform
The shortest path, end to end:
- Clone the repository and copy
.env.exampleto.env. - Set
JWT_SECRETandCOLLECTOR_SECRET(the entrypoint provisions the encryption keys itself). docker compose -f docker/docker-compose.yml up --build -d.- Watch
docker compose logs -f serveruntil migrations and seeding complete, then open the frontend and log in.
For air-gapped hosts, substitute the all-in-one image and the tarball-transfer flow. For Coolify, import the repository and fill the required secrets - the platform deploys the rest.

