Deploy
Production Deployment
What has to change between a deploy.sh stack and a production one: placeholders, secrets, the demo logins, CORS, rate limits, the public URL, and running the worker on its own.
Production runs the same two processes as a laptop — mirobody serve for HTTP and mirobody worker for the queues — against the same three configuration layers. What changes is everything the repository ships as a convenient default: placeholder credentials, a demo account, the demo seed, and a rate limit tuned for one person clicking around. This page is the delta from a ./deploy.sh stack, key by key.
Declare the production posture
Section titled “Declare the production posture”Production is a switch, not an environment name: naming your ENV prod changes nothing by itself. Two keys declare the posture, and deploy.sh writes both into the generated override as comments so they are one uncomment away:
PRODUCTION: trueBOOTSTRAP_SCHEMA: falsePRODUCTION: truedeclares that this deployment faces real users. The server then refuses to start whileEMAIL_PREDEFINE_CODESis non-empty or any config value still readsREPLACE_THIS_VALUE_IN_PRODUCTION, and it skips the demo seed — the demo conveniences must be removed deliberately rather than forgotten.BOOTSTRAP_SCHEMA: falseturns off the boot-time replay ofmirobody/schema/. A real deployment provisions the schema ahead of time (see Data stores); the defaulttrueis for the one-command local run.
An ENV whose name contains prod with the switch unset only earns a startup warning — the behaviour stays the demo posture.
Production checklist
Section titled “Production checklist”Posture and secrets
- Set
PRODUCTION: true— the server then enforces the next two items - Replace every
REPLACE_THIS_VALUE_IN_PRODUCTION— the full list is in the next section - Clear
EMAIL_PREDEFINE_CODES - Supply
CONFIG_ENCRYPTION_KEYfrom the environment rather than a.envfile on disk - Generate
JWT_KEYyourself; the onedeploy.shwrote came from the shell’s$RANDOM - Set
PG_ENCRYPTION_KEY, and keep it out of the config file
Networking
- Terminate TLS in front of the engine and forward the public
Hostheader - Set
MCP_PUBLIC_URLto the public HTTPS origin - Narrow the CORS entries in
HTTP_HEADERSto origins you own - Revisit
REQUEST_RATE_LIMITER, and rate-limit anonymous traffic at the proxy - Make sure the proxy does not buffer the
/api/chatevent stream
Data stores
- A managed PostgreSQL where
vector,pg_trgmandpgcryptoare available - Set
BOOTSTRAP_SCHEMA: falseand applymirobody/schema/yourself - Redis with a password, and a persistence and eviction policy you chose deliberately
- Configure
S3_*instead of writing uploads to a container-local directory - Automate backups and rehearse a restore
Operations
-
LOG_LEVEL: INFOor higher —DEBUGalso switches on FastAPI’s debug mode - Decide between
LOG_NAME+LOG_DIRand capturing the console - Point liveness and readiness probes at
GET /api/health - Deploy and scale
mirobody workerseparately frommirobody serve - Set
DEFAULT_TIMEZONEto something your users actually live in
Placeholders you must replace
Section titled “Placeholders you must replace”The template ships a literal sentinel string wherever a value cannot have a safe default. Six places carry it:
| Key | Location | What it is |
|---|---|---|
PG_PASSWORD | config.yaml | The database password. |
PG_ENCRYPTION_KEY | config.yaml | Key for the encrypted columns in the schema. |
REDIS_PASSWORD | config.yaml | Has to match whatever the Redis server was started with. |
JWT_KEY | config.yaml, and deploy.sh seeds a random one into config.{env}.yaml | HS256 signing key for the access tokens. |
POSTGRES_PASSWORD | compose.yaml, the pg service | Initialises the cluster on first boot; must match PG_PASSWORD. |
--requirepass | compose.yaml, the redis command | The Redis password; must match REDIS_PASSWORD. |
Secrets and the encryption key
Section titled “Secrets and the encryption key”Any config key whose name matches _KEY, _PASSWORD, _PASS, _PWD, _SECRET, _SK or _TOKEN — and does not end in _URL — is encrypted when the file is loaded, and the YAML file is rewritten in place with the ciphertext. So a password you paste into config.{env}.yaml becomes a gAAAA… string after the first start. That is by design, and it means the process needs write access to that file.
The Fernet key is derived from CONFIG_ENCRYPTION_KEY alone: the value is trimmed, truncated to 32 characters, right-padded with 0 to 32 bytes, then base64-url-encoded. Two consequences follow. Only the first 32 characters ever matter, so a longer key buys nothing; and a shorter key is silently padded, so aim for exactly 32.
Values are read in this order, later layers overriding earlier ones: the repository template config.yaml, then a remote config document if CONFIG_SERVER and CONFIG_TOKEN are set, then your config.{env}.yaml. Environment variables win over all three. .env is loaded with setdefault, so a real environment variable also beats the file — which is what makes the same image work across environments.
Turn off the demo logins
Section titled “Turn off the demo logins”config.yaml ships one account with a fixed verification code so that a fresh clone can sign in immediately:
EMAIL_PREDEFINE_CODES: caregiver@mirobody.ai: '111111'Later layers override earlier ones by key, so declaring the key with no value in your override clears it — the loader falls back to an empty mapping when the value is not a dictionary:
EMAIL_PREDEFINE_CODES:Logging and the debug flag
Section titled “Logging and the debug flag”config.yaml ships LOG_LEVEL: INFO, and the level does more than control verbosity. Server.start constructs the app as FastAPI(debug = config.log.level <= logging.DEBUG, …), so setting DEBUG also switches FastAPI into debug mode.
Logs go to the console unless you set both LOG_NAME and LOG_DIR, in which case files are written as {date}_{name}_{time}.log in that directory. On a container platform, leaving them unset and collecting the console is usually the better choice — the compose file already sets PYTHONUNBUFFERED=1 so lines are not held back.
CORS and rate limiting
Section titled “CORS and rate limiting”The HTTP_HEADERS block is entirely commented out in the template, and its example origin is http://localhost:18080. If you leave it unset, no CORS middleware is added at all — fine when the bundled SPA is served from the same origin, but a separately hosted front end will be blocked. Exactly five keys are read out of the block:
HTTP_HEADERS: Access-Control-Allow-Origin: 'https://app.example.com' Access-Control-Allow-Credentials: 'true' Access-Control-Allow-Methods: 'GET, POST, PUT, DELETE, OPTIONS' Access-Control-Allow-Headers: 'Authorization, Content-Type' Access-Control-Max-Age: '600'Access-Control-Allow-Origin is passed through as a single origin, not a list. Pairing * with credentials is rejected by browsers, and the engine logs a warning if you try.
REQUEST_RATE_LIMITER maps a URL path to a per-minute budget; the template covers /api/chat and /api/session (6 each) plus the sign-in paths /password/login (10), /password/register (5), /email/login (5) and /email/verify (10). The implementation is a Redis INCR on limit:{user_id}:{path} with a 60-second expiry set on the first hit, returning 429 with Retry-After set to the key’s remaining TTL once the threshold is passed.
Public URL, TLS and the reverse proxy
Section titled “Public URL, TLS and the reverse proxy”The engine serves plain HTTP on HTTP_HOST:HTTP_PORT through uvicorn; TLS terminates in front of it.
MCP_PUBLIC_URL is the publicly reachable HTTPS origin, and it does more than appear in a banner. When no object store is configured, LocalStorage builds its file URLs as {MCP_PUBLIC_URL}/files — with the key unset, that base is empty. It is also the origin remote MCP clients are pointed at, so set it to the address the outside world uses, not the container’s.
/api/chat is a Server-Sent Events stream. The engine already sends cache-control: no-cache, no-transform and x-accel-buffering: no on that response — make sure your proxy honours them rather than buffering the stream into a single reply.
Data stores
Section titled “Data stores”PostgreSQL
Section titled “PostgreSQL”The schema needs three extensions, created by mirobody/schema/00_init_schema.sql: vector, pg_trgm and pgcrypto. Either grant the role the rights to create them or have them installed ahead of time; vector in particular is load-bearing, since the indicator tables declare vector(1024) columns with HNSW indexes.
The files are executed in filename order, so applying them by hand is a sorted pass:
for f in mirobody/schema/*.sql; do psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$f"doneNote that the engine’s own bootstrap logs and rolls back a failing file and then continues to the next one, so with the replay left on a partially applied schema is easy to miss — ON_ERROR_STOP=1 above is deliberately stricter.
PG_SCHEMA defaults to theta_ai; the bootstrap splits the value on commas and creates each schema it names. Pool sizing is PG_MIN_CONNECTION (5) and PG_MAX_CONNECTION (20) — per process, and there are two processes, so budget the server’s connection limit for both.
Redis is not optional in a normal deployment. Three things use it: the rate limiter’s counters, the worker’s task queues, and the locks and temporary OAuth state in the provider pull path.
The compose Redis is configured for a laptop — --maxmemory 512mb --maxmemory-policy noeviction, with the append-only file in a named volume. Size the memory ceiling for production deliberately, and keep noeviction (or an equivalent) in mind: the queues and locks carry no TTL, so a policy that evicts under pressure would silently discard queued work.
For a managed endpoint, REDIS_SSL, REDIS_SSL_CHECK_HOSTNAME and REDIS_SSL_CERT_REQS control the TLS side; the template ships them off. A second, separate connection can be declared by suffixing the same keys with _LOG.
Object storage
Section titled “Object storage”get_storage_client() tries each cloud backend in turn and falls back to local disk. The S3 backend needs all four of S3_KEY, S3_TOKEN, S3_REGION and S3_BUCKET — if any is missing it raises, and the factory quietly falls through to LocalStorage. S3_PREFIX and S3_CDN are optional.
That silent fallback is the thing to watch: with an incomplete S3 block, uploads land in ./.theta/mcp/upload/ inside the container. Behind more than one replica, a file written by one instance is then invisible to the others.
Default timezone
Section titled “Default timezone”DEFAULT_TIMEZONE is the fallback for a user who has not set one; both the template and the code default to America/Los_Angeles. A request can carry its own timezone, and a stored user record can too, so this value only decides what happens when neither does — which, for server-side jobs and for the first turn of a new account, is often.
Deploy the worker separately
Section titled “Deploy the worker separately”mirobody serve and mirobody worker are packaged the same way and configured the same way, but they should not be one deployment.
- Only
mainserves traffic.Worker.startstarts no uvicorn and registers no routes, so a worker replica behind your load balancer would be a black hole. - They scale on different signals. HTTP capacity follows request concurrency; queue capacity follows ingest volume. A bulk import can need many workers and no extra HTTP capacity, and a traffic spike is the reverse.
- Extra replicas divide the work. Consumers
BLPOPa shared Redis list per task class, so additional worker replicas take from the same queue instead of duplicating it. Task discovery is automatic — every registered task gets its own consumer loop, and there are two today: indicator sync and profile refresh. - Give it time to drain.
SIGINTandSIGTERMset the stop events and the loops finish their current batch, so the termination grace period should exceed a typical task rather than truncate it.
For monitoring: an idle consumer logs a heartbeat every ten minutes naming its queue, and a task class can declare a maximum queue length, in which case enqueueing raises once the queue is at capacity — treat that as the signal that consumers are falling behind.
Health checks
Section titled “Health checks”curl -s https://api.example.com/api/health{ "service": "mirobody", "version": "…", "tools": 4, "public_tools": 2, "authenticated_tools": 2, "resources": 2, "agents": 2}The endpoint is unauthenticated and answers from counters gathered at startup, without touching PostgreSQL or Redis. That makes it a good liveness probe and a poor readiness probe for the data stores — it will keep returning 200 while the database is unreachable. The counts are useful in their own right: if the extension directories you configured did not load, the tool and agent numbers say so immediately.
The worker has no HTTP surface at all. Supervise it by process liveness and by queue depth in Redis.
An example production override
Section titled “An example production override”Everything that is not a secret, in one file:
PRODUCTION: trueBOOTSTRAP_SCHEMA: false
LOG_LEVEL: INFODEFAULT_TIMEZONE: UTC
HTTP_HOST: 0.0.0.0HTTP_PORT: 18060
HTTP_HEADERS: Access-Control-Allow-Origin: 'https://app.example.com' Access-Control-Allow-Credentials: 'true' Access-Control-Allow-Methods: 'GET, POST, PUT, DELETE, OPTIONS' Access-Control-Allow-Headers: 'Authorization, Content-Type' Access-Control-Max-Age: '600'
MCP_PUBLIC_URL: 'https://api.example.com'
REQUEST_RATE_LIMITER: /api/chat: 30 /api/session: 30
PG_HOST: pg.internalPG_PORT: 5432PG_USER: mirobodyPG_DBNAME: mirobodyPG_SCHEMA: theta_aiPG_MIN_CONNECTION: 5PG_MAX_CONNECTION: 20
REDIS_HOST: redis.internalREDIS_PORT: 6379REDIS_SSL: true
S3_REGION: us-west-2S3_BUCKET: mirobody-prodS3_PREFIX: uploads/
# Clear the demo account the template ships (PRODUCTION: true enforces this).EMAIL_PREDEFINE_CODES:And the secrets, from the environment instead:
export ENV=prodexport CONFIG_ENCRYPTION_KEY="…"export PG_PASSWORD="…"export PG_ENCRYPTION_KEY="…"export REDIS_PASSWORD="…"export JWT_KEY="…"export S3_KEY="…"export S3_TOKEN="…"export GOOGLE_API_KEY="…"Note that HTTP_HOST and HTTP_PORT are in the file here for a deployment that reads YAML. Under the shipped compose.yaml those two arrive as container environment variables, which take priority — so there you change them in the compose file instead.
Next steps
Section titled “Next steps”The image, the four services, and the volumes underneath them
Every key group, and what the three layers are for
The two processes, the middleware stack, and the route families
What MCP_PUBLIC_URL opens up once it is reachable