Configuration Reference¶
All configuration is managed via environment variables, loaded from
backend/.env using pydantic-settings.
Settings are defined in app/core/config.py and accessed via the global
settings object:
Getting Started¶
make install creates backend/.env from backend/.env.example when there is
none, and never touches it again — so on a fresh checkout there is nothing to
copy, and on an existing one nothing to lose.
Before anything reaches a network anybody else is on, set the values the example ships as placeholders:
openssl rand -hex 32 # SECRET_KEY — signs every access token
openssl rand -hex 32 # VAULT_MASTER_KEY — unwraps every credential stored at rest
SECRET_KEY ships as a published string, and an empty VAULT_MASTER_KEY falls
back to it so a fresh checkout runs at all. Both are fine on a laptop and are the
whole security of a deployment anywhere else — and setting VAULT_MASTER_KEY
explicitly is also what lets stored secrets survive a SECRET_KEY rotation.
Project Settings¶
| Variable | Default | Description |
|---|---|---|
PROJECT_NAME |
agenticos |
Display name for the project |
API_V1_STR |
/api/v1 |
API version prefix |
DEBUG |
false |
Enable debug mode (verbose errors, auto-reload) |
ENVIRONMENT |
local |
One of: development, local, staging, production |
TIMEZONE |
UTC |
IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York) |
MODELS_CACHE_DIR |
./models_cache |
Directory for cached ML models |
MEDIA_DIR |
./media |
Directory for uploaded files |
MAX_UPLOAD_SIZE_MB |
50 |
Maximum file upload size in megabytes |
Authentication¶
JWT¶
| Variable | Default | Description |
|---|---|---|
SECRET_KEY |
(insecure default) | JWT signing key. Must be changed in production. Generate with: openssl rand -hex 32 |
ACCESS_TOKEN_EXPIRE_MINUTES |
30 |
Access token lifetime |
REFRESH_TOKEN_EXPIRE_MINUTES |
10080 |
Refresh token lifetime (7 days) |
ALGORITHM |
HS256 |
JWT signing algorithm |
Production validation: SECRET_KEY must be at least 32 characters and cannot
use the default value in ENVIRONMENT=production.
Secret vault¶
Every credential the platform stores at rest — provider keys, channel bot
tokens, MCP credentials and organization secrets — is sealed by
app/core/vault.py, whose envelope is derived from the master key and the
owner (an organization, or the member a personal connection belongs to). A
ciphertext is therefore useless outside the tenant it was sealed for.
| Variable | Default | Description |
|---|---|---|
VAULT_MASTER_KEY |
(empty, falls back to SECRET_KEY) |
Master key for the secret vault. Set it explicitly in production so stored secrets survive a SECRET_KEY rotation. Generate with: openssl rand -hex 32 |
API Key¶
| Variable | Default | Description |
|---|---|---|
API_KEY |
change-me-in-production |
Shared API key for programmatic access |
API_KEY_HEADER |
X-API-Key |
HTTP header name for API key |
Production validation: API_KEY cannot use the default value in
ENVIRONMENT=production.
OAuth2 (Google)¶
| Variable | Default | Description |
|---|---|---|
GOOGLE_CLIENT_ID |
(empty) | Google OAuth2 client ID |
GOOGLE_CLIENT_SECRET |
(empty) | Google OAuth2 client secret |
GOOGLE_REDIRECT_URI |
http://localhost:8000/api/v1/oauth/google/callback |
OAuth2 callback URL |
FRONTEND_URL |
http://localhost:3000 |
Frontend URL for OAuth2 redirects |
Getting the pair: Google Cloud console → APIs & Services → Credentials → Create OAuth client ID → Web application.
The authorized redirect URI is the backend's callback, not the frontend's -
http://localhost:8000/api/v1/oauth/google/callback by default, and whatever
GOOGLE_REDIRECT_URI says in a deployment. Google exchanges the code with the
API, which then sends the browser on to FRONTEND_URL. Registering the
frontend URL instead is the mistake worth naming: the consent screen works, and
the callback 404s.
Database (PostgreSQL)¶
| Variable | Default | Description |
|---|---|---|
POSTGRES_HOST |
localhost |
PostgreSQL host |
POSTGRES_PORT |
5432 |
PostgreSQL port |
POSTGRES_USER |
postgres |
PostgreSQL user |
POSTGRES_PASSWORD |
(empty) | PostgreSQL password |
POSTGRES_DB |
agenticos |
Database name |
DB_POOL_SIZE |
5 |
Connection pool size |
DB_MAX_OVERFLOW |
10 |
Max overflow connections |
DB_POOL_TIMEOUT |
30 |
Pool timeout in seconds |
Computed properties:
- DATABASE_URL -- async connection string (postgresql+asyncpg://...)
- DATABASE_URL_SYNC -- sync connection string for Alembic
Redis¶
| Variable | Default | Description |
|---|---|---|
REDIS_HOST |
localhost |
Redis host |
REDIS_PORT |
6379 |
Redis port |
REDIS_PASSWORD |
(none) | Redis password (optional) |
REDIS_DB |
0 |
Redis database number |
Background work (Prefect)¶
| Variable | Default | Description |
|---|---|---|
PREFECT_API_URL |
http://localhost:4200/api |
The self-hosted server, or a Prefect Cloud workspace URL |
PREFECT_API_KEY |
(none) | Prefect Cloud only |
PREFECT_RUNNER_LIMIT |
5 |
How many flow runs execute at once; the rest queue |
PREFECT_RUNNER_SERVER_HOST |
127.0.0.1 in compose |
Interface the runner serves its own health endpoint on |
PREFECT_RUNNER_SERVER_PORT |
8080 |
Port for the same |
PREFECT_RUNNER_LIMIT is a memory ceiling, not a throughput dial. Each run is a
separate process that imports the whole application — roughly 120 MB — and the
number that matters is not the steady state but the restart: the runner comes up,
finds every run that was scheduled while it was down, and starts as many as the
limit allows. Uncapped, three days of downtime was 71 processes and 6 GiB. Raise
it if ingestion queues behind syncs on a machine with memory to spare; lower it on
a small host.
The two PREFECT_RUNNER_SERVER_* variables are Prefect's, and the compose files
pin them so the runner's container has a health status that means something. The
runner starts Prefect's runner webserver, whose GET /health answers 503 once it
has missed two polls of the Prefect API — so a process that is alive but no longer
picking up work reads unhealthy rather than fine. It is bound to the loopback
because the same webserver also exposes POST /shutdown; the probe runs inside
the container, and nothing outside it can reach either. Moving the port means
moving the probe in the compose files with it.
There is no HEALTHCHECK in backend/Dockerfile. The image is started as two
different processes — the API and this runner — and a probe for one is a
permanent false alarm on the other, so each service definition carries its own.
Approval expiry¶
| Variable | Default | Description |
|---|---|---|
APPROVAL_EXPIRY_HOURS |
72 |
How long a parked tool call waits before the hourly sweep denies it by timeout |
Three days because it has to span a weekend: the approval that arrives on Friday afternoon is the one nobody decides, and expiring it on Saturday would be expiring it for having been asked at the wrong hour. Shorten it where a queue is watched during the working day and a stale ask is worse than a slow one; lengthen it where approvals are a weekly ritual. Expiring a call also ends its run — see Governance for what that settles and what it deliberately leaves alone.
AI Models — configured in the app, not here¶
Chat models are not environment variables. Each organization stores its own
provider keys in the vault (Settings → Models), and every agent's spec names
the model profile it runs on. AI_MODEL, AI_TEMPERATURE,
AI_THINKING_ENABLED, AI_THINKING_EFFORT, AI_AVAILABLE_MODELS,
AI_FRAMEWORK and LLM_PROVIDER were removed along with the template's
general assistant; setting them now does nothing.
The one model credential that stays in the environment is the embeddings key — see RAG below.
Observability (Logfire)¶
| Variable | Default | Description |
|---|---|---|
LOGFIRE_TOKEN |
(none) | Pydantic Logfire token. Get one at https://logfire.pydantic.dev |
LOGFIRE_SERVICE_NAME |
agenticos |
Service name in Logfire dashboard |
LOGFIRE_ENVIRONMENT |
development |
Environment tag |
LOGFIRE_ORGANIZATION |
(none) | Organization slug, for building a link into a stored trace. The token is a write credential and carries neither slug |
LOGFIRE_PROJECT |
(none) | Project slug, alongside the organization. With either unset a run's logfire_trace_id is still recorded and no link is offered |
LOGFIRE_BASE_URL |
https://logfire-us.pydantic.dev |
Which Logfire deployment those slugs belong to. logfire-eu is a different host, and a link built for the wrong one 404s |
Web Search¶
| Variable | Default | Description |
|---|---|---|
RAG (Retrieval Augmented Generation)¶
Vector Database¶
pgvector uses the existing PostgreSQL connection. No additional configuration is needed.
Embeddings¶
| Variable | Default | Description |
|---|---|---|
OPENROUTER_API_KEY |
(empty) | The fallback embeddings credential, for collections that chose no vault key of their own — and the one a degraded choice falls back to. Not "every collection embeds on it": see File processing |
EMBEDDING_MODEL |
text-embedding-3-large |
What a new collection is built with. The width is recorded on the row and never changes afterwards, so changing this does not invalidate existing collections — they keep embedding with the model they were created with |
Document Parsing — configured per collection, not here¶
Parser, OCR, chunk size, chunk overlap, chunking strategy and the
image-description model are not environment variables. They are stored on
each knowledge base (knowledge_bases.ingestion_config) and edited on /rag,
and any one of them can additionally be overridden for a single upload.
The reason is that one installation-wide value made the same form produce
different collections on two deployments, with nothing in the product showing
which — and a scanned contract archive and a folder of Markdown notes want
different answers on the same deployment. PDF_PARSER, CHAT_PDF_PARSER,
LLAMAPARSE_TIER, LITEPARSE_OCR_LANGUAGE, LITEPARSE_TIMEOUT_SECONDS,
RAG_ENABLE_OCR, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP and
RAG_CHUNKING_STRATEGY were removed; setting them now does nothing.
What stays here is what a tenant must not choose:
| Variable | Default | Description |
|---|---|---|
LLAMAPARSE_API_KEY |
(empty) | Fallback LlamaParse key for collections that chose no vault key of their own |
LITEPARSE_OCR_SERVER_URL |
(empty) | HTTP OCR server; an address on the deployment's own network |
Chat attachments are read with PyMuPDF and are not configurable: an attachment belongs to no collection, so there is no stored configuration to read.
Google Drive Sync¶
| Variable | Default | Description |
|---|---|---|
GOOGLE_DRIVE_CREDENTIALS_FILE |
credentials/google-drive-sa.json |
Path to Google service account credentials, for rag-sync-gdrive only |
This is the CLI's credential, not a fallback for a sync source. A gdrive
sync source carries its own service_account_json and runs on that or does not
run: a deployment-wide key standing in for a missing field meant a tenant's
folder_id chose what was listed under the operator's service account.
The file is a service account's key: Cloud console → create a service account → Keys → Add key → JSON. Then share the Drive folder with the service account's own email address - it is a principal like any other, and a folder nobody shared with it lists as empty rather than as refused.
S3/MinIO Sync¶
| Variable | Default | Description |
|---|---|---|
S3_RAG_ENDPOINT |
(none) | S3/MinIO endpoint URL |
S3_RAG_ACCESS_KEY |
(empty) | Access key |
S3_RAG_SECRET_KEY |
(empty) | Secret key |
S3_RAG_BUCKET |
agenticos-rag |
Bucket name |
S3_RAG_REGION |
us-east-1 |
AWS region |
Agent workspaces¶
The state workspace needs nothing here. It is stored in this database, works on
every deployment, and is what an agent gets by default — so the settings below
are only for a container-backed one.
| Variable | Default | Notes |
|---|---|---|
SANDBOX_STATE_MAX_BYTES |
4 MiB | Per workspace. Past it a write is refused with a message the model reads |
SANDBOX_INLINE_IMAGE_MAX_BYTES |
5 MiB | Above this an attached image is written to the workspace and not also sent inline |
Where sandboxes run is not a setting. It is a row per organization — Sandboxes
in the app, sandbox_connections in the database — with the service token in the
vault. Two reasons, and neither is expressible in an environment variable: a
deployment can hold more than one host, and one address per deployment gave every
organization the same one; and the token authorises opening a session, which runs
commands on the host holding the Docker socket, so it belongs where every other
credential at rest lives.
An operator registers a connection with a name, an address, and a key from the vault. An agent names one by id, exactly as it names a model profile, or names none and takes the organization's default — so moving to another host is one edit rather than a republish of every agent.
The service token is worth what the Docker socket is worth. The service holds
that socket, the socket is an unauthenticated API for root on the host, and the
token is what opens a session on it. Never in a browser, never in a log, never
committed — which is why the operator screen shows only that a credential is
attached, and why GET /policy is proxied through this API rather than fetched by
the browser. The service's own dashboard (SANDBOXD_UI_ENABLED) is off in every
shipped compose file for the same reason: it asks a human to paste this value into
a browser.
SANDBOXD_TOKEN in backend/.env is the service's own — what the daemon in the
compose file will accept. make sandbox-token generates it, and the connection
form stores the same value in the vault for you: the API reads this setting for
exactly one purpose, offering it to the vault, and asking somebody to copy a secret
out of a file their own stack is already reading is friction with nothing behind
it. It is never used to reach a host — resolving a connection unseals the vault
entry that connection names, and that stays the only path — so a deployment that
leaves it unset loses one button and nothing else, and pastes the token instead.
The same form asks whether a service is already answering, rather than making an
operator know that a make dev sandbox service lives at http://sandboxd:8080.
That address is not configuration and deliberately so — it is a row, because a
deployment can hold several hosts — so the API probes the unauthenticated
/healthz at the address this project's compose file uses and prefills what
answered. Nothing is decided by asking: no service means an empty field, and a
connection already pointing there is named so nobody registers one host twice.
The address is fetched by this API, so it is validated as one. Registering or
probing a connection makes the API container issue an authenticated GET and hands
the JSON body back, which is a request-forgery primitive if the address is taken on
trust. So base_url refuses anything that is not http(s) with a host, and refuses
link-local addresses and the instance-metadata hostnames outright —
169.254.169.254 and metadata.google.internal are never a sandbox service.
Private addresses stay allowed, and have to: http://sandboxd:8080 inside compose
and http://localhost:8080 for a developer running the API on their host are both
private, so a private-range denylist would refuse the deployment this page
describes. That means the validator narrows the hole rather than closing it — a
hostname that resolves to something internal still resolves. The boundary that
actually holds is connections:manage plus egress policy on the API container:
whoever may register a host is trusted with one, and a deployment on a network
holding unauthenticated internal APIs should say so at the network rather than here.
The service runs behind the sandbox compose profile, which is on by default in
local dev and off elsewhere until an operator opts in — mounting the Docker socket
on a shared host is a deliberate act. COMPOSE_DEV_PROFILES in the Makefile is
the one place to change that. uv run agenticos cmd doctor probes every registered
connection: whether it answers, whether it accepts its credential, and whether it
allows any runtime at all. No connection registered is a warning, not a failure —
the state workspace needs none.
Browsing what the agents kept. Workspaces is its own screen — not part of Sandboxes, which is about hosts. Each row names the agent, the conversation the files belong to (or how many chats reach them, for a workspace no single conversation owns), who can see them, how big it is and when it was last used. Open goes to that workspace's own page: folders walked one at a time, a search box over the whole tree rather than the folder on screen, tiles for the files, and every file downloadable. A second view on the listing flattens every file the reader can see into one grid — the "who is holding a copy of that CSV" question the per-workspace page cannot answer.
Clicking a file opens it in a viewer, and it is the same viewer in the chat panel.
An image is a picture, a PDF is the browser's own PDF view, markdown offers Preview
and Source — both are the file, and a # that silently became large type is how
somebody fails to notice their agent is writing markdown into something nothing reads
as markdown — and anything else is its text. Download is always there, including for
what cannot be shown at all. One component, because "open this file" meaning two
different things on two screens is how the second one ends up missing a case.
Bytes come from GET /sandbox-workspaces/{id}/raw?path=…, or from GET
/conversations/{id}/workspace/raw?path=… for the panel beside a chat. Two routes
rather than one because they authorise different callers — the conversation route is
reached by fetching the conversation, so somebody a chat was shared with keeps
access — and one module deciding what may be displayed, so the answer cannot differ
by surface. Almost everything is served as an attachment; raster images and PDFs
are served for display, a raster because it cannot execute and a PDF because the
browser renders it in its own viewer, which never gets the page's DOM. SVG and HTML
are downloadable and never displayable — an SVG served inline from this origin is
stored cross-site scripting written by whatever the agent decided to save, and "the
agent wrote it" is not a trust boundary. Everything else is typed
application/octet-stream with X-Content-Type-Options: nosniff, so a browser
cannot decide such a body is HTML after all. The filename travels as filename*
only, because a workspace path can hold any UTF-8 and the bare form has no way to
say so.
Only a stored workspace can serve arbitrary bytes. A container-backed one is read through the workspace archive, whose only reader is textual, so a text file is served by encoding it and anything else is refused rather than quietly mangled — the browser offers the download beside the refusal so the answer is never a dead end.
Files are read only when a workspace is opened, or when the flat view is switched on: a deployment can hold one per warm conversation, so reading each to render the table would be a request per row for a page nobody has asked a question of yet. The flat view is bounded for the same reason, and says so — how many workspaces it read, how many it could not, and whether more exist. A shorter list is otherwise indistinguishable from fewer files.
Who sees which workspace is decided per reader, in the query. A caller holding
connections:manage sees the organization's — the honest bar for a listing that
crosses chats that are not theirs. Everybody else sees the workspaces they are part
of: their own user-scoped files, the workspaces of their own conversations, and
the shared workspace of an agent they have talked to. "Have talked to" rather than
"could open", deliberately: agent scope shares one workspace across an agent's
users and the chat panel already shows those files to anybody in a conversation
with it, so being able to open the agent is a wider claim than this listing makes.
channel scope is visible to an operator only, which is correct rather than an
oversight — it is keyed on a Slack or Telegram chat, so the people sharing it are
identified by that platform and not by a row in users.
A workspace fetched by id applies the same three predicates and answers not found rather than forbidden when they fail: an id must not be usable to discover which workspaces exist in a colleague's conversation. Nothing here crosses an organization — an app admin browsing another tenant's files would be the one read this platform refuses, so they switch organization like anybody else.
A container-backed workspace is read off the host volume, which needs one. The
sandbox service serves those files from SANDBOXD_WORKSPACE_ROOT, and that is what
lets a conversation from last month list its files after its session was reaped —
no container is started to answer. A service configured without one keeps nothing
on disk, so its files exist only while a sandbox is running and cannot be read
without starting one: the Files panel could then only say so, for a file the agent
had demonstrably just written.
Every compose file therefore sets one, overridable with SANDBOX_WORKSPACE_ROOT (an
environment variable where compose interpolates it — the project root, not
backend/.env, except on the dev and prod targets which pass that file
explicitly) — one host path,
bind-mounted at the same location on both sides, because the service creates the
directory and then asks the daemon to mount it and the daemon resolves the path on
the host. A named volume, or any path existing only inside the service's container,
is refused with "mounts denied". Local dev defaults to
/tmp/agenticos-sandbox-workspaces, which Docker Desktop shares and anybody can
write to, so a laptop needs no setup; the server files default to
/var/lib/agenticos/sandbox-workspaces, which has to exist and be writable by uid
10001 (install -d -o 10001 once) and belongs on storage somebody backs up. A
reboot sweeps /tmp, which is the one reason not to point a real deployment there.
That is reported rather than raised. Every listing carries unreadable_reason, and
a client shows it as an explanation instead of an error, because neither cause is a
fault: a service keeping nothing on disk is a configuration with a one-line fix the
message names, and a host that is down will be up later. Raising made it a 500,
which a browser could only render as "something went wrong" — beside an empty list,
which reads as "there are no files". Two wrong answers at once. Reading one file
from such a host is refused with the same sentence rather than reported as "no such
file", which would say the file is missing when it is not.
What is running is read from the service too. The Sandboxes screen lists this
organization's open sandboxes on its default host — runtime, what shares each one,
idle time, and memory against its own ceiling when asked — plus the activity log
per sandbox: which paths were read, which commands ran, and how each went. Neither
file contents nor command output is recorded by the service, which is what keeps
an audit trail from becoming a way to read another agent's work. The dashboard
answers the same three questions in its own section, for a caller holding
connections:manage; memory is behind a switch there for the same reason it is on
the screen, because the service samples each sandbox individually for it.
All three ceilings now divide. The session listing is filtered to the caller's
organization but carries SANDBOXD_MAX_SESSIONS and SANDBOXD_MAX_OPEN_SESSIONS
through from the service untouched, so those two count every tenant on the host while
the rows count one - len(sessions) divides only against SANDBOXD_MAX_SESSIONS_PER_TENANT.
The response carries two host-wide numerators for the other pair, taken from the
unfiltered list before the filter narrows it: host_session_count, the resident
sandboxes the service marks state == "running", against limit; and
host_open_count, every session that exists resident or hibernated, against
open_limit. Now the capacity card can say why a session was refused while this
organization is short of its own ceiling: the host itself is full of somebody else's
work. That the two are host-wide is a deliberate, narrow disclosure - two aggregate
integers naming nothing, a long way from the session rows the filter withholds, and
the listing is gated on connections:view, the authority to watch a host rather than
any member's. They are None on a Daytona connection, which enforces no ceilings of
ours to divide.
That listing is filtered, not forwarded. One sandboxd answers for every
organization that registered a connection at its address, so passing its response
through would show one tenant another tenant's containers. Sessions are matched on
the tenant label this platform sets when it opens one, and named from
agent_workspaces rather than by decoding the session id — the id encodes the
scope key, and parsing it back would make that format a schema.
What the service allows is read from the service. The runtime allowlist and the
ceiling behind each alias (SANDBOXD_RUNTIMES, SANDBOXD_MEM_LIMIT,
SANDBOXD_NETWORK_MODE, SANDBOXD_MAX_SESSIONS_PER_TENANT and the rest) are its
own boot configuration, and there is deliberately no endpoint to write them: a
browser that could reconfigure the process holding the Docker socket would own the
host. The Sandboxes screen and the dashboard's runtimes card both read them so
what is in force is visible, and the Builder offers an agent only the aliases the
service will actually accept.
Neither view asks a Daytona connection any of this. It publishes no allowlist of its own and holds none of our sessions to enumerate — what it permits is a setting on that account, and what runs there is visible in its own dashboard.
Messaging Channels¶
| Variable | Default | Description |
|---|---|---|
Bot credentials are not configured here: each bot is registered in the app
with its token sealed in the vault, and a Slack bot additionally carries its
own app's signing secret and xapp- token (SLACK_BOT_TOKEN,
SLACK_SIGNING_SECRET and SLACK_APP_TOKEN were removed - each bot is its
own Slack app now). Telegram webhook
URLs are built from PUBLIC_BASE_URL (TELEGRAM_WEBHOOK_BASE_URL was
removed), model profiles may point at local endpoints such as Ollama without
any flag (ALLOW_INTERNAL_MODEL_ENDPOINTS was removed), and the run_python
sandbox limits are per-agent capability configuration
(CODE_EXECUTION_TIMEOUT_SECS / CODE_EXECUTION_MAX_MEMORY_MB were removed).
CORS¶
| Variable | Default | Description |
|---|---|---|
CORS_ORIGINS |
["http://localhost:3000","http://localhost:8080"] |
Allowed origins (JSON array) |
CORS_ALLOW_CREDENTIALS |
true |
Allow credentials (cookies) |
CORS_ALLOW_METHODS |
["*"] |
Allowed HTTP methods |
CORS_ALLOW_HEADERS |
["*"] |
Allowed HTTP headers |
Production validation: CORS_ORIGINS cannot contain "*" in
ENVIRONMENT=production.
Rate Limiting¶
| Variable | Default | Description |
|---|---|---|
RATE_LIMIT_REQUESTS |
100 |
Maximum requests per period |
RATE_LIMIT_PERIOD |
60 |
Period in seconds |
A worker whose event loop has stopped turning¶
| Variable | Default | Description |
|---|---|---|
EVENT_LOOP_WEDGED_AFTER |
15 |
Seconds the event loop may stop turning before the worker is killed and replaced. 0 or below switches the check off |
A worker that is alive but not answering — deadlocked on a lock, spinning in a
synchronous call, blocked on a socket that never answers — has no exit code, so
every recovery path in every stack used to read it as healthy while requests
timed out. The container goes unhealthy, and a status is not a mechanism.
So the worker judges its own event loop. A timer callback stamps the loop once a
second; a thread reads the stamp, and if the loop has not turned for
EVENT_LOOP_WEDGED_AFTER across two consecutive checks it ends the process —
SIGKILL, or os._exit(137) where the worker is PID 1, because the kernel
delivers no signal to a namespace's init that init has no handler for. Either
way docker inspect reports 137, and "wedged", which nothing handled, becomes
"gone", which every stack already handles:
| Stack | What replaces the worker |
|---|---|
docker-compose.yml |
the reload supervisor, on its next poll |
docker-compose-dev.yml |
PID 1 is the server, so the container exits and restart: unless-stopped acts |
docker-compose-prod.yml |
uvicorn's Multiprocess, within about half a second; the other three workers keep serving |
Two properties are the reason for the design, and both are worth knowing before changing the number:
- It measures liveness, not readiness. The stamp is a timer callback, not a request, so a slow database or a model provider that takes twenty seconds is not a wedge — the loop is turning, it is waiting. An HTTP probe would have been fewer moving parts and would restart-loop a healthy server against a broken dependency.
- Two checks, not one.
docker pause, a frozen cgroup and a laptop waking from sleep stop the watchdog as thoroughly as the loop, so the first check after one reads a stale stamp that says nothing.
The local stack's reload supervisor reads the same variable for the judgement it
makes from outside the worker, so one number covers both. Set it to 0 while
debugging: a breakpoint blocks the event loop and nothing can tell that from a
deadlock, so a worker sitting on one is otherwise killed under you.
It cannot see a process that is not running at all — kill -STOP, a frozen
cgroup — because a watchdog inside a stopped process is stopped too. That case
is the one the supervisors already cover: the reload supervisor's beat goes
stale and production's pipe ping goes unanswered.
Docker / Production¶
| Variable | Default | Description |
|---|---|---|
DOMAIN |
example.com |
Production domain (for Traefik) |
ACME_EMAIL |
admin@example.com |
Let's Encrypt email for SSL certs |
REDIS_PASSWORD |
change-me-in-production |
Redis password for production |
Production Checklist¶
Before deploying to production, ensure these variables are properly set:
1. SECRET_KEY -- Generate a unique 64-character hex key: openssl rand -hex 32
2. API_KEY -- Generate a unique key: openssl rand -hex 32
3. ENVIRONMENT -- Set to production
4. DEBUG -- Set to false
5. POSTGRES_PASSWORD -- Use a strong, unique password
6. CORS_ORIGINS -- List only your actual frontend domain(s)
7. REDIS_PASSWORD -- Set a strong password
8. OPENROUTER_API_KEY -- Your production API key