|
| 1 | +"""Modal deployment for the dploy backend. |
| 2 | +
|
| 3 | +Deploy with: |
| 4 | +
|
| 5 | + cd backend && modal deploy _modal.py |
| 6 | +
|
| 7 | +Architecture |
| 8 | +------------ |
| 9 | +The FastAPI app runs on Modal as a single-instance ASGI app. Single instance |
| 10 | +is intentional: `app.services.sandbox_pool` (the warm Modal-sandbox pool) and |
| 11 | +the WebSocket terminal session registry both live in process memory. Multiple |
| 12 | +containers would split that state and break sandbox lookup mid-deploy. |
| 13 | +
|
| 14 | +Throughput is gated by `@modal.concurrent(max_inputs=100)` — one container |
| 15 | +serves up to 100 concurrent HTTP/WS requests, plenty for current load. |
| 16 | +
|
| 17 | +State |
| 18 | +----- |
| 19 | +A Modal Volume is mounted at `/data` and used for the SQLite database. The |
| 20 | +Secret sets `DATABASE_URL=sqlite+aiosqlite:////data/app.db` so the URL |
| 21 | +survives container restarts/redeploys. (Switch to managed Postgres later if |
| 22 | +you need backups, replicas, or zero-downtime deploys.) |
| 23 | +
|
| 24 | +Note: `backend/uploads/` is currently in-container only. It's not wired up |
| 25 | +to the deploy flow yet (`upload-based deployments not yet supported`), so |
| 26 | +losing it on restart is a no-op for now. When uploads ship, repoint |
| 27 | +`UPLOAD_DIR` at `/data/uploads`. |
| 28 | +
|
| 29 | +Modal credentials |
| 30 | +----------------- |
| 31 | +This function runs *inside* Modal, so `modal.Sandbox.create()` authenticates |
| 32 | +implicitly via the workspace identity — no `MODAL_TOKEN_*` env vars needed. |
| 33 | +
|
| 34 | +Secrets (one-time setup) |
| 35 | +------------------------ |
| 36 | +Create a Modal Secret named `dploy-backend` before the first deploy: |
| 37 | +
|
| 38 | + modal secret create dploy-backend \\ |
| 39 | + ANTHROPIC_API_KEY=... \\ |
| 40 | + GITHUB_CLIENT_ID=... \\ |
| 41 | + GITHUB_CLIENT_SECRET=... \\ |
| 42 | + SESSION_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')" \\ |
| 43 | + FRONTEND_URL=https://dploy.ryantanen.com \\ |
| 44 | + GITHUB_REDIRECT_URI=https://api.dploy.ryantanen.com/api/v1/auth/github/callback \\ |
| 45 | + CORS_ORIGINS='["https://dploy.ryantanen.com"]' \\ |
| 46 | + SESSION_COOKIE_SECURE=true \\ |
| 47 | + DATABASE_URL=sqlite+aiosqlite:////data/app.db |
| 48 | +""" |
| 49 | + |
| 50 | +import modal |
| 51 | + |
| 52 | +app = modal.App(name="dploy-backend") |
| 53 | + |
| 54 | +# Image build: |
| 55 | +# 1. `pip_install_from_pyproject` reads ./pyproject.toml at deploy time and |
| 56 | +# installs the runtime deps. Cached across deploys unless pyproject changes. |
| 57 | +# 2. `add_local_python_source("app")` ships the `app/` package (including |
| 58 | +# non-Python files like openclaw.json) into the image at deploy time. |
| 59 | +# This is the bit that was missing — without it, `from app.main import |
| 60 | +# create_app` fails with ModuleNotFoundError. |
| 61 | +image = ( |
| 62 | + modal.Image.debian_slim(python_version="3.13") |
| 63 | + .pip_install_from_pyproject("pyproject.toml") |
| 64 | + .add_local_dir("app", remote_path="/root/app") |
| 65 | +) |
| 66 | + |
| 67 | +volume = modal.Volume.from_name("dploy-backend-data", create_if_missing=True) |
| 68 | + |
| 69 | + |
| 70 | +@app.function( |
| 71 | + image=image, |
| 72 | + secrets=[modal.Secret.from_name("dploy-secrets")], |
| 73 | + volumes={"/data": volume}, |
| 74 | + # Pin to one container — sandbox_pool + WS terminal sessions are in-memory. |
| 75 | + min_containers=1, |
| 76 | + max_containers=1, |
| 77 | + # WebSocket terminal sessions can stay open for a long time. |
| 78 | + timeout=60 * 60, |
| 79 | + # Don't aggressively idle the container out — keeps the warm sandbox |
| 80 | + # pool alive between bursts of traffic. |
| 81 | + scaledown_window=60 * 30, |
| 82 | +) |
| 83 | +@modal.concurrent(max_inputs=100) |
| 84 | +@modal.asgi_app() |
| 85 | +def fastapi_app(): |
| 86 | + from app.main import create_app |
| 87 | + return create_app() |
0 commit comments