Skip to main content

Configuration

Environment Variables​

Database​

VariableDefaultDescription
DATABASE_HOSTlocalhostPostgreSQL host
DATABASE_PORT5432PostgreSQL port
DATABASE_NAMEsoat_devDatabase name
DATABASE_USERsoat_userDatabase user
DATABASE_PASSWORDsoat_passwordDatabase password
DATABASE_POOL_MAX10Maximum pooled connections per server process

DATABASE_POOL_MAX is sized for two concurrent hybrid knowledge searches with headroom: one such search holds four connections at once — a vector and a lexical query over each store, two of them inside their own transaction. Raise it for a hotter deployment, keeping DATABASE_POOL_MAX × <number of tasks> below the database's max_connections.

The database needs pgvector 0.8 or newer (the pgvector/pgvector image, or a manual install). Semantic search sets hnsw.iterative_scan, added in 0.8; an older extension discards the setting with a warning and a scoped or path-filtered search can silently come back short. See Ranking is approximate.

Standard PG* environment variables​

Beyond DATABASE_* (most commonly TLS), the node-postgres driver honors the standard libpq PG* environment variables.

VariableDescription
PGSSLMODESSL negotiation mode: disable, prefer, require, verify-ca, verify-full, or no-verify
PGSSLROOTCERTPath to a CA certificate bundle used to verify the server certificate (required for verify-full)
PGCONNECT_TIMEOUTConnection timeout in seconds
PGOPTIONSCommand-line options to send to the server at connection time

Full list: libpq environment variables.

Managed PostgreSQL with forced SSL

Amazon Aurora / RDS may set rds.force_ssl=1. SOAT connects in plaintext by default, so the server exits at startup. Set PGSSLMODE:

services:
server:
environment:
# ... DATABASE_* variables
PGSSLMODE: no-verify

no-verify encrypts without certificate verification. Stricter: PGSSLMODE=verify-full with PGSSLROOTCERT pointing at the provider's CA bundle (for RDS, the Amazon RDS CA bundle).

Aurora PostgreSQL 18.3

Aurora PostgreSQL 18.3 crashes on the ORM's multi-statement session-setup query (SET client_min_messages ...; SET TIME ZONE ...). SOAT suppresses the SET TIME ZONE half (the session timezone is UTC either way), so no extra configuration is needed.

Schema Management​

Schema changes are a discrete step that runs to completion before the service rolls, not something every boot performs. The same image carries it:

docker run --rm <image> node packages/server/dist/migrate.mjs run

It applies the versioned migrations the ORM's sync cannot perform, records each one in a schema_migrations ledger table it maintains itself, and then syncs the schema the models describe. Already-applied migrations are skipped, so this is safe on every release rather than something to remember.

CommandDoes
migrate.mjs statusEvery migration, and when each was applied
migrate.mjs run --dry-runWhat a real run would do; writes nothing
migrate.mjs runApplies what is pending, then syncs

A new database needs no special handling. Every migration can recognise its own change in the schema, so one the sync has just built records them without running them. There is no baseline step to remember.

An ordinary boot performs no DDL: it reads the ledger and refuses to start if the database is behind the code, naming the migrations that are missing. That keeps /health honest — a task binds its port in seconds, so an orchestrator that health-gates a rollout is never waiting on a migration.

VariableDefaultDescription
DB_SYNCfalseWhen true, the process prepares the schema itself at boot instead of requiring the step above
SCHEMA_SYNC_LOCK_TIMEOUT_MS600000 (10min)Upper bound in milliseconds on how long a schema step waits to acquire its advisory lock before failing fast

DB_SYNC=true is for a deployment with nowhere to put a pre-deploy step — a single container against its own database. Set it and docker compose up works with no extra step, at the cost of a boot that blocks on the schema.

Upgrading from a release that synced at boot

Deployments that relied on the boot-time sync({ alter: true }) must add the migrate step to their rollout, or set DB_SYNC=true. Without either, the new image refuses to start and says so.

