Backend & API Gateway

How Core API, plugin APIs, and the gateway compose one federated GraphQL endpoint.

Complete Local Setup before running these services. For deployment images, see Self-Hosting.

Services and ports

Service Project Port Notes
Gateway backend/gateway 4000 (PORT) Public API entry point
Core API backend/core-api 3300 (PORT) core subgraph + REST routes
Logs service backend/services/logs 3301 (PORT) BullMQ workers, no subgraph
Automations service backend/services/automations 3302 (PORT) BullMQ workers + tRPC
Plugin APIs backend/plugins/*_api 3303-3313 e.g. sales 3305, frontline 3304, operation 3307
Apollo Router spawned by the gateway 127.0.0.1:50000 (APOLLO_ROUTER_PORT) Loopback only; never publish

Request flow

  1. The browser calls https://<domain>/gateway/..., which a reverse proxy forwards to the gateway on port 4000 with the /gateway prefix stripped. See Self-Hosting for the Nginx configuration.
  2. backend/gateway/src/main.ts applies a global rate limit (5000 requests per 15 minutes per IP, skipping /health and /bullmq-board), then CORS. A request carrying an active app token in x-app-api-token gets origin: true; all others get the DOMAIN, WIDGETS_DOMAIN, ALLOWED_DOMAINS, and ALLOWED_ORIGINS allow-list.
  3. userMiddleware identifies the caller (see below).
  4. Routes dispatch as follows:
    • ^/graphql proxies to the Apollo Router at http://127.0.0.1:50000, which executes the query across subgraphs.
    • /pl:<serviceName> proxies directly to the address that <serviceName> registered in Redis, bypassing the router. Plugins use this for HTTP routes such as webhooks.
    • /graphql over WebSocket is served by the gateway's own subscription server (see below).
    • / falls through to Core API (core.address in production, http://localhost:3300 in development). Core routes such as /initial-setup, /get-frontend-plugins, and /subscriptionPlugin.js are reached this way.
    • /health returns ok; /bullmq-board serves a Bull Board UI for the gateway-service-discovery queue; /locales/:lng/:file serves plugin translation JSON.

The proxy layer adds hostname, userid, x-forwarded-for, x-forwarded-port, and x-forwarded-proto headers to every forwarded request.

Service discovery in Redis

Every backend service announces itself by writing two Redis keys after it starts listening:

  • erxes-service-<name>: the service's HTTP address.
  • erxesservice:config:<name>: JSON with dbConnectionString, hasSubscriptions, meta (merged with any existing meta), and releaseVersion. Note the key is erxesservice with no hyphen.

The address is LOAD_BALANCER_ADDRESS when set, otherwise http://localhost:<port> in development and http://plugin-<name>-api:<port> in production. joinErxesGateway in service-discovery.ts performs this registration. The gateway additionally stores the enabled plugin list under erxes-active-plugins.

releaseVersion comes from RELEASE_VERSION when it starts with 3.; otherwise it is latest. Core API uses it to version frontend plugin asset URLs; see Frontend & Plugin Loading.

Supergraph composition

On start(), the gateway:

  1. Calls getPlugins(), which returns ['core', ...ENABLED_PLUGINS, ...ENABLED_PLUGINS_ONLY_API].
  2. For each name, waits until erxes-service-<name> exists (retryGetProxyTarget, one attempt per second) and until POST <address>/graphql answers the federation introspection query _service { sdl } (retryEnsureGraphqlEndpointIsUp, one attempt per 5 seconds). MAX_PLUGIN_RETRY bounds these retries; unset means retry forever.
  3. Writes a Rover config listing every target as a subgraph (federation_version =2.9.3, routing and schema URL <address>/graphql) and runs rover supergraph compose to produce supergraph.graphql under src/apollo-router/temp/ (supergraph-compose.ts).
  4. Spawns Apollo Router v1.59.2 with a generated router.yaml: 300-second timeouts, include_subgraph_errors, all request headers propagated, cors.allow_credentials, a Rhai script that merges subgraph set-cookie and server-timing headers into the response, and introspection enabled in development or when INTROSPECTION=true.
  5. Waits for a TCP connection to the router port, then starts listening on 4000. If the router exits unexpectedly, the gateway respawns it with a growing backoff (up to 30 seconds).

In development, the gateway re-runs rover supergraph compose every SUPERGRAPH_POLL_INTERVAL_MS (default 10000) so plugin restarts update the schema without a gateway restart. In development it also downloads the router binary on first run, which needs internet access; the production image already contains Rover and the router (see the gateway Dockerfile).

In production, joinErxesGateway instead enqueues a delayed BullMQ job on the gateway-update-apollo-router queue (10-second delay, 3 attempts, guarded by a 30-second gateway:update-apollo-router:pending lock so a burst of registrations triggers one recompose). The gateway's update-apollo-router worker (workers.ts) clears the service-discovery cache, refetches targets, recomposes, and restarts the router.

What happens when a plugin starts

start-plugin.ts runs this sequence for every *_api service:

  1. Builds the Express app with CORS, JSON body parsing (15mb limit), cookies, /health, and /debug-sentry.
  2. Mounts the plugin's expressRouter, middlewares, and apiHandlers (each wrapped in logHandler, which reports to the logs queue when a logs service is registered).
  3. When hasSubscriptions is set, serves /subscriptionPlugin.js behind a rate limiter. When trpcAppRouter is set, mounts /trpc.
  4. Builds a subgraph schema from the plugin's graphql() typeDefs and resolvers (wrapped by wrapApolloResolvers for auth and error handling) and mounts it at /graphql.
  5. Listens on the configured PORT, then calls joinErxesGateway. This is when the service becomes visible to the gateway.
  6. Processes meta: beforeResolvers, afterProcess, references, automations, segments, notifications, importExport, and payments each register producers or write plugin config back to erxesservice:config:<name>.

Core API follows the same pattern but with its own bootstrap in main.ts: it mounts src/routes (which includes /initial-setup and /get-frontend-plugins), starts Apollo, then registers as core with hasSubscriptions: true.

Authentication and tenants

userMiddleware (userMiddleware.ts) runs on every request before routing, in this order:

  1. erxes-core-token + erxes-core-website-url: validates against https://erxes.io/check-website and grants a limited system principal.
  2. erxes-app-token: looks up an active Apps record; a valid token creates a synthetic user app:<id> (owner when allowAllPermission). A separate x-app-api-token header is checked earlier, in the CORS layer, where a valid token opens origin: true for the request and is cached for one hour under app_token:<subdomain>:<token>.
  3. x-app-token: a JWT signed with JWT_TOKEN_SECRET identifying a client portal; adds a base64 clientportal header. client-auth-token (header or cookie) also identifies a portal user and adds a base64 cpuser header.
  4. Bearer token or auth-token cookie: a JWT signed with JWT_TOKEN_SECRET. The user must exist in the tenant's Users collection and the token must still be listed in Redis as user_token_<userId>_<token>. The user document travels downstream as a base64 user header (with loginToken and bulky fields stripped) plus a userid header. OAuth access tokens (typ: 'oauth_access') also carry oauthClientId and scopes.

The tenant is derived from the hostname: getSubdomain reads the nginx-hostname header, then the hostname header, then req.hostname, and takes the first DNS label. Each service uses that subdomain to select tenant models. See Authentication.

Gateway environment variables

Variable Default Purpose
PORT 4000 Gateway listen port
NODE_ENV none development enables dev remotes, router download, polling recompose
APOLLO_ROUTER_PORT 50000 Loopback port for the internal router
DOMAIN http://localhost:3000 Primary UI origin for CORS
WIDGETS_DOMAIN http://localhost:3200 Widgets origin for CORS
ALLOWED_DOMAINS none Extra comma-separated CORS origins
ALLOWED_ORIGINS none Comma-separated regexes added to the CORS origin list
JWT_TOKEN_SECRET SECRET Verifies user, portal, and app JWTs; always set in production
MONGO_URL mongodb://127.0.0.1:27017/erxes?directConnection=true App/user lookups in userMiddleware
REDIS_HOST / REDIS_PORT / REDIS_PASSWORD 6379 Shared Redis for discovery, queues, tokens
ENABLED_PLUGINS / ENABLED_PLUGINS_ONLY_API none Plugin names the gateway must wait for
LOAD_BALANCER_ADDRESS none Address each service advertises instead of the derived default
RELEASE_VERSION none Values starting with 3. become the plugin asset version
VERSION os os or saas; changes plugin discovery and CORS paths
INTROSPECTION none true enables GraphQL introspection in production
MAX_PLUGIN_RETRY unlimited Bounds plugin join/GraphQL readiness retries
SUPERGRAPH_POLL_INTERVAL_MS 10000 Dev-mode recompose interval
GRAPHQL_LIMITER none When set, enables depth/alias/character limits
TRUST_PROXY loopback, linklocal, uniquelocal Express trust proxy setting
DEBUG_GATEWAY_AUTH false Verbose auth/proxy request logging

Operate on one system.

Talk to our team about consolidating your stack onto erxes. 30-minute walkthrough, custom to your org.