Self-Hosting

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:

    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 as erxes_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:

    ComponentImage repositoryPlatformsTag patterns in CI
    Core UIerxes/erxes-next-uiamd64latest, branch, short SHA, semver
    Core APIerxes/erxes-next-core-apiamd64latest, YYYYMMDD-<short-sha>
    Gatewayerxes/erxes-next-gatewayamd64latest, full commit SHA
    Logs serviceerxes/erxes-next-logsamd64, arm64latest, YYYYMMDD-<short-sha>
    Automationserxes/erxes-next-automationsamd64latest, YYYYMMDD-<short-sha>
    Migrationserxes/erxes-next-migrationsamd64latest, YYYYMMDD-<short-sha>
    Plugin APIserxes/erxes-next-<plugin>_apiamd64, arm64latest, YYYYMMDD-<short-sha>
    Appserxes/frontline-widgets, erxes/posclient-front, erxes/help-centeramd64latest, 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, and REDIS_PASSWORD; there is no REDIS_URL. Redis holds service-discovery keys and BullMQ queues, so the example enables AOF persistence and the noeviction policy. Pin the redis:7 digest too when recording your release.
    • LOAD_BALANCER_ADDRESS must be reachable inside Docker. Without it, a production service registers http://plugin-<name>-api:<port>, which does not match this template's service names.
    • VERSION: os selects the open-source code paths used by /initial-setup and plugin discovery.
    • The Core UI container's entrypoint writes every REACT_APP_* variable into a browser-readable js/env.js at startup. Put only public frontend configuration there; it is a runtime injection, not a build-time bake, so changing REACT_APP_API_URL takes effect on container restart.
    • INTROSPECTION stays false in 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 its LOAD_BALANCER_ADDRESS; the enabled list must have no missing services.
  • Gateway health works but GraphQL fails: check the router process and supergraph compose errors in the gateway logs.
  • MongoDB connection or authorization error: check container DNS/connectivity, URI encoding, TLS, and permissions on both the application and _logs databases.
  • Redis authentication error: REDIS_PASSWORD must 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.io asset 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.

Was this helpful?