This guide covers deploying Cortex to production — with the npx @mocaos/cortex installer, by hand from the release stack, or with Docker Compose and Coolify.
Prerequisites
- A server with Docker Engine 24+ and the Docker Compose v2 plugin (
docker compose version—apt install docker.iodoes not include it) - At least 4GB RAM (8GB+ recommended for production)
curlandtarfor the installer;git,jq, andopensslas well if you follow the manual release-stack path, whose commands use all three (a minimal server image ships none of them)- Domain name with DNS configured (for HTTPS)
- OpenAI API key (or alternative LLM provider)
Install with the installer (recommended)
Code
One command brings up the whole stack — Cortex, Neo4j, a nightly backup sidecar, and Caddy for automatic HTTPS if you deploy on a domain — from prebuilt, version-pinned images. Cortex Chat is offered as an opt-in extra, off by default. Nothing to clone, nothing to build, no Compose file to edit.
It runs in this order, and stops at the first thing that would waste your time:
- Environment preflight — Docker daemon, Compose v2, architecture, free disk, RAM, and the ports it is about to bind. The fatal ones stop the install with a plain reason.
- Live credential validation — a real chat completion and a real embedding call. Nothing is written to disk and no image is pulled until both succeed, so a wrong key costs you seconds instead of a 1.6 GB pull. The embedding dimension is measured here rather than assumed, because it gets baked into the Neo4j vector index on first use.
- Interactive configuration — localhost or a public domain, whether to install Cortex Chat (default no), your provider and models (embeddings may come from a different provider), your admin identity, and secrets, which are generated for you unless you'd rather set them.
- Pull, start, verify — writes
.envat mode0600, fetches that release's Compose files, pulls the pinned images, and waits for every service to report healthy before printing your login.
It also owns the instance afterwards, so you rarely touch Compose directly:
Code
start, stop, restart, config, restore and uninstall round it out. Your data lives in Docker volumes, so stop and uninstall never touch it — removing volumes needs a confirmation and a typed phrase. And if a project name already owns volumes on that machine, the installer stops and offers to rename rather than adopting data that might not be yours.
Requirements: Docker with the Compose v2 plugin, plus curl and tar (both present on macOS and virtually every Linux desktop; Debian's minimal images ship wget instead, and preflight names whichever is missing). npx brings its own Node, but if one is already on your PATH it must be 20.12 or newer.
Two npx failures worth knowing
Neither message points at its real cause:
could not determine executable to run— there is noinstallsubcommand.npxalready means "fetch and run", sonpx install @mocaos/cortexasks npm to run a package literally namedinstall. The command is justnpx @mocaos/cortex.ENOVERSIONS — No versions available for @mocaos/cortex— the package is public and fine; npm is hiding it from you. An.npmrcwithmin-release-ageset (increasingly common after the npm supply-chain compromises of recent months, and worth keeping) filters out every version published inside that window, so a release that is hours old has none left. Override it for the single command rather than disabling the policy:
Code
The flag works on any verb, and once the release is older than your window plain npx @mocaos/cortex resolves normally. npm has no way to exempt a single package, so it is the flag or the wait.
Self-host from the release stack (manual)
Everything the installer does, done by hand. Use this if you want to see each step, script it yourself, or manage the stack with existing tooling — the two are kept in sync, so this document also describes the files the installer created.
Since v1.0.0 you don't have to build Cortex to run it. Each release publishes multi-arch images to GHCR plus a static Compose stack under selfhost/ that is configured entirely through .env — you never edit the Compose files, so updates never conflict with local changes.
stack.json, attached to every GitHub release, is the version manifest: it pins the stack version and the component versions that were tested together.
Code
Fill in at least the secrets block and the LLM block in .env (the comments there carry ready-to-run openssl commands), then:
Code
The stack runs Cortex. Cortex Chat is optional — set COMPOSE_PROFILES=chat in .env to include it. With chat installed the two share one identity — ADMIN_EMAIL + ADMIN_PASSWORD — and Chat mints its own scoped backend keys using ADMIN_API_KEY.
Choosing a mode
COMPOSE_FILE in .env selects the topology. Each mode also sets the URL and cookie flags correctly for you, which is what makes the LAN/HTTPS foot-guns below a non-issue here.
Code
Code
Point both A records at the host before the first up — Let's Encrypt issuance fails otherwise. In domain mode only Caddy publishes ports; the API and the Neo4j browser stay private on the Compose network, and the API remains reachable at https://cortex.example.com/api/... because the frontend proxies it.
The ports overlay sets SESSION_COOKIE_SECURE=false (browsers drop Secure cookies on plain HTTP) while domain mode leaves it unset so it defaults to secure — nothing to configure either way. Note that Cortex Chat has no equivalent switch: its cookie is always Secure, which works at http://localhost:3001 but silently fails to log in if you point BIND_ADDR at a LAN IP over plain HTTP. Use domain mode for remote access.
Rules and updates
- Put local changes in
docker-compose.override.yml— Compose merges it automatically and updates never touch it. Do not editdocker-compose.yml. - Never rename the
backendservice. The published frontend image bakesAPI_URL=http://backend:8000into its Next.js rewrite manifest at build time. - Apps are enabled by default in this stack (the backend's own default is off). Set
ENABLE_APPS=falseif you want the smaller attack surface. - Error tracking is off here:
SENTRY_DSN_BACKEND/SENTRY_DSN_FRONTENDdefault to empty rather than to the project's GlitchTip, so a self-hoster's stack traces never leave their box unless they opt in with their own DSN.
To update, re-fetch stack.json and set the three CORTEX_*_IMAGE lines in .env to its component versions — then re-fetch that release's stack files too. New image tags alone are not an update: the Compose files and the ops/ scripts change between releases, and a fix that lives in a shell script arrives no other way.
Code
Your .env, your Caddyfile and any docker-compose.override.yml survive that copy: the release ships .env.example and both Caddyfile templates and carries no override file. If you never hand-edited Caddyfile, re-copy it from the refreshed template so template changes land too — Caddyfile.template without chat, Caddyfile.chat.template with it.
--build is not optional here. The backup sidecar is built from ops/backup rather than pulled — it has no image:, so docker compose pull skips it — and Compose will not rebuild an existing image just because its build context changed. Without --build, a release shipping a corrected backup.sh or restore.sh writes it to disk and keeps running the old one. That is precisely how the v1.0.1 graph-restore fix fails to reach an install that already has a sidecar image built. npx @mocaos/cortex update passes it for you, and stack.json's minInstaller requires an installer version that does.
Docker Compose Deployment
Production Configuration
Create a docker-compose.prod.yml:
Code
Deploy
Code
Coolify Deployment
Cortex includes Coolify-ready configuration for easy deployment.
Setup Steps
-
Create a New Resource
- Go to your Coolify dashboard
- Click "New Resource" → "Docker Compose"
-
Configure Git Repository
- Repository:
https://github.com/mocaOS/cortex-app - Branch:
main - Compose file:
coolify/docker-compose.coolify.yml
- Repository:
-
Set Environment Variables
In Coolify's environment section, add:
Code -
Configure Domains
- Frontend:
cortex.yourdomain.com - Backend API:
api.cortex.yourdomain.com
- Frontend:
-
Deploy
- Click "Deploy"
- Wait for containers to start
- Coolify handles SSL certificates automatically
Coolify Compose File
The coolify/docker-compose.coolify.yml includes Coolify-specific magic variables:
Code
Nginx Configuration
For manual deployments, configure Nginx as a reverse proxy:
Code
Health Checks
Backend Health
Code
Expected response:
Code
A degraded instance (Neo4j unreachable or schema not yet confirmed) answers
HTTP 503 with "status": "degraded" — so curl -f, the compose
healthchecks, depends_on: service_healthy, and health-aware proxies all
gate on it. The compose healthchecks carry a 60s start_period to cover
schema initialization at startup.
Neo4j Health
Code
Redeploy Safety
Before restarting or upgrading an instance (e.g. rolling out a new version), confirm it isn't mid-flight on destructible work:
Code
Code
safe_to_redeploy is false while documents are processing/extracting, background tasks are running (a restart interrupts them — their records survive in Neo4j and are marked failed so pollers get a real answer, but the work must be re-run), or an AskAI/research query is in flight (a restart kills the stream). Pending documents persist in Neo4j and resume after a restart, so they never block. Poll this endpoint in deploy automation and proceed only once safe_to_redeploy is true. Requires manage permission.
Scaling
Backend Scaling
Scale the backend vertically (more CPU/RAM for the single backend container):
Code
Do not run multiple backend replicas behind one Neo4j instance: task progress, SSE streams, and rate-limit buckets are tracked in process memory, so replicas would see inconsistent state. For more capacity per deployment, scale vertically; for more isolation, run one full Cortex stack per workload (the container-per-tenant model Cortex Cloud uses).
Neo4j Scaling
For large knowledge bases, increase Neo4j resources:
Code
Backup & Recovery
The backup sidecar (recommended)
The self-host, Dokploy, and Coolify composes include the backup sidecar; on the standalone prod stack add the overlay:
Code
Nightly (BACKUP_INTERVAL_SECONDS), online and verified: a server-side APOC
logical export of the graph (graph.cypher.gz) plus a tar of the file volumes —
uploads, custom inputs, chat data, installed skills, and installed apps —
with checksums and a .complete marker. Skills and apps were added in v1.0.0;
before that they were silently excluded, and since they are user-authored with
no other copy, verify that any older backup you are relying on actually
contains them. Retention
(BACKUP_RETENTION_DAYS, default 7) only rotates after a verified success and
never removes the newest complete backup. The sidecar's healthcheck goes
unhealthy when the newest verified backup is older than twice the interval, so
a silently failing backup shows up in docker ps.
Code
Restore
Restoring is a six-step procedure, not one command: restore.sh replays the
graph only, leaving uploads, skills, apps, and chat data untouched.
Code
The authoritative runbook is the header comment in ops/backup/restore.sh.
Backups live in the backups volume on the same host; ship them off-host
(restic/rclone) for disaster recovery.
Manual physical dump (alternative, requires downtime)
Code
The two formats are not interchangeable: a .dump restores only via
neo4j-admin database load, a sidecar export only via restore.sh.
Monitoring
Logs
Code
Resource Usage
Code
Security Checklist
- Strong
NEO4J_PASSWORDset -
ADMIN_API_KEYrotated from default -
SESSION_SECRETis 32+ characters - HTTPS enabled with valid certificates
- API key authentication enabled (
REQUIRE_API_KEY=true) - CORS configured for your domains only
- Firewall blocks direct access to Neo4j (7687)
- Regular backups scheduled