Deployment
Polyant is designed to run on any container platform. This document gives concrete recipes for the most common targets.
Architecture recap
You need to deploy three components:
| Component | Default port | Image |
|---|---|---|
| Engine (NestJS) | 4000 | built from packages/engine/Dockerfile |
| Web (Next.js) | 3000 | built from packages/web/Dockerfile |
| PostgreSQL 16 + pgvector | 5432 | pgvector/pgvector:pg16 (managed DB preferred in production) |
Both Dockerfiles expect the build context to be the monorepo root, not the package directory:
docker build -f packages/engine/Dockerfile -t polyant-engine .
docker build -f packages/web/Dockerfile -t polyant-web .Required environment variables
Only the production-critical vars are listed here. For the complete inventory (defaults, tunables, debug flags), see Environment Variables Reference.
Engine
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL | yes | Full PostgreSQL connection string with pgvector extension |
ENCRYPTION_KEY | yes | 32-byte hex key (openssl rand -hex 32). Lost key = unrecoverable instance secrets |
AUTH_SECRET | yes | 32+ char random string. Must match the web package |
AUTH_INTERNAL_SECRET | yes (for credentials login) | Shared secret used to authenticate the web → engine credentials bridge. Must match the same env var on the web package. |
CORS_ORIGINS | yes in prod | Comma-separated allowlist of web origins (e.g. https://app.example.com). When unset under NODE_ENV=production, the engine rejects all cross-origin requests. A credentialed wildcard (*) is refused at startup — list explicit origins. |
TRUST_PROXY | yes behind a proxy | Number of trusted reverse-proxy hops (e.g. 1 behind Render/Railway/nginx, 2 behind two layers). Defaults to 0 (trust nothing). When the engine sits behind a proxy and this is left at 0, the Twilio webhook signature check breaks because X-Forwarded-Host/-Proto are ignored. |
BASE_URL | recommended | Public base URL of the engine — used to build webhook callback URLs. |
INITIAL_ADMIN_EMAIL | optional | First-boot Superadmin email. Defaults to administrator@local. |
INITIAL_ADMIN_PASSWORD | optional | First-boot Superadmin password. If unset, a random password is generated and printed once to the engine logs at first boot. |
PLATFORM_S3_BUCKET, PLATFORM_S3_REGION, PLATFORM_S3_ACCESS_KEY_ID, PLATFORM_S3_SECRET_ACCESS_KEY | optional | Enables attachment persistence (WhatsApp / Telegram media). Without these, attachments are not durable across restarts. |
NODE_ENV | recommended | Set to production. Activates the strict CORS default above and disables verbose error responses. |
Tunables — review before going live
These have sensible defaults but you almost certainly want to revisit them when traffic shape becomes clear. See env-vars.md for each variable’s contract.
| Variable | Default | Why you might tune it |
|---|---|---|
MESSAGE_SOFT_DEBOUNCE_MS | 2000 | Inbound message coordinator — coalescence window for WhatsApp/Telegram burst fragments before the pipeline fires. Lower it on a chatty bot, raise it for users who think in long bullets. |
MESSAGE_TYPING_DELAY_MS | 1500 | Delay before the typing indicator is shown. Set to a value ≤ the soft-debounce so the user gets feedback during the wait. |
MESSAGE_MAX_RESTARTS | 3 | Cap on consecutive cancel-and-restart cycles when fragments keep arriving mid-pipeline. |
AGENT_CALL_TIMEOUT_MS | 60000 | Max wall-clock duration of a single sub-agent invocation (virtual agent channel). |
SSE_MAX_CONNECTIONS / SSE_MAX_CONNECTIONS_PER_USER | 50 / 5 | Activity-stream concurrency caps. Bump if you have many admin panel tabs open in parallel. |
ANALYTICS_RETENTION_DAYS | 90 | Daily cleanup of ai_logs and pipeline_traces. Lower to save storage, raise for longer dashboard windows. |
KNOWLEDGE_MAX_DOCS_PER_INSTANCE | 500 | Hard cap on knowledge documents per instance. Uploads beyond this return HTTP 400. |
DEDUP_SIMILARITY_THRESHOLD | 0.90 | Cosine threshold for memory de-duplication (extraction layer). |
DATETIME_TIMEZONE / DATETIME_LOCALE | UTC / en-US | Default timezone and locale for the supervisor’s {{datetime}} template substitution. |
Web
| Variable | Required | Notes |
|---|---|---|
AUTH_SECRET | yes | Must match engine |
AUTH_INTERNAL_SECRET | yes (for credentials login) | Must match engine. Without it the Credentials provider is disabled and email/password sign-in stops working. |
AUTH_TRUST_HOST | yes behind a proxy | Set to true behind a reverse proxy. Without it Auth.js rejects the request host as untrusted and login fails. |
DATABASE_URL | yes | Auth.js adapter writes sessions here (same database as the engine) |
NEXT_PUBLIC_API_URL | yes | Public URL of the engine (browser-side; embedded at build time) |
INTERNAL_ENGINE_URL | recommended | Server-side URL the web’s Credentials provider uses to call POST /api/auth/credentials/verify on the engine. In production set this to the engine’s internal/cluster address — never via the public proxy. Defaults to http://localhost:4000 (development only). |
GOOGLE_CLIENT_ID | optional | OAuth credentials. Without both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET the Google sign-in button is hidden and the provider is disabled. |
GOOGLE_CLIENT_SECRET | optional | See above. |
AUTH_ALLOWED_DOMAINS | optional | Restrict Google sign-in to specific email domains (comma-separated). |
Behind a reverse proxy — checklist
Run-of-the-mill production setups put both services behind nginx / Caddy / a managed platform’s TLS terminator. Two settings are easy to forget:
- Web:
AUTH_TRUST_HOST=true— Auth.js otherwise refuses requests whose host doesn’t match a locked-down list. - Engine:
TRUST_PROXY=<hops>— the default0meansX-Forwarded-Host/X-Forwarded-Protoare ignored. The Twilio webhook signature is computed against the request URL, so if those headers are dropped the HMAC check fails and inbound WhatsApp/SMS stops working with a403.
If neither service is behind a proxy (single-host deployment with the engine directly exposed), leave both at their defaults.
Docker Compose (single host)
For small deployments, Docker Compose on a single VM works well.
services:
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_DB: polyant
POSTGRES_USER: polyant
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
engine:
build:
context: .
dockerfile: packages/engine/Dockerfile
restart: unless-stopped
depends_on: [postgres]
environment:
NODE_ENV: production
DATABASE_URL: postgresql://polyant:${POSTGRES_PASSWORD}@postgres:5432/polyant
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
AUTH_SECRET: ${AUTH_SECRET}
AUTH_INTERNAL_SECRET: ${AUTH_INTERNAL_SECRET}
CORS_ORIGINS: https://your-web.example.com
TRUST_PROXY: "1"
BASE_URL: https://your-engine.example.com
ports: ["4000:4000"]
web:
build:
context: .
dockerfile: packages/web/Dockerfile
args:
NEXT_PUBLIC_API_URL: https://your-engine.example.com
AUTH_SECRET: ${AUTH_SECRET}
restart: unless-stopped
depends_on: [engine]
environment:
NODE_ENV: production
DATABASE_URL: postgresql://polyant:${POSTGRES_PASSWORD}@postgres:5432/polyant
AUTH_SECRET: ${AUTH_SECRET}
AUTH_INTERNAL_SECRET: ${AUTH_INTERNAL_SECRET}
AUTH_TRUST_HOST: "true"
INTERNAL_ENGINE_URL: http://engine:4000
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
ports: ["3000:3000"]
volumes:
pgdata:Put an HTTPS reverse proxy (Caddy, nginx, Traefik) in front.
Render
Polyant works well on Render with two web services plus a managed PostgreSQL.
- PostgreSQL: create a Render PostgreSQL instance with pgvector enabled (via the
CREATE EXTENSIONquery in the shell). - Engine service:
- Environment: Docker
- Dockerfile path:
packages/engine/Dockerfile - Docker build context:
.(repo root) - Health check:
/health - Plan: Starter+ (memory-intensive due to tools)
- Web service:
- Environment: Docker
- Dockerfile path:
packages/web/Dockerfile - Docker build context:
. - Build args:
NEXT_PUBLIC_API_URL,AUTH_SECRET
- Add the environment variables listed above to each service.
- Migrations run automatically at engine startup (
packages/engine/scripts/docker-start.shinvokesdb:migratebefore the server).
Fly.io
fly launch --no-deploy # in repo rootEdit the generated fly.toml:
primary_regionclose to your users- Set secrets:
fly secrets set ENCRYPTION_KEY=... AUTH_SECRET=... - Use Fly PostgreSQL:
fly pg create --name polyant-dband attach it.
Deploy the engine and web as two separate Fly apps (recommended), sharing the same PostgreSQL cluster.
Kubernetes
The Dockerfiles are stateless runners — any Kubernetes platform works. Key points:
- Persistent volume: not required for engine or web. Only PostgreSQL needs persistence.
- Ingress: terminate HTTPS here and set
AUTH_TRUST_HOST=trueon the web deployment. - Secrets: use Kubernetes Secrets or an external secret manager for
ENCRYPTION_KEYandAUTH_SECRET. - Migrations: run as a Kubernetes Job (
node dist/database/migrate.js) before rolling out a new engine version, or let the engine run them at startup. - Scaling: the engine is horizontally scalable for HTTP traffic. However, channel adapters (Telegram long-poll, Slack Socket Mode) are stateful — run exactly one engine replica with channels enabled, or add distributed coordination.
Migration strategy
Polyant uses Drizzle migrations stored in packages/engine/src/database/migrations/.
- Migrations are SQL files applied in order.
- They are executed automatically at engine startup via
packages/engine/scripts/docker-start.sh. - To run manually:
npm run db:migrate. - Rolling back: create a new forward-migration that undoes the change. Drizzle migrations are forward-only.
Observability
- Health check:
GET /healthreturns{status: "ok"}. - Pipeline tracing:
pipeline_tracestable — per-request phase timings (context prep, tool building, LLM call, total). - AI call logs:
ai_logstable — per-LLM-call token counts and USD cost. - LangSmith: enable per-instance in the Settings tab for distributed tracing.
- Application logs: structured JSON on stdout — ship to your log aggregator.
Backups
Back up the PostgreSQL database. It contains:
- All conversations and messages
- All memories (embeddings in pgvector)
- All encrypted instance secrets and channel configs (decryptable only with your
ENCRYPTION_KEY) - All skills, prompts, tools
Back up your ENCRYPTION_KEY separately. Losing it means the encrypted data in the DB becomes unreadable.