Deployment
Deploy erxes on your own server using Docker Compose and an HTTPS reverse proxy: a single-server Core UI, Core API, gateway, and logs-service deployment, backed by a MongoDB replica set and persistent Redis. Plugins and external integrations are added after the core installation is working.
The repository does not include a ready-made Compose file. The configuration below is a deployment template built from the Dockerfiles, CI workflows, and runtime code in erxes/erxes, not a bundled installer or a high-availability setup. Supply a working MongoDB endpoint and compatible image references before running it. For running source code on your laptop, use Local Setup.
Deployment layout
Browser
|
| HTTPS :443
v
Host Nginx
|-- / --> 127.0.0.1:3000 --> Core UI container :80
|-- /gateway/ --> 127.0.0.1:4000 --> Gateway container :4000
|
| GraphQL / service proxy
v
Core API :3300
Core API, gateway, and logs-service --> MongoDB replica set
Core API, gateway, and logs-service --> Redis :6379 (private Docker network)
The gateway image (Dockerfile) bundles Apollo Router v1.59.2 and Rover 0.22.0 for GraphQL composition. Core API registers its internal address through Redis. Apollo Router's internal port defaults to 127.0.0.1:50000; it does not need a published host port. See Backend & API Gateway for the full flow.
1. Prepare the server
Use an x86-64 Linux server, such as Ubuntu 24.04 LTS. The gateway, Core API, automations, and migrations images are published for linux/amd64 only; the logs and plugin API images also publish linux/arm64. The Compose example pins platform: linux/amd64 so it behaves identically on ARM hosts that can emulate.
Install:
- Docker Engine and the Docker Compose plugin.
- Nginx, with Certbot and its Nginx plugin for HTTPS.
curland OpenSSL for checks and secret generation.
This guide uses Ubuntu's sites-available/sites-enabled layout and systemctl. Adjust those commands for another distribution.
For capacity: the repository does not establish a tested minimum CPU, RAM, or disk size. Budget for the Node.js services, Redis, container images, uploads, and your database workload, and measure a staging deployment before choosing production capacity.
For networking:
- Point your application hostname, such as
erxes.example.com, at the server. - Allow inbound HTTPS (
443) and HTTP (80, used for certificate issuance and redirects), plus your administration access. - The example publishes the UI and gateway ports on loopback only. Core API, Redis, and the router stay internal.
- Allow the containers to reach your MongoDB servers. With plugins enabled, browsers also need access to the plugin asset host described in step 8.
Create a deployment directory owned by your deployment account; all Compose commands below run from it:
mkdir -p ~/erxes-deploy
cd ~/erxes-deploy
2. Prepare MongoDB
Provision a MongoDB replica set before starting erxes: a self-managed set on private infrastructure or a managed MongoDB service. Replica sets support the sessions and transactions some plugins use (startSession writes). A single-node set works for evaluation but provides no redundancy.
Obtain a connection URI that:
- Selects your application database, for example
erxes. - Includes authentication and the appropriate
authSource, replica-set, and TLS settings for your database. - Resolves to hosts reachable from the Docker containers, including all replica-set members MongoDB advertises.
- Grants access to the application database and the logs database. The logs service writes to a separate database named
<application-database>_logs, such aserxes_logs; see Background Services.
For a remote replica set, use its normal discovery URI rather than copying the directConnection=true URI from the local-development guide. URL-encode special characters in credentials. Verify the URI with mongosh from the deployment network before proceeding.
Configure database persistence and automated backups with your database tooling. The Compose file manages Redis and erxes services; it does not provision MongoDB.
3. Select compatible container images
CI publishes these image repositories:
| Component | Image repository | Platforms | Tag patterns in CI |
|---|---|---|---|
| Core UI | erxes/erxes-next-ui |
amd64 | latest, branch, short SHA, semver |
| Core API | erxes/erxes-next-core-api |
amd64 | latest, YYYYMMDD-<short-sha> |
| Gateway | erxes/erxes-next-gateway |
amd64 | latest, full commit SHA |
| Logs service | erxes/erxes-next-logs |
amd64, arm64 | latest, YYYYMMDD-<short-sha> |
| Automations | erxes/erxes-next-automations |
amd64 | latest, YYYYMMDD-<short-sha> |
| Migrations | erxes/erxes-next-migrations |
amd64 | latest, YYYYMMDD-<short-sha> |
| Plugin APIs | erxes/erxes-next-<plugin>_api |
amd64, arm64 | latest, YYYYMMDD-<short-sha> |
| Apps | erxes/frontline-widgets, erxes/posclient-front, erxes/help-center |
amd64 | latest, branch, short SHA, semver |
The release.yml workflow additionally re-tags every image above except erxes/erxes-next-migrations as :<version> when a release tag is pushed, so versioned tags exist for released versions. Prefer an explicit version tag or digest over floating latest so you can roll back; see Upgrades. Record the selected references and digests:
docker pull erxes/erxes-next-core-api:CHOSEN_TAG
docker image inspect erxes/erxes-next-core-api:CHOSEN_TAG \
--format '{{json .RepoDigests}}'
Use the resulting repository@sha256:... reference in .env. Repeat for each service and check registry availability for your selected tags.
If you build your own images, follow each Dockerfile and CI workflow for the version you deploy: Core API and the logs/automations Dockerfiles use prebuilt application and erxes-api-shared artifacts (run pnpm nx build erxes-api-shared and the service build first), while the gateway and Core UI Dockerfiles have their own build stages.
4. Configure the deployment
Create .env in ~/erxes-deploy. Replace every REPLACE_... value before starting:
APP_HOST=erxes.example.com
CORE_UI_IMAGE=REPLACE_WITH_CORE_UI_IMAGE_DIGEST
CORE_API_IMAGE=REPLACE_WITH_CORE_API_IMAGE_DIGEST
GATEWAY_IMAGE=REPLACE_WITH_GATEWAY_IMAGE_DIGEST
LOGS_IMAGE=REPLACE_WITH_LOGS_IMAGE_DIGEST
MONGO_URL=REPLACE_WITH_AUTHENTICATED_MONGODB_URI
REDIS_PASSWORD=REPLACE_WITH_RANDOM_HEX
JWT_TOKEN_SECRET=REPLACE_WITH_DIFFERENT_RANDOM_HEX
Generate a separate value for each secret with openssl rand -hex 32.
Keep secrets private and consistent
Keep .env out of source control. All API services must share the same JWT_TOKEN_SECRET; it signs team-member sessions, portal tokens, and app tokens, and a mismatch silently invalidates logins. The same applies to REDIS_PASSWORD on every service and the Redis container itself.
APP_HOST is a bare hostname; the Compose file adds https:// when constructing DOMAIN and GATEWAY_URL.
Create compose.yml alongside .env:
name: erxes
x-api-environment: &api-environment
NODE_ENV: production
VERSION: os
DOMAIN: https://${APP_HOST:?Set APP_HOST}
GATEWAY_URL: https://${APP_HOST:?Set APP_HOST}/gateway
MONGO_URL: ${MONGO_URL:?Set MONGO_URL}
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD}
JWT_TOKEN_SECRET: ${JWT_TOKEN_SECRET:?Set JWT_TOKEN_SECRET}
services:
redis:
image: redis:7
restart: unless-stopped
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD}
command:
- sh
- -c
- exec redis-server --appendonly yes --maxmemory-policy noeviction --requirepass "$$REDIS_PASSWORD"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "REDISCLI_AUTH=$$REDIS_PASSWORD redis-cli ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 12
core-api:
image: ${CORE_API_IMAGE:?Set CORE_API_IMAGE}
platform: linux/amd64
restart: unless-stopped
environment:
<<: *api-environment
PORT: "3300"
LOAD_BALANCER_ADDRESS: http://core-api:3300
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3300/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
retries: 20
start_period: 30s
logs-service:
image: ${LOGS_IMAGE:?Set LOGS_IMAGE}
platform: linux/amd64
restart: unless-stopped
environment:
<<: *api-environment
PORT: "3301"
LOAD_BALANCER_ADDRESS: http://logs-service:3301
depends_on:
redis:
condition: service_healthy
gateway:
image: ${GATEWAY_IMAGE:?Set GATEWAY_IMAGE}
platform: linux/amd64
restart: unless-stopped
environment:
<<: *api-environment
PORT: "4000"
INTROSPECTION: "false"
ports:
- "127.0.0.1:4000:4000"
depends_on:
core-api:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
retries: 20
start_period: 60s
core-ui:
image: ${CORE_UI_IMAGE:?Set CORE_UI_IMAGE}
platform: linux/amd64
restart: unless-stopped
environment:
NODE_ENV: production
REACT_APP_API_URL: https://${APP_HOST:?Set APP_HOST}/gateway
ports:
- "127.0.0.1:3000:80"
volumes:
redis-data:
Notes on this file:
- The backend reads
REDIS_HOST,REDIS_PORT, andREDIS_PASSWORD; there is noREDIS_URL. Redis holds service-discovery keys and BullMQ queues, so the example enables AOF persistence and thenoevictionpolicy. Pin theredis:7digest too when recording your release. LOAD_BALANCER_ADDRESSmust be reachable inside Docker. Without it, a production service registershttp://plugin-<name>-api:<port>, which does not match this template's service names.VERSION: osselects the open-source code paths used by/initial-setupand plugin discovery.- The Core UI container's entrypoint writes every
REACT_APP_*variable into a browser-readablejs/env.jsat startup. Put only public frontend configuration there; it is a runtime injection, not a build-time bake, so changingREACT_APP_API_URLtakes effect on container restart. INTROSPECTIONstaysfalsein production unless you need schema introspection for frontend development; the gateway warns when it is enabled.
5. Start the containers
Validate the configuration without printing resolved credentials, pull the images, and start:
docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 core-api gateway logs-service
Wait for Core API to register and the gateway to compose its GraphQL schema, then check the loopback health endpoint:
curl --fail http://127.0.0.1:4000/health
It returns ok. The gateway only listens after the router is up, so this also confirms initial schema composition; it does not prove every database, worker, or plugin is healthy. Complete the checks in step 7 after HTTPS is working.
6. Configure HTTPS with Nginx
On Ubuntu, install Nginx and Certbot if needed:
sudo apt update
sudo apt install nginx certbot python3-certbot-nginx
Create /etc/nginx/sites-available/erxes, replacing erxes.example.com with your APP_HOST:
map $http_upgrade $erxes_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name erxes.example.com;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /gateway {
return 301 /gateway/;
}
location /gateway/ {
proxy_pass http://127.0.0.1:4000/;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $erxes_connection_upgrade;
}
}
The trailing slash in proxy_pass http://127.0.0.1:4000/ strips the public /gateway/ prefix, so /gateway/graphql reaches /graphql on the gateway. The WebSocket upgrade headers carry GraphQL subscriptions on the same path. 50m is a proxy request limit, not a guarantee of application upload size; the JSON body limit in the APIs is 15mb, and file uploads go through the configured storage backend.
Enable the site, test it, and request a certificate:
sudo ln -s /etc/nginx/sites-available/erxes /etc/nginx/sites-enabled/erxes
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d erxes.example.com --redirect
sudo certbot renew --dry-run
Certificate issuance requires working DNS and access to the HTTP challenge endpoint. Verify automatic renewal is scheduled.
7. Verify and create the owner account
Check the public gateway and GraphQL endpoint:
curl --fail https://erxes.example.com/gateway/health
curl --fail https://erxes.example.com/gateway/graphql \
-H 'Content-Type: application/json' \
--data '{"query":"query SelfHostingHealth { __typename }"}'
Expect ok from health and a GraphQL response with data.__typename equal to Query. HTTP 200 with an errors array is not a successful GraphQL check.
Open https://erxes.example.com. On a fresh database, the frontend fetches /initial-setup, sees hasOwner: false, and redirects to /create-owner. Create the initial owner account, sign in, then verify you can create and edit a record and that it appears without a manual refresh. Inspect browser requests for failed API calls or WebSocket connections.
8. Add plugins and integrations
For each plugin, deploy its matching API image (erxes/erxes-next-<plugin>_api) built from a compatible version, with the shared API environment plus PORT and LOAD_BALANCER_ADDRESS, and give ENABLED_PLUGINS to both Core API and the gateway as a comma-separated list of base names such as sales,operation, with no _api/_ui suffixes, spaces, or trailing commas. Deploy every listed API before recreating core services: the gateway waits for all enabled plugins to register and serve their subgraph. Plugin APIs that join after startup trigger a router recompose through the gateway-update-apollo-router queue, but a plugin that never registers leaves the gateway waiting at boot.
For automation execution, deploy erxes/erxes-next-automations explicitly; ENABLED_SERVICES is a development-startup variable, not a Compose directive. The logs-service container in this template persists event logs, activity logs, and the undo journal; see Background Services.
Plugin UI assets are served from a CDN
Core API's /get-frontend-plugins route returns https://plugins.erxes.io/<version>/<plugin>_ui/remoteEntry.js; the asset host is hardcoded. <version> is the plugin API's registered releaseVersion (RELEASE_VERSION when it starts with 3., else latest), and the corresponding folder must exist on the CDN. Self-hosting the backend does not make plugin assets local or the deployment offline-capable. Serving plugin UIs from your own infrastructure requires building the *_ui projects yourself and changing that route. Check the asset URLs and release compatibility before enabling plugins in production. See Frontend & Plugin Loading.
Configure the storage backend used by your deployment (Settings → file upload: UPLOAD_SERVICE_TYPE and provider credentials are stored in the database, not env vars) before relying on uploads, and include stored objects in backups. Email delivery, SSO, and other integrations need their own credentials; see Configuration.
Widgets, the client portal template, the POS client, and the help center are separate applications with their own images (erxes/frontline-widgets, erxes/posclient-front, erxes/help-center) and environment; this template does not launch them.
Operations
Logs and troubleshooting
docker compose ps
docker compose logs --tail=200 gateway core-api logs-service
docker compose logs --tail=100 redis core-ui
- Gateway waits for a plugin: the API container must be running, registered as
erxes-service-<name>in Redis, and reachable at itsLOAD_BALANCER_ADDRESS; the enabled list must have no missing services. - Gateway health works but GraphQL fails: check the router process and
supergraph composeerrors in the gateway logs. - MongoDB connection or authorization error: check container DNS/connectivity, URI encoding, TLS, and permissions on both the application and
_logsdatabases. - Redis authentication error:
REDIS_PASSWORDmust be identical on the container and every API/worker. - Sign-in, CORS, or subscription failure: check the public HTTPS URL,
DOMAIN,REACT_APP_API_URL, proxy prefix stripping, and WebSocket upgrade headers. - Plugin navigation fails: check API registration plus the returned
plugins.erxes.ioasset URL and its version. - Containers exit during startup: check image architecture (
platform: linux/amd64), compatible artifacts, env completeness, and memory.
See Logs & Troubleshooting for more symptoms and fixes, including Redis discovery keys.
Backups and upgrades
Back up the application MongoDB database, its <db>_logs database, uploaded files/object storage, and the deployment configuration. Preserve Redis state for queued work as your recovery plan requires. Store backups off the host and test restoration.
Before an upgrade, review the release's migration notes (Database Migrations), take a restorable backup, and test the new image set with a copy of your data. Then update the image references in .env and apply:
docker compose pull
docker compose up -d
docker compose ps
Repeat the health, GraphQL, login, and plugin checks. See Upgrades for rollback. Restarting an older image does not reverse database migrations, and a single-server Compose update causes a short outage.
Stop or remove the containers
docker compose stop # stop, keep containers and volumes
docker compose down # remove containers and network, keep named volumes
The redis-data volume survives down, and the separately managed MongoDB is unaffected.
Data loss
docker compose down -v deletes the Redis volume (queues and service discovery). Run it only as a deliberate operation after confirming your MongoDB backups, never as part of generic host cleanup.
Licensing
Review the repository's LICENSE.md for the version you deploy. It contains AGPLv3 provisions, references Enterprise Edition terms, and prohibits hosting a SaaS version that competes with erxes Inc. Self-hosting does not by itself grant unrestricted plugin or commercial SaaS rights; Enterprise plugins are licensed separately.