Skip to content

DeploymentDeployment Overview

Deployment Overview

Choose the right deployment strategy for your Courier application — standalone server, framework adapter, single or multi-replica.

4 min read

Deployment Models

Courier supports two deployment models for realtime features (SSE and WebSocket):

ModelBest ForPackages
Framework adapterApps already using Express, Fastify, Hono, or NestJS@noverax/courier-express, -fastify, -hono, -nestjs
Standalone serverNext.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 ReplicaMulti-Replica
WS broadcastNothing to add — the handler delivers to its own connectionscreateRedisPubSub(), passed to the server as pubsub
Stream historycreateMemoryStream(), read by your stream handlerscreateRedisStream(), read by your stream handlers
PresencecreateMemoryPresence(), used by your channel handlerscreateRedisPresence(), used by your channel handlers
Use caseDevelopment, single-process prodK8s, 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

  1. Do you need realtime? (SSE or WebSocket)

    • No → standard apiRoute() / courierHandler() is enough
    • Yes → continue
  2. Which framework?

    • Next.js → sseRoute() for SSE + @noverax/courier-server for WS
    • Express / Fastify / Hono / NestJS → framework adapters handle both SSE and WS natively
    • No framework → @noverax/courier-server standalone
  3. 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 returns 200 { "status": "ok" } (liveness probe)
  • GET /readyz — returns 200 when ready, 503 during 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.

nginx.conf
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:

ts
courierHandler({ allowedOrigins: ["https://app.example.com", "https://*.example.com"] })

Next Steps