Skip to content

Deployment

A practical checklist for taking a jmaple instance from development to production.

Process model

jmaple.app:app is a stock FastAPI application. Run it under any ASGI server:

gunicorn jmaple.app:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000

Workers are independent — each holds its own in-process PushBroker and SQLAlchemy connection pool. State that needs to be shared lives in the database; everything else is per-worker.

Database

PostgreSQL via asyncpg is the recommended driver:

JMAPLE_DATABASE_URL=postgresql+asyncpg://jmaple:secret@db.internal/jmaple

Run migrations as part of your deploy pipeline:

uv run jmaple migrate upgrade head

Never start the server without applying pending migrations first.

Blob storage

Default is the filesystem at JMAPLE_BLOB_DIR. For multi-instance deployments, swap jmaple.blobs.store.BlobStore with an S3-backed implementation. The protocol is three methods:

class BlobStore(Protocol):
    async def put(self, account_id: str, blob_id: str, stream: AsyncIterator[bytes]) -> tuple[str, int, str]: ...
    async def open(self, storage_key: str) -> AsyncIterator[bytes]: ...
    async def delete(self, storage_key: str) -> None: ...

Override jmaple.blobs.deps.get_blob_store() to return your implementation.

Reverse proxy

Specifics for Nginx-like proxies:

  • /jmap/eventsource — long-lived text/event-stream. Disable response buffering: proxy_buffering off;.
  • /jmap/ws — WebSocket upgrade. Pass Upgrade / Connection headers, bump proxy_read_timeout.
  • /jmap/upload/* — POSTs of arbitrary size up to JMAPLE_CORE_CAPABILITY__MAX_SIZE_REQUEST. Make sure client_max_body_size matches.

Observability

  • Python's logging module — wire up to your usual handlers.
  • Set JMAPLE_DEBUG=true for verbose request/response logs in dev only.
  • Every method call writes a structured line with the method name and call id.

Push

The default in-process PushBroker is single-instance. For multi-worker / multi-instance setups, you have three options:

  1. Sticky sessions on the load balancer — SSE/WS clients always hit the same worker. Simplest; doesn't help webhooks.
  2. Webhook-only — backend services subscribe via PushSubscription/set, skip SSE/WS entirely. Works across any number of workers.
  3. Replace the broker — implement a Redis-backed PushBroker and patch jmaple.push.broker.get_broker() to return it. Webhooks and SSE/WS keep working unchanged.

Health checks

There's no built-in /healthz — the JMAP session resource is the de-facto liveness probe. A 401 means the server is up but unauthenticated; a 200 means everything's healthy.

For a stricter check, hit /.well-known/jmap with a known-good bearer token from your monitoring pipeline.