Deploy to production¶
What you'll learn
How to ship each mode. The static modes (A/WASM and C/transpile) are just files — host them on any CDN. Mode B (server) is a FastAPI host that needs a reverse proxy, TLS, and (to scale) sticky sessions.
Static modes (A / C) — CDN¶
tempestweb build --mode wasm or --mode transpile produces a static
directory. Serve it from any file host (Netlify, Vercel, S3+CloudFront, GitHub
Pages, nginx). No server, no state — just CDN + cache. The service worker
handles offline.
Mode B (server) — FastAPI¶
The host serves /ws, /sse, /sse/{id} and /health. Before exposing it
publicly, harden it (see Security):
create_app(make_state, view, security=SecurityConfig(
authenticate=jwt_authenticator(os.environ["JWT_KEY"]),
allowed_origins=["https://app.example.com"],
max_connections=1000,
security_headers=True,
hsts=True,
))
Infrastructure blocking WebSocket? Swap the shell for SSE¶
The --mode server artifact ships both Mode B clients —
static/transport-ws.js (the one the generated index.html mounts) and
static/transport-sse.js. When your proxy or CDN won't let a WebSocket through,
replace the artifact's index.html; the same host already answers on /sse and
/sse/{id}, and nothing changes on the Python side:
<script type="module">
import { mount } from "./static/tempestweb.js";
import { createSSETransport } from "./static/transport-sse.js";
const transport = createSSETransport({ session: crypto.randomUUID() });
mount(document.getElementById("app"), transport);
</script>
The session is yours to choose
An SSE session is keyed by the id the client picks — both channels
(GET /sse?session=<id> and POST /sse/<id>) derive from it.
crypto.randomUUID() gives one per tab; keeping it in sessionStorage lets
a reload resume the same session. Across replicas, keep sticky sessions (the
generated nginx.conf already uses ip_hash) or configure the Redis router.
Generate the deploy files (tempestweb deploy)¶
Instead of hand-writing the nginx config, generate it for your project:
Writes to deploy/: nginx.conf (parameterized from the tempestweb.toml
port, with WS upgrade, X-Forwarded-*, streaming timeouts, ip_hash, and — with
--tls — a 443 block + HTTP→HTTPS redirect), Dockerfile (+ HEALTHCHECK),
docker-compose.yml and DEPLOY.md (guide). Flags: --out,
--server-name, --tls, --replicas, --no-sticky, --force.
Docker + reverse proxy (reference)¶
The same files, static, also live in
examples/deploy/:
Dockerfile—python:3.12-slim+tempestweb[server], runstempestweb run --mode server --host 0.0.0.0, with aHEALTHCHECKon/health.nginx.conf— WebSocket upgrade,Origin/X-Forwarded-*preserved, long timeouts +proxy_buffering offfor WS/SSE, andip_hash(sticky).docker-compose.yml— app + nginx (TLS).
Horizontal scale (S4)¶
WebSocket is self-contained (one duplex connection on one replica) → it
scales without stickiness. SSE is the exception: its GET (stream) and
POST (events) must hit the same replica. Two options:
- Sticky sessions (default) —
ip_hashin nginx pins the client;tempestweb deployemits it. - Redis backend (drops stickiness) — route SSE inbound over Redis pub/sub:
from tempestweb.server import create_app, RedisSessionRouter
app = create_app(make_state, view,
sse_backend=RedisSessionRouter.from_url("redis://redis:6379"))
Then generate nginx without ip_hash: tempestweb deploy --no-sticky
(round-robin). Requires the [cache] extra (redis).
Don't use --workers > 1 without stickiness
Each uvicorn worker has its own in-memory session registry. Run 1 worker per container and scale with replicas behind a sticky proxy.
Health checks¶
GET /health (no auth) returns {"status":"ok","sessions":N,"ready":bool}.
ready flips to false when max_connections is reached — use it in your load
balancer's readiness probe to drain a full instance.
Metrics (S8)¶
create_app(..., metrics=True) mounts GET /metrics in Prometheus format:
tempestweb_sessions_live (gauge), tempestweb_sessions_opened_total and
tempestweb_connections_rejected_total (counters), plus tempestweb_sessions_max
when a cap is set. Point your scraper at it.
Slow handler: spawn first, concurrent_dispatch second¶
Each session dispatches one event at a time. That is what guarantees two fast keystrokes arrive in order — and it is also what lets a slow handler freeze that whole connection: no other button of that user responds while it runs.
In the overwhelming majority of cases the answer is not to change dispatch,
it is to move the work out of the handler with
tempestweb.runtime.spawn. It
changes no semantics: event order stays the same, and the task dies with the
connection.
When you genuinely want handlers of different widgets running at the same time:
Each event becomes its own task. Events on the same key still run in
arrival order (there is a per-widget lock), so typing does not scramble;
handlers of different widgets overlap. A handler that raises in this mode is
logged and dropped instead of tearing down the connection.
What the option demands of you
With concurrent_dispatch=True two handlers can mutate the state at the
same time. The app has to be written for that — a set_state that reads the
state, computes and writes is no longer atomic with respect to another
handler. That is why it ships off by default.
Also read the effect on your load limits: once on, every accepted envelope
becomes a task, and max_events_per_minute becomes the ceiling on
concurrent tasks per IP — see
Security → S2.
Recap¶
- A/C:
build→ publish the static directory to a CDN. Done. - B: harden with
SecurityConfig, run behind nginx (TLS + WS upgrade), scale with sticky replicas (1 worker each), watch/health. - Slow handler:
spawnsolves it without changing semantics;concurrent_dispatch=Trueonly when you really want overlap — and then configuremax_events_per_minute.