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"] })The user's address
An API a call goes to through your server sees your server's address. Courier tells it the user's in X-Forwarded-For, as every proxy does: the chain the proxies in front recorded, with the address of the one that connected appended — the same header nginx's $proxy_add_x_forwarded_for writes. A caller that connects from a public address is the whole chain, since what it claims about addresses before it is its own word; a connection from this machine or a private network is a proxy, and its chain is kept. Next.js, React Router and edge functions do not report the connection: there the chain goes on as the proxy in front, or Next.js itself where none is, recorded it. Each API reads the user's address from the chain with its own trust, as it reads any proxied request's.
Courier counts a user's connections by one address — the WebSocket limit per address, the limits you set per caller. It reads that address from the right of the chain, past this machine and private networks, since each proxy appends what it saw and an address a user forges stands left of it. A DDoS filter, a CDN or a load balancer on public addresses is one more proxy to read past: name its addresses and ranges, and the header it names the user in, if it has one.
COURIER_TRUSTED_PROXIES=192.0.2.0/24,2001:db8:aa00::/40
COURIER_CLIENT_ADDRESS_HEADER=DDG-Connecting-IPCourier believes that header only on a request one of those proxies passed on — the nearest address on the chain past your own network is one of theirs, or they connected themselves. Name only your own proxies and your provider's published ranges: Courier believes whatever address they report. An entry that is neither an address nor a range, or a range of every address, is left out, and the log says so once.
An API that reads the user's address from one header — X-Real-IP, X-Client-IP, one of its own — or believes the first address of the chain, as the request-ip library and many others do, can be sent the address Courier reads, alone. Name its headers in forward.clientAddress, and the request's id under every header the API logs it by in forward.requestId:
defineApi({
baseURL: process.env.API_URL,
forward: {
clientAddress: ["x-client-ip", "x-real-ip"],
requestId: ["x-request-id", "x-correlation-id"],
},
endpoints,
})X-Forwarded-For still carries the chain; name x-forwarded-for too to send the address alone there. A user can write a chain of their own, and a proxy that appends keeps it in front of the address it saw: an API that believes the chain's first address then names whoever the user said, and one sent the address alone does not.
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