Deployment
On this page
Polyant is designed to run on any container platform. This document gives concrete recipes for the most common targets.
To run Polyant without containers on a single Linux server, see Run Polyant with systemd.
Architecture recap
You need to deploy three components:
| Component | Default port | Image |
|---|---|---|
| Engine (NestJS) | 4000 | built from Dockerfile.engine |
| Web (Next.js) | 3000 | built from Dockerfile.web |
| 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 Dockerfile.engine -t polyant-engine .
docker build -f Dockerfile.web -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 | yes for webhooks | Public HTTPS origin of the engine, used to build every webhook URL, OAuth redirect and agent card. It is the value a fresh installation boots with: once a platform admin stores a public address in Installation policies, the stored address wins, so a wrong address is corrected without a redeploy. Telegram, Slack, WhatsApp, email and HubSpot all deliver to /webhooks/* on this address, so it must be reachable from the internet. |
INITIAL_ADMIN_EMAIL | optional | First-boot platform-admin email. Defaults to administrator@local. |
INITIAL_ADMIN_PASSWORD | required to seed an admin | First-boot platform-admin password. If unset, no admin is seeded at all - the engine logs that it skipped the seed rather than generating a password into its own log file. |
AUTH_ALB_PLATFORM_ADMIN_GROUP (managed services; inert, because gateway mode is refused at boot) | gateway mode only | The identity-provider group whose members get the platform_admin role. The former name AUTH_ALB_SUPERADMIN_GROUP is still read as a fallback and warns at boot - rename it. |
NODE_ENV | recommended | Set to production. Activates the strict CORS default above and disables verbose error responses. |
Where the third-party values come from
One of the web entries below is a credential somebody else issues, and it is not a Polyant value:
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET(managed services) - issued in the Google Cloud console, with a redirect URI that has to match exactly. The steps are in Authentication.
Attachment storage
Attachment persistence is per agent, not per installation: each agent writes
to its own bucket, configured as that agent’s secrets.
There is no deployment-wide bucket, and no environment variable to set - the four
PLATFORM_S3_* variables that used to be here are gone. The panel collects the
four values in the agent’s Advanced section, in the card that holds the
Store attachments switch: the bucket, region and access keys appear once the
switch is on.
| Secret | Required | What it is |
|---|---|---|
s3_bucket_name | yes | The bucket this agent writes to. With managed services it can come from the organization’s shared credentials, so several agents can write to one bucket, each under its own attachments/<agent slug>/ prefix. |
aws_region | yes | The bucket’s own region. A mismatch is a redirect the SDK reports as a permanent failure. There is no fallback to the deployment’s AWS_REGION. With managed services the organization can share it together with the bucket name. |
aws_access_key_id + aws_secret_access_key | yes | Static credentials, set on the agent. The principal needs s3:PutObject and s3:GetObject on arn:aws:s3:::<bucket>/*, and s3:DeleteObject on the attachments/ prefix for the cleanup described below. |
s3_endpointis gone. It was handed straight to the S3 client so an agent could write to MinIO or Cloudflare R2 - which made an agent secret into a request-forgery primitive: whoever could write an agent’s secrets could point every upload and download, file contents included, at any host, the deployment’s private network included. The key is ignored and the stored values were deleted on upgrade, rather than left inert to read as configuration still in force. Bringing S3-compatible storage back means a deployment-tier allow-list of endpoints, not this key.
The bucket is reached only with the agent’s static keys, never with the
deployment’s own AWS identity (the ECS task role): the bucket name is a value the
tenant writes, so that identity could otherwise be aimed at any bucket it can
reach. When both keys are set they are used; when only one is, storage refuses
with an error naming the missing half; when neither is, nothing is stored. An
agent that still carries the retired s3_use_task_role secret and no static
keys stores nothing, and each upload logs that the setting is no longer
supported and what to set instead.
Keeping the files users send is a per-agent choice: Store attachments, in the
agent’s Advanced section, off for every agent until someone turns it on. With
it on, an agent with storage configured stores every inbound attachment under
attachments/<agent slug>/ in its bucket, so an IAM policy limited to another
prefix must allow that one. A failed upload does not fail the turn: the message
and the reply are saved, and the attachment simply has no stored copy. Every call
to the bucket gives up after 5 seconds to connect or 60 seconds in all.
Two things worth knowing before relying on any of this. Storage never gated the
agent’s own sight of an attachment - the bytes reach the model inline - so the
switch decides only whether a file can be reopened afterwards. And the stored
files follow their conversations: deleting a conversation or an agent, and with
managed services a retention purge, deletes the files recorded on its messages.
That needs s3:DeleteObject on the attachments/ prefix; a refused delete is
logged, leaves the files in place and never blocks the database delete. An
opt-out does not delete them, so an erasure request still has to reach the
agent’s bucket by hand, and a lifecycle rule on the bucket is the way to make
files expire on their own.
Outbound mail (managed services) is not an environment variable either. The relay, the sender and the panel origin are installation settings a platform admin edits at Admin Console → Installation policies, and the SMTP password is stored encrypted there. Correcting a sender address is an edit with an audit row rather than a redeploy. See Installation policies.
Per-agent credentials - model providers, object storage, LangSmith, Deepgram, GitHub, channels - are not environment variables at all: they are rows, and where each one comes from is in Manage secrets and Channels. With managed services the AI and speech provider keys are provider connections, and the deployment’s own AWS identity reaches Bedrock only for an organization a platform admin has granted it.
Settings that are no longer environment variables
The operational limits and timeouts (live-stream caps, rate-limit window and limit, agent-to-agent and MCP timeouts, scheduler deadlines) and analytics retention are Installation policies, edited by a platform admin at Admin Console → Installation policies (managed services) or Settings → General (open source). Time zone, locale, memory dedup threshold and the message-coordination timings are each agent’s Behaviour overrides. The knowledge-document cap belongs to the organization. A deployment-wide time zone for the process is TZ.
The variables that used to hold these values are read by nothing. The engine logs a warning at boot for each one still set, naming where the value is set now. The full list is in Retired variables.
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 | Address the web server forwards the panel’s engine calls to (the engine routes under /api/* and /v1/* among them) through its Next.js rewrites. The calls leave from the web container, not from the browser, so the address must be reachable from there: on a single host, the engine’s container address. Embedded at build time, so a change needs a rebuild. |
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, managed services | Google sign-in, available with managed services. Without both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET the Google sign-in button is hidden and the provider is disabled. WHICH accounts it admits is not configured here: the organization’s SSO domain list and the invitation rule decide, both product data. AUTH_ALLOWED_DOMAIN / AUTH_ALLOWED_DOMAINS no longer exist. |
GOOGLE_CLIENT_SECRET | optional, managed services | See above. |
Behind a reverse proxy - checklist
Run-of-the-mill production setups put both services behind nginx / Caddy / a managed platform’s TLS terminator. Four things 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. - A public HTTPS address for the engine. Telegram, Slack, WhatsApp, email, HubSpot and phone calls deliver to the engine’s public address, set as
BASE_URLor stored in Installation policies. An engine reachable only on a private network receives no channel traffic. - Routes that reach the engine with no sign-in in front. These paths must go to the engine, never to the web panel, and must not sit behind an identity-aware proxy or SSO gateway: each one authenticates its caller itself, and a provider cannot complete a sign-in.
/webhooks/*- Telegram, Slack, Twilio, email and HubSpot webhooks. With managed services, a phone call’s audio arrives on/webhooks/twilio/<slug>/voice/streamas a WebSocket, so the proxy must pass theUpgradehandshake on that path too./channels/http/*- the HTTP channel./dev-mode/socket- the dev-mode bridge (managed services), as a WebSocket, so the proxy must pass theUpgradehandshake through.
If neither service is behind a proxy (single-host deployment with the engine directly exposed), leave the first two at their defaults; the address and routes still apply.
AWS CDK stack
The AWS CDK stack shipped with the product already applies the checklist above. Its load balancer sends /webhooks/* and /channels/http/* (and, with managed services, /dev-mode/socket) to the engine without the OIDC sign-in that fronts the panel. A hand-built load balancer needs the same rules: without them those requests reach the web panel and answer 404, and inbound channel traffic never arrives.
The stack sets the container time zone as TZ, from app.timezone in config.yaml. That key is the only one the app block keeps: defaultInstanceId and locale are gone, and the stack no longer passes DEFAULT_INSTANCE_ID or DATETIME_LOCALE to the engine. Remove them from an existing config.yaml.
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}
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U polyant -d polyant"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- pgdata:/var/lib/postgresql/data
engine:
build:
context: .
dockerfile: Dockerfile.engine
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
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
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD}
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 60s
volumes:
- engine-logs:/app/logs
ports: ["4000:4000"]
web:
build:
context: .
dockerfile: Dockerfile.web
args:
NEXT_PUBLIC_API_URL: http://engine:4000
restart: unless-stopped
depends_on:
engine:
condition: service_healthy
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
# Managed services only: Google sign-in
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
ports: ["3000:3000"]
volumes:
pgdata:
engine-logs:Save it as compose.yaml at the repository root, the build context. Compose prefers that name to the development docker-compose.yml beside it, and reads the ${...} values from a .env file in the same folder. POSTGRES_PASSWORD goes into the connection URLs as it is, so choose one without URL-reserved characters, for example openssl rand -hex 32. On first boot the engine creates the platform admin from INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD, and the panel asks for a new password at the first sign-in.
The engine and the panel wait for the services they need: the engine starts once PostgreSQL accepts connections, so the first boot’s migrations find the database, and the panel once the engine’s /health answers.
The engine copies its console messages (the boot sequence, seeding, HTTP errors) to one file a day, engine-YYYY-MM-DD.log, in /app/logs, and at startup and every hour deletes the oldest beyond the last fourteen. The component loggers (pipeline, memory, scheduled tasks and the like) write only to the container output. The engine-logs volume keeps the files when the container is recreated: docker compose exec engine ls /app/logs lists them, and docker compose logs -f engine follows the container output.
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:
Dockerfile.engine - Docker build context:
.(repo root) - Health check:
/health - Plan: Starter+ (memory-intensive due to tools)
- Web service:
- Environment: Docker
- Dockerfile path:
Dockerfile.web - 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 (
docker-entrypoint.shat the repository root invokesdb: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 packages/engine/dist/database/migrate.js) before rolling out a new engine version, or let the engine run them at startup. Some releases are not rolling upgrades:1.2.0-eemust stop every old engine pod before the new one migrates. Scale the deployment to zero first, and read Upgrading before each release. - Scaling: Telegram and Slack receive through HTTPS webhooks. Keep the engine’s public address (
BASE_URL, or the one stored in Installation policies) public and stable; both providers must reach it. A rolling deploy no longer silences Telegram: the webhook is deleted only when the channel is switched off or deleted, or its agent is deleted. Run one engine replica with channels enabled until channel lifecycle and scheduled jobs have 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
docker-entrypoint.shat the repository root. - To run manually:
npm run db:migrate. - Rolling back: create a new forward-migration that undoes the change. Drizzle migrations are forward-only.
- Only one migrator runs at a time. Concurrent starts are serialised, and the journal cannot be skipped silently - a container that cannot take the lock waits rather than racing.
- All pending migrations run in one transaction. If any of them fails, none is applied. A long migration can outlast the container health check (about 150 seconds on the CDK stack): if the platform replaces the task, the transaction rolls back and the upgrade has done nothing. Lengthen the health check’s start period for an upgrade with a heavy migration; Upgrading says which ones are.
- Not every release is rolling-safe. When new migrations break the code of the previous release, the old engine must be stopped before the new one migrates. Upgrading marks those releases.
Upgrading
Release-specific upgrade steps, including the stop-then-start releases and the irreversible migrations, are in Upgrading.
Observability
- Health check:
GET /healthreturns{status: "ok"}. - Scheduler health:
GET /health/scheduleris public and reports counts only: free concurrency slots and runs stuck past their deadline. Zero free slots, or stuck runs, across consecutive scrapes means the scheduler loop is wedged while/healthstill saysok. See Scheduled tasks. - Retired variables: at boot the engine logs one warning for each retired environment variable it still finds set, naming where the value is set now. A deployment that kept one would otherwise believe the old value still applied.
- 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, plus anoutcomeand an error class (auth,rate_limit,bad_request,overloaded,timeout,unknown) for calls that failed. The provider’s error body is deliberately not stored. Cost totals count every billed call; call counts and response times count only model calls that returned - see Analytics. - MCP server health: the last check time, outcome and error are persisted per configured server, written both by the manual test in the panel and by the runtime’s own per-turn connect. The second is the one that matters: a server can pass a test at 09:00 and be unreachable all afternoon.
- LangSmith: enable per-instance in Advanced → LangSmith Tracing for distributed tracing.
- Application logs: timestamped text lines on stdout and stderr - ship them to your log aggregator. The engine’s console messages also go to daily files in
/app/logs, as described under Docker Compose.
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.