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
- The browser calls
https://<domain>/gateway/..., which a reverse proxy forwards to the gateway on port4000with the/gatewayprefix stripped. See Self-Hosting for the Nginx configuration. backend/gateway/src/main.tsapplies a global rate limit (5000 requests per 15 minutes per IP, skipping/healthand/bullmq-board), then CORS. A request carrying an active app token inx-app-api-tokengetsorigin: true; all others get theDOMAIN,WIDGETS_DOMAIN,ALLOWED_DOMAINS, andALLOWED_ORIGINSallow-list.userMiddlewareidentifies the caller (see below).- Routes dispatch as follows:
^/graphqlproxies to the Apollo Router athttp://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./graphqlover WebSocket is served by the gateway's own subscription server (see below)./falls through to Core API (core.addressin production,http://localhost:3300in development). Core routes such as/initial-setup,/get-frontend-plugins, and/subscriptionPlugin.jsare reached this way./healthreturnsok;/bullmq-boardserves a Bull Board UI for thegateway-service-discoveryqueue;/locales/:lng/:fileserves 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 withdbConnectionString,hasSubscriptions,meta(merged with any existing meta), andreleaseVersion. Note the key iserxesservicewith 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:
- Calls
getPlugins(), which returns['core', ...ENABLED_PLUGINS, ...ENABLED_PLUGINS_ONLY_API]. - For each name, waits until
erxes-service-<name>exists (retryGetProxyTarget, one attempt per second) and untilPOST <address>/graphqlanswers the federation introspection query_service { sdl }(retryEnsureGraphqlEndpointIsUp, one attempt per 5 seconds).MAX_PLUGIN_RETRYbounds these retries; unset means retry forever. - Writes a Rover config listing every target as a subgraph (
federation_version =2.9.3, routing and schema URL<address>/graphql) and runsrover supergraph composeto producesupergraph.graphqlundersrc/apollo-router/temp/(supergraph-compose.ts). - Spawns Apollo Router
v1.59.2with a generatedrouter.yaml: 300-second timeouts,include_subgraph_errors, all request headers propagated,cors.allow_credentials, a Rhai script that merges subgraphset-cookieandserver-timingheaders into the response, and introspection enabled in development or whenINTROSPECTION=true. - 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:
- Builds the Express app with CORS, JSON body parsing (
15mblimit), cookies,/health, and/debug-sentry. - Mounts the plugin's
expressRouter,middlewares, andapiHandlers(each wrapped inlogHandler, which reports to the logs queue when a logs service is registered). - When
hasSubscriptionsis set, serves/subscriptionPlugin.jsbehind a rate limiter. WhentrpcAppRouteris set, mounts/trpc. - Builds a subgraph schema from the plugin's
graphql()typeDefs and resolvers (wrapped bywrapApolloResolversfor auth and error handling) and mounts it at/graphql. - Listens on the configured
PORT, then callsjoinErxesGateway. This is when the service becomes visible to the gateway. - Processes
meta:beforeResolvers,afterProcess,references,automations,segments,notifications,importExport, andpaymentseach register producers or write plugin config back toerxesservice: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:
erxes-core-token+erxes-core-website-url: validates againsthttps://erxes.io/check-websiteand grants a limited system principal.erxes-app-token: looks up an activeAppsrecord; a valid token creates a synthetic userapp:<id>(owner whenallowAllPermission). A separatex-app-api-tokenheader is checked earlier, in the CORS layer, where a valid token opensorigin: truefor the request and is cached for one hour underapp_token:<subdomain>:<token>.x-app-token: a JWT signed withJWT_TOKEN_SECRETidentifying a client portal; adds a base64clientportalheader.client-auth-token(header or cookie) also identifies a portal user and adds a base64cpuserheader.Bearertoken orauth-tokencookie: a JWT signed withJWT_TOKEN_SECRET. The user must exist in the tenant'sUserscollection and the token must still be listed in Redis asuser_token_<userId>_<token>. The user document travels downstream as a base64userheader (withloginTokenand bulky fields stripped) plus auseridheader. OAuth access tokens (typ: 'oauth_access') also carryoauthClientIdand 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 |