Deployment Models
Courier supports two deployment models for realtime features (SSE and WebSocket):
| Model | Best For | Packages |
|---|---|---|
| Framework adapter | Apps already using Express, Fastify, Hono, or NestJS | @noverax/courier-express, -fastify, -hono, -nestjs |
| Standalone server | Next.js (WS needs separate process), dedicated realtime service | @noverax/courier-server |
Next.js limitation
Next.js App Router cannot handle WebSocket upgrades. Use sseRoute() for SSE, and run a separate @noverax/courier-server for WebSocket support.
Single vs Multi-Replica
| Single Replica | Multi-Replica | |
|---|---|---|
| WS broadcast | Nothing to add — the handler delivers to its own connections | createRedisPubSub(), passed to the server as pubsub |
| Stream history | createMemoryStream(), read by your stream handlers | createRedisStream(), read by your stream handlers |
| Presence | createMemoryPresence(), used by your channel handlers | createRedisPresence(), used by your channel handlers |
| Use case | Development, single-process prod | K8s, Docker Swarm, any horizontal scaling |
A single process needs no adapter to broadcast. The stream and presence adapters are building blocks your handlers call — the server does not read or write them on its own — and the Redis ones share their state across replicas. Redis Adapters shows a stream that resumes from them and a room that tracks who is in it.
Decision Tree
-
Do you need realtime? (SSE or WebSocket)
- No → standard
apiRoute()/courierHandler()is enough - Yes → continue
- No → standard
-
Which framework?
- Next.js →
sseRoute()for SSE +@noverax/courier-serverfor WS - Express / Fastify / Hono / NestJS → framework adapters handle both SSE and WS natively
- No framework →
@noverax/courier-serverstandalone
- Next.js →
-
How many replicas?
- Single → no pub/sub adapter; memory stream and presence adapters where your handlers need them
- Multiple → Redis adapters + sticky sessions
Health Checks
Every Courier server (standalone or framework adapter) provides two health endpoints:
GET /health— always returns200 { "status": "ok" }(liveness probe)GET /readyz— returns200when ready,503during shutdown (readiness probe)
Use these with Kubernetes, Docker health checks, or load balancer configuration.
Logs
Courier writes info and debug lines to stdout and warn and error lines to stderr, so a log router that tells the two streams apart sees Courier's failures as errors. A failure carries the error's stack and cause chain. COURIER_LOG_LEVEL sets the lowest level printed — debug, info (the default), warn, error or silent — and a container's log gets plain text with the level as a word, since colour is written only to a terminal (FORCE_COLOR=1 keeps it, NO_COLOR removes it everywhere). For a log pipeline — Datadog, Loki, Elasticsearch, Cloud Logging — set COURIER_LOG_FORMAT=json: each line is then one JSON object with time, level, scope, pid, message and, for a failure, error with its stack and cause chain.
A browser that cancels a call is not a failure, and is logged at debug: an upload it stopped sending, a query it stopped waiting for, a stream it stopped reading. The proxy stops its upstream request and answers such a call with status 499, which only an access log ever sees.
Behind a Reverse Proxy
The proxy route refuses a request another site's page made — the check that stops another site's page from calling your API with your users' cookies. A current browser says where a request comes from in Sec-Fetch-Site, and where it does, that decides, whatever name the app thinks it has. Where it does not — an older browser, or a site served over plain HTTP — the page's Origin is compared with the origin the request was sent to. Behind nginx, a cloud load balancer or a CDN that terminates TLS, the app itself sees plain HTTP, and often the upstream's name in Host, so it needs the proxy to say what the browser used.
The same name decides which host the request was addressed to, which every route checks against allowedHosts to refuse a DNS-rebinding page with 421. Production serves every name until allowedHosts lists them; list the public names your proxy routes to the app to refuse the rest as well — see Host validation.
The Node adapters (Express, Fastify, NestJS and the standalone server) read X-Forwarded-Proto and X-Forwarded-Host — but only from a proxy on a loopback or private address (127.0.0.1, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7). A caller on a public address cannot claim another site's name with them. Next.js reports every route's URL on the host it was started with, so its adapter reads X-Forwarded-Host and X-Forwarded-Proto, which Next fills from Host when no proxy did, as Next's own Server Actions do. SvelteKit and the other Web-standard adapters take the URL from their framework, which applies its own proxy settings.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}When the proxy reaches the app from a public address, or pages on other origins call the API on purpose, list those origins instead:
courierHandler({ allowedOrigins: ["https://app.example.com", "https://*.example.com"] })Next Steps
- Standalone Server — full API and CLI reference
- Redis Adapters — multi-replica setup
- Replication Guide — per-framework examples
- Docker — Dockerfile, Compose, and Swarm
- Kubernetes — manifests, ingress, HPA
- Cloudflare Workers — edge deployment