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, three demo accounts, a debug log level, and a rate limit tuned for one person clicking around. This page is the delta from a ./deploy.sh stack, key by key.
Production checklist
Section titled “Production checklist”Secrets and keys
- Replace every
REPLACE_THIS_VALUE_IN_PRODUCTION— the full list is in the next section - 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 - Clear
EMAIL_PREDEFINE_CODES
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 - Apply
mirobody/schema/yourself — a productionENVskips the built-in bootstrap - 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 three accounts with a fixed verification code so that a fresh clone can sign in immediately:
EMAIL_PREDEFINE_CODES: exp1@mirobody.ai: '111111' exp2@mirobody.ai: '111111' exp3@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: DEBUG, and the level does more than control verbosity. Server.start constructs the app as FastAPI(debug = config.log.level <= logging.DEBUG, …), so a DEBUG level 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 defaults are /api/chat: 6 and /api/session: 6. 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 on a non-production ENV 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 allkeys-lru, and no volume, so append-only persistence writes into the container’s own filesystem. Both deserve a decision in production: allkeys-lru will evict any key under pressure, including a queued task or a pending OAuth handshake.
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 and charts in ./.theta/mcp/charts. 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": 12, "public_tools": 4, "authenticated_tools": 8, "resources": 3, "agents": 3}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:
LOG_LEVEL: INFODEFAULT_TIMEZONE: UTC
HTTP_HOST: 0.0.0.0HTTP_PORT: 18080
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 accounts the template ships.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