Any non-positive-integer SCHEMA_SYNC_LOCK_TIMEOUT_MS (non-numeric, 0, negative, fractional, empty) falls back to the default. Keep it larger than a legitimate migration's duration: it bounds a process waiting for a peer's lock, and a waiter should wait a live migration out rather than abort.

Indexes are never dropped by the sync

The sync creates the indexes the current schema declares and never drops earlier ones. A renamed index leaves its predecessor in place; a widened unique index leaves the narrower one enforcing the old constraint, which can reject writes the current schema permits.

Release notes call out indexes to drop: DROP INDEX CONCURRENTLY IF EXISTS <name>, or ALTER TABLE <table> DROP CONSTRAINT IF EXISTS <name> when a UNIQUE constraint owns the index.

Server​

VariableDefaultDescription
PORT5047HTTP port the server listens on
SOAT_ERROR_LOGS_ENABLEDtrueControls request error logs from the global error middleware

Debug Logging​

Logging uses the debug package.

VariableDefaultDescription
DEBUG(off)Enables debug namespaces (for example, soat:* or soat:formations)
# Enable all SOAT debug namespaces
DEBUG=soat:* pnpm dev

# Enable only formation-related logs
DEBUG=soat:formations pnpm dev

In Docker Compose:

services:
server:
environment:
DEBUG: soat:*

SOAT_ERROR_LOGS_ENABLED is independent of DEBUG. Unset means enabled; disable with false, 0, off, or no (case-insensitive).

# Disable request error logs from the global middleware
SOAT_ERROR_LOGS_ENABLED=false pnpm dev

# Also disables (same behavior, case-insensitive)
SOAT_ERROR_LOGS_ENABLED=OFF pnpm dev

# Request error logs still remain enabled regardless of DEBUG filters
SOAT_ERROR_LOGS_ENABLED=true DEBUG=soat:formations pnpm dev

Admin Bootstrap​

VariableRequiredDescription
SOAT_ADMIN_USERNAMENoIf set and no users exist at startup, an admin account is created automatically
SOAT_ADMIN_PASSWORDNoPassword for the auto-created admin. Must meet complexity requirements

Secrets Encryption​

VariableRequiredDescription
SECRETS_ENCRYPTION_KEYYes64-character hex string (32 bytes) used to encrypt stored secrets

Also encrypts webhook and trigger signing secrets at rest. If lost, outbound webhook delivery and inbound webhook-trigger signature verification fail until each affected secret is rotated and subscribers receive the new value.

Generate a key:

openssl rand -hex 32
danger

SECRETS_ENCRYPTION_KEY must be set in production. Changing it makes stored secrets and webhook/trigger signing secrets unreadable.

Outbound Egress​

VariableDefaultDescription
TOOL_EGRESS_ALLOWED_HOSTS(unset)Comma-separated non-public destinations the server may request on a tenant's behalf

An http or mcp tool target must be publicly routable by default. Loopback, RFC1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16, cloud metadata services), CGNAT and IPv6 ULA are refused with 403 TOOL_EGRESS_BLOCKED unless listed here. The same rule covers every tenant-chosen destination:

DestinationRefused how
An http/mcp tool target403 TOOL_EGRESS_BLOCKED on the call
A webhook's urlthe delivery is closed as failed, with the reason on the row
An AI provider's base_urlthe generation or model listing fails
A GCP service-account key file's token_uri, on an http tool403 TOOL_EGRESS_BLOCKED on the call

Operator-chosen destinations (OLLAMA_BASE_URL, EMBEDDING_BASE_URL, the embedding stack) are not covered and keep working on localhost. Unset, the public internet stays reachable; only non-public networks are closed.

