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

ServiceProjectPortNotes
Gatewaybackend/gateway4000 (PORT)Public API entry point
Core APIbackend/core-api3300 (PORT)core subgraph + REST routes
Logs servicebackend/services/logs3301 (PORT)BullMQ workers, no subgraph
Automations servicebackend/services/automations3302 (PORT)BullMQ workers + tRPC
Plugin APIsbackend/plugins/*_api3303-3313e.g. sales 3305, frontline 3304, operation 3307
Apollo Routerspawned by the gateway127.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

VariableDefaultPurpose
PORT4000Gateway listen port
NODE_ENVnonedevelopment enables dev remotes, router download, polling recompose
APOLLO_ROUTER_PORT50000Loopback port for the internal router
DOMAINhttp://localhost:3000Primary UI origin for CORS
WIDGETS_DOMAINhttp://localhost:3200Widgets origin for CORS
ALLOWED_DOMAINSnoneExtra comma-separated CORS origins
ALLOWED_ORIGINSnoneComma-separated regexes added to the CORS origin list
JWT_TOKEN_SECRETSECRETVerifies user, portal, and app JWTs; always set in production
MONGO_URLmongodb://127.0.0.1:27017/erxes?directConnection=trueApp/user lookups in userMiddleware
REDIS_HOST / REDIS_PORT / REDIS_PASSWORD6379Shared Redis for discovery, queues, tokens
ENABLED_PLUGINS / ENABLED_PLUGINS_ONLY_APInonePlugin names the gateway must wait for
LOAD_BALANCER_ADDRESSnoneAddress each service advertises instead of the derived default
RELEASE_VERSIONnoneValues starting with 3. become the plugin asset version
VERSIONosos or saas; changes plugin discovery and CORS paths
INTROSPECTIONnonetrue enables GraphQL introspection in production
MAX_PLUGIN_RETRYunlimitedBounds plugin join/GraphQL readiness retries
SUPERGRAPH_POLL_INTERVAL_MS10000Dev-mode recompose interval
GRAPHQL_LIMITERnoneWhen set, enables depth/alias/character limits
TRUST_PROXYloopback, linklocal, uniquelocalExpress trust proxy setting
DEBUG_GATEWAY_AUTHfalseVerbose auth/proxy request logging
Was this helpful?