Deployment
Production runs the same two processes as development — the backend and the ajs dms frontend (Architecture) — but each is built once and launched from its build artifacts instead of the dev tooling. The steps below are the production-specific ones; the pages linked from each section own the details.
Build and run the backend
Build the project once, then launch from the artifacts:
ajs project build
ajs project start
ajs project start launches from the build output and skips module download and graph validation. ajs project dev (and ajs project run, its legacy alias) runs the project in dev mode even without -w — so don't use either as a production launcher.
Use production values in your antelope.config.ts: real secrets (see the checklist below), Redis for realtime, durable file storage, and your SMTP settings. Every key and default is in Configuration.
Build and run the frontend
The frontend is built from the project itself, with no backend running: ajs dms build evaluates antelope.config.ts with the backend's environment rules, takes each enabled module's frontend from node_modules/<module>/frontend-vue/, and computes its options with the function the module declares as dmsFrontend in its package.json, the one the backend calls too:
ajs dms build -e production # in the project, next to antelope.config.ts
export DMS_BOOTSTRAP_SECRET=… # same value as the backend's frontend.bootstrapSecret
ajs dms start -b https://api.example.com -p 3001
The variables the options read (DMS_API_PUBLIC_BASE_URL, DMS_CLIENT_BASE_URL, a Stripe publishable key…) must hold the same values for the build and for the backend. Build and deploy both from the same commit: once ready, the frontend server reports each module's version to the backend, which logs a warning on any difference.
start does not auto-discover the backend: pass -b or set DMS_API_BASE_URL. Flags and workspace behavior are in the Frontend CLI reference.
Set DMS_SESSION_SECRET to a stable secret of at least 32 characters on every frontend instance. Keep the complete generated build, including dist/server/locales/: the email renderer reads its merged translation catalogs from these JSON files. Copying only the email JavaScript files breaks localized rendering.
DMS_BOOTSTRAP_SECRET is only needed by start, which presents it when it reports what it serves. The private options (DMS_HTML_RENDER_SECRET, the OAuth relay secret) are read from the server's environment at start, never at build.
Pass it through the environment rather than --bootstrap-secret, and note that a credential read from a file keeps its trailing newline — ajs dms rejects a value that cannot travel in an HTTP header rather than letting fetch fail obscurely.
Startup order
The two processes start in any order: the frontend asks the backend nothing to build or to start, and answers a page with the backend's own error until it is up.
Rebuild the frontend whenever the layer set changes — you add or remove a module that calls AddFrontendModule, or you change a layer's code. Backend-only changes (pages, components, data) need no frontend rebuild: pages are data the frontend draws.
Production checklist
The defaults are development values. Set each of these before going live:
| Key | Default | Production requirement |
|---|---|---|
auth.jwtSecret (DMS config) | "" | A strong random value — it signs every session token. The DMS refuses to start in production when it is empty or "dev". |
htmlRender.serviceSecret (DMS config) | "dev" | A strong random value — it signs the render-service tokens. The DMS refuses to start in production when it is empty or "dev". |
frontend.bootstrapSecret (DMS config) | — | A strong random value — setting it gates the dev manifest and the frontend's ready report. Also set it as DMS_BOOTSTRAP_SECRET on the frontend server. |
secret (the @antelopejs/auth-jwt module's config) | — | A strong random value, independent of auth.jwtSecret. |
apiBaseUrl / clientBaseUrl | localhost | Your public backend and dashboard URLs — generated links (invite emails, signup) use them, and modules read them through GetApiBaseUrl() / GetClientBaseUrl(). clientBaseUrl is required in production and must be an http(s) origin; the DMS adds it to the api module's allowed CORS origins, next to the ones configured there. |
htmlRender.renderEndpoint | localhost | The production frontend's /api/html/render URL, reachable from the backend. |
Serve the dashboard over HTTPS and HTTP/2 (or HTTP/3) to browsers, typically through a reverse proxy terminating TLS: see HTTP/2 and long-lived streams.
All of these live in the DMS config block except the auth-jwt secret, which belongs to that module's own entry — Configuration documents each key. Transactional email also needs an interface-email implementation (typically @antelopejs/nodemailer) wired in the config, or invites, validation, and password resets fail (Backend services).
HTTP/2 and long-lived streams
Each signed-in browser keeps one realtime stream open to the origin it reaches the backend through (the dashboard's own, when the frontend server proxies /api), shared by all of its tabs (Realtime & notifications). Over HTTP/1.1 that stream holds one of the six connections a browser opens per host for as long as it lives, so:
- Serve the dashboard over HTTPS with HTTP/2 at the reverse proxy. HTTP/2 multiplexes every request and stream on one connection, and the six-connection limit no longer applies. The proxy can keep talking HTTP/1.1 to the frontend server.
- Serve it over HTTPS in any case: the tabs share their stream through Web Locks, which browsers only offer in a secure context (HTTPS, or
localhost). Over plain HTTP on another host name each tab opens a stream of its own, and a few tabs exhaust the connections. - Let the stream through unbuffered. The backend sends
X-Accel-Buffering: no(for nginx) and a keepalive every 20 seconds; a proxy idle timeout shorter than that drops the stream, which the browser then reopens. - Forward the
x-realtime-connectionandx-realtime-sessionheaders. A tab names its connection inx-realtime-connectionand its session inx-realtime-session. The dashboard's frontend server relays both by name to the backend; a proxy of your own between the browser and the backend must forward them too, or every session request answersok: false: the browser keeps reopening its connection, with a growing delay, and live pages fall back to polling.
Multiple instances
Running more than one backend instance requires two things: Redis for realtime, and affinity on /api/realtime/*.
Redis for realtime
The in-memory driver does not cross process boundaries: events, presence, and notification streams stay on one instance. Load @antelopejs/redis and keep realtime.driver at "auto" or set "redis" (Configuration). With Redis, an event published on any instance reaches every browser, whichever instance holds its stream.
Affinity on /api/realtime/*
Redis carries the events, not the connections. The registry of a browser's realtime connection and of the sessions its tabs open on it stays in the memory of the instance that serves the stream:
GET /api/realtime/connectionopens the browser's SSE stream on one instance, which registers the connection and greets it with its id.- Each tab then sends
POST /api/realtime/sessionwith that id inx-realtime-connection. Only the instance holding the stream knows the connection: any other answersok: false. - The tab's later requests (
/session/close,/subscribe/:pageId,/unsubscribe,/acquire,/release) read the same registry.
A browser's realtime requests must therefore all reach the instance that serves its stream. Without affinity, a session request lands on another instance, gets ok: false, and the browser reopens its connection with a growing delay: live pages fall back to polling and presence keeps resetting.
Route /api/realtime/* with affinity at the balancer in front of the backend instances. Other /api routes need none.
- Through the dashboard's frontend server (the default, when it proxies
/api), hash on the client address. The frontend server writes the browser's address inX-Forwarded-Foron every backend request (Routing and proxy), the same for the stream and for every session request of that browser. It never relays the browser'sCookieorAuthorization, so a balancer cookie set on the backend side never comes back. Do not hash onAuthorizationeither: the access token changes at each refresh, while the stream stays open. - Browsers that reach the backend directly can use a balancer cookie instead, or the same hash on the client address.
With nginx:
upstream dms_backend {
server backend-1:5010;
server backend-2:5010;
}
upstream dms_backend_realtime {
hash $http_x_forwarded_for consistent;
server backend-1:5010;
server backend-2:5010;
}
server {
location /api/realtime/ {
proxy_pass http://dms_backend_realtime;
}
location / {
proxy_pass http://dms_backend;
}
}
On Kubernetes, a Service's sessionAffinity: ClientIP sees the frontend server's address, not the browser's: every browser behind one frontend pod would land on one backend pod. Hash on X-Forwarded-For at the ingress or proxy in front of the backend instead. With ingress-nginx, that is the nginx.ingress.kubernetes.io/upstream-hash-by: "$http_x_forwarded_for" annotation on an Ingress that serves only /api/realtime.
When a browser's address changes, or an instance goes away, its requests move to another instance. The session request then answers ok: false, and the browser reopens its connection there: the affinity only has to hold for as long as a stream lives.
realtime.driver: "auto"), so most production stacks include it from the start.