environment:
# hostname, host:port, *.suffix, or CIDR — comma-separated
TOOL_EGRESS_ALLOWED_HOSTS: 'billing.svc.cluster.local,*.internal.acme.com,10.42.0.0/16'
Entry formMatches
billing.svc.cluster.localthat hostname, on any port, whatever it resolves to
server:5047that hostname, only on port 5047 (the URL's implicit scheme port counts)
*.internal.acme.comany subdomain of that suffix
10.42.0.0/16any hostname whose resolved address falls in the range
[::1]:8080an IPv6 literal with a port

A malformed entry fails loudly rather than being dropped. The resolved address is checked (a public-looking hostname whose A record points at 169.254.169.254 is refused), every redirect hop is checked, and credential headers (Authorization, Cookie) are dropped when a redirect changes origin.

note

Deployment-wide, not per-project. When the destination is SOAT's own API, use a builtin tool rather than an http tool at your own base URL: it dispatches in-process under the caller's permissions.

Provider Credentials​

VariableDefaultDescription
AI_PROVIDER_ALLOW_AMBIENT_CREDENTIALSfalseWhether an AI provider record that links no credential may sign with the deployment's own

bedrock and vertex AI provider SDKs fall back to the deployment's credentials: Bedrock walks the AWS default credential chain (environment, instance or task role), Vertex resolves Application Default Credentials. Unless this is true, a tenant-written provider record must carry its own credential:

  • 400 VALIDATION_FAILED on create or update with neither a linked secret nor a config.apiKey;
  • 400 AI_PROVIDER_MISCONFIGURED when such a record generates or lists models, so one that reached the table another way fails closed.

Set true only on a single-tenant deployment (an EC2 instance profile or ECS task role serving your own team); otherwise a tenant's record generates on the deployment's cloud account, quotas and IAM role.

The embedding stack is unaffected: EMBEDDING_PROVIDER and its region are operator settings, so bedrock embeddings always use the AWS credential chain.

File Storage​

VariableDefaultDescription
FILES_STORAGE_DIR/data/filesLocal directory where uploaded files are stored

Mount a persistent volume here in Docker.

Agent Generation​

VariableDefaultDescription
SOAT_TOOL_CALL_TIMEOUT_MS300000Maximum time in milliseconds to wait for a single external tool call (MCP, SOAT, or HTTP tools)
TOOL_CONTEXT_HEADER_PREFIXX-Soat-Context-Prefix prepended to every tool_context key to form the outbound request header name

A tool server that does not respond within SOAT_TOOL_CALL_TIMEOUT_MS (default 5 minutes) aborts the call and fails the generation.

TOOL_CONTEXT_HEADER_PREFIX renames the context headers a deployment emits (e.g. to hide the SOAT name from third-party tool providers). Prepended verbatim, so include the trailing - (X-Acme-Context- + userId → X-Acme-Context-userId). Must be a valid HTTP header-name prefix (letters, digits and !#$%&'*+-.^_`|~); an invalid value fails the tool call with an error naming the variable. Empty or unset keeps the default; the prefix cannot be removed, since an unprefixed key could land on Authorization. Changing it breaks every tool endpoint already reading these headers: set it before wiring up tools, or update both sides together.

Retrieval defaults for knowledge search. Every one of them is also a per-request field, and the request wins; an invalid value falls back to the variable, then to the default, rather than failing the search.

VariableDefaultDescription
KNOWLEDGE_RRF_K60The k in the reciprocal rank fusion term 1 / (k + rank); smaller weights the top of each ranked list more heavily
KNOWLEDGE_RECENCY_HALF_LIFE_DAYS0Half-life in days of a recency decay applied to memory results after fusion. 0 disables the blend
KNOWLEDGE_TEXT_SEARCH_CONFIGsimplePostgreSQL text search configuration backing the lexical channel (e.g. english, portuguese)

KNOWLEDGE_RECENCY_HALF_LIFE_DAYS ships at 0, so nothing decays until it is set and an upgrade reorders nothing. It accepts fractions (0.5 is twelve hours), reads age from an entry's updated_at, and never touches document results. How many ranks a given half-life costs depends on KNOWLEDGE_RRF_K, and on a corpus mixing documents and memories it can cost more than it looks — measure it against your own data before enabling it. See Recency blend.

Embeddings​

Ollama by default; OpenAI and Amazon Bedrock are supported.

VariableDefaultDescription
EMBEDDING_PROVIDERollamaEmbedding provider: ollama, openai, or bedrock
EMBEDDING_MODELqwen3-embedding:0.6bModel name for the selected provider
EMBEDDING_DIMENSIONS1024Embedding vector dimensions (must match the model; at most 2000)
OLLAMA_BASE_URLhttp://localhost:11434Base URL of the Ollama instance (ollama only)
EMBEDDING_API_KEY—OpenAI API key, or a Bedrock ABSK… bearer token. openai falls back to OPENAI_API_KEY
EMBEDDING_BASE_URL—Override base URL for an OpenAI-compatible endpoint (openai only)
EMBEDDING_REGIONus-east-1AWS region for Bedrock (bedrock only); falls back to AWS_REGION
EMBEDDING_INPUT_1M_TOKEN_PRICE_USD(unset)USD per million input tokens. Unset meters embeddings at 0; the price book does not price them

Embedding spend is priced from EMBEDDING_INPUT_1M_TOKEN_PRICE_USD, not the price book (no AI provider record configures the embedding stack). Unset meters every embedding at 0 (correct for a local model, silently free on a vendor-billed one) and logs a startup warning. See Pricing embeddings.

Change EMBEDDING_MODEL and EMBEDDING_DIMENSIONS together. Dimensions may not exceed 2000, the widest vector pgvector can build an HNSW index over; a larger model is refused at startup. Bedrock without EMBEDDING_API_KEY uses the AWS credential chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY).

Docker Compose Example​

PostgreSQL and Ollama run externally.

services:
server:
image: ttoss/soat:latest
ports:
- '5047:5047'
environment:
SOAT_ADMIN_USERNAME: admin
SOAT_ADMIN_PASSWORD: change-me
SOAT_ERROR_LOGS_ENABLED: 'true'
DATABASE_HOST: <postgres-host>
DATABASE_PORT: '5432'
DATABASE_NAME: soat_prod
DATABASE_USER: soat_user
DATABASE_PASSWORD: change-me
SECRETS_ENCRYPTION_KEY: <64-char hex — run `openssl rand -hex 32`>
FILES_STORAGE_DIR: /data/files
OLLAMA_BASE_URL: http://<ollama-host>:11434
EMBEDDING_PROVIDER: ollama
EMBEDDING_MODEL: qwen3-embedding:0.6b
EMBEDDING_DIMENSIONS: '1024'
volumes:
- files_data:/data/files

volumes:
files_data:
tip

Replace every change-me and SECRETS_ENCRYPTION_KEY (openssl rand -hex 32) before deploying.

Linux: Connecting to Host Services from Docker​

Docker on Linux does not resolve host.docker.internal automatically (Docker Desktop on macOS and Windows does). Reaching host services (Ollama, PostgreSQL) needs two steps.

Step 1: Add extra_hosts to your Docker Compose file​

services:
server:
image: ttoss/soat:latest
extra_hosts:
- 'host.docker.internal:host-gateway'
environment:
OLLAMA_BASE_URL: http://host.docker.internal:11434
# ... other environment variables

Step 2: Configure Ollama to listen on all interfaces​

Ollama binds to 127.0.0.1 by default, unreachable from a container:

# Create an override for the Ollama systemd service
sudo systemctl edit ollama

Add:

[Service]
Environment="OLLAMA_HOST=0.0.0.0"

Restart:

sudo systemctl restart ollama
warning

OLLAMA_HOST=0.0.0.0 exposes Ollama on all interfaces; firewall port 11434 on a network-facing machine.

Verification​

docker compose exec server wget -qO- http://host.docker.internal:11434/api/tags

Expected: a JSON list of Ollama models. Otherwise re-check both steps and systemctl status ollama.

Production Checklist​

  • Generate a strong SECRETS_ENCRYPTION_KEY — openssl rand -hex 32
  • Use strong database credentials — change the defaults
  • Set SOAT_ADMIN_USERNAME / SOAT_ADMIN_PASSWORD — or call /bootstrap immediately after first deploy
  • Mount a persistent volume on FILES_STORAGE_DIR to preserve uploaded files
  • Back up the PostgreSQL volume regularly — all data lives in Postgres and on the file storage
  • Put SOAT behind a reverse proxy (nginx, Caddy, etc.) with TLS termination — the server does not handle HTTPS directly