Skip to content
Get Started

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.

Secrets and keys
  • Replace every REPLACE_THIS_VALUE_IN_PRODUCTION — the full list is in the next section
  • Supply CONFIG_ENCRYPTION_KEY from the environment rather than a .env file on disk
  • Generate JWT_KEY yourself; the one deploy.sh wrote 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 Host header
  • Set MCP_PUBLIC_URL to the public HTTPS origin
  • Narrow the CORS entries in HTTP_HEADERS to origins you own
  • Revisit REQUEST_RATE_LIMITER, and rate-limit anonymous traffic at the proxy
  • Make sure the proxy does not buffer the /api/chat event stream
Data stores
  • A managed PostgreSQL where vector, pg_trgm and pgcrypto are available
  • Apply mirobody/schema/ yourself — a production ENV skips 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: INFO or higher — DEBUG also switches on FastAPI’s debug mode
  • Decide between LOG_NAME + LOG_DIR and capturing the console
  • Point liveness and readiness probes at GET /api/health
  • Deploy and scale mirobody worker separately from mirobody serve
  • Set DEFAULT_TIMEZONE to something your users actually live in

The template ships a literal sentinel string wherever a value cannot have a safe default. Six places carry it:

KeyLocationWhat it is
PG_PASSWORDconfig.yamlThe database password.
PG_ENCRYPTION_KEYconfig.yamlKey for the encrypted columns in the schema.
REDIS_PASSWORDconfig.yamlHas to match whatever the Redis server was started with.
JWT_KEYconfig.yaml, and deploy.sh seeds a random one into config.{env}.yamlHS256 signing key for the access tokens.
POSTGRES_PASSWORDcompose.yaml, the pg serviceInitialises the cluster on first boot; must match PG_PASSWORD.
--requirepasscompose.yaml, the redis commandThe Redis password; must match REDIS_PASSWORD.

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.

config.yaml ships three accounts with a fixed verification code so that a fresh clone can sign in immediately:

config.yaml
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:

config.prod.yaml
EMAIL_PREDEFINE_CODES:

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.

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:

config.prod.yaml
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.

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.

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:

Terminal window
for f in mirobody/schema/*.sql; do
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$f"
done

Note 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.

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 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.

mirobody serve and mirobody worker are packaged the same way and configured the same way, but they should not be one deployment.

  • Only main serves traffic. Worker.start starts 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 BLPOP a 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. SIGINT and SIGTERM set 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.

Terminal window
curl -s https://api.example.com/api/health
example response
{
"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.

Everything that is not a secret, in one file:

config.prod.yaml
LOG_LEVEL: INFO
DEFAULT_TIMEZONE: UTC
HTTP_HOST: 0.0.0.0
HTTP_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.internal
PG_PORT: 5432
PG_USER: mirobody
PG_DBNAME: mirobody
PG_SCHEMA: theta_ai
PG_MIN_CONNECTION: 5
PG_MAX_CONNECTION: 20
REDIS_HOST: redis.internal
REDIS_PORT: 6379
REDIS_SSL: true
S3_REGION: us-west-2
S3_BUCKET: mirobody-prod
S3_PREFIX: uploads/
# Clear the demo accounts the template ships.
EMAIL_PREDEFINE_CODES:

And the secrets, from the environment instead:

Terminal window
export ENV=prod
export 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.