Enterprise

Deployment

The Docker stack, runtime topology, persistence, and health checks.

Maturity: how far the subjects on this page are today, as of 2026-10-05. How maturity is measured.

SubjectKindLabelScoreMain limit
Host (neuralis)packagestable90 %Sign-in is email and password; single sign-on (OIDC, SAML) is not available.
Platform: Linuxtopicexperimental85 %No run on a standalone Linux host is recorded; the live-tested setup is Docker on WSL2.
Platform: Windows with WSL2topicstable90 %Use an in-distro Docker engine; with Docker Desktop the host broker needs its loopback TCP fallback.
Platform: macOStopicexperimental35 %Neuralis has not been run on macOS so far.
Platform: Windows (native)topicexperimental35 %Neuralis has not been run on native Windows so far.

Self-hosted is the point: your agents' conversations, files, memory, and audit trail never leave your infrastructure, and the model mix — nine hosted provider families, custom endpoints of your own, or both — is yours to configure. The recommended production shape for an organization is the Docker Compose stack: the Neuralis host application plus a Qdrant vector database sidecar, with all durable state on the host filesystem. A native Node deployment is also supported. This page covers both, plus the runtime topology, what persists where, and how to wire health checks.

The Docker stack

pnpm neuralis:setup    # must run BEFORE compose; safe to re-run later
docker compose up -d   # starts neuralis (:3100, :3101) + qdrant

The setup script creates the ~/.neuralis data directories with correct ownership, writes the .env (including NEXTAUTH_SECRET), and generates the docker-compose.yml for this machine. Run it before the first docker compose up — if Docker creates the data directories first, they end up root-owned.

It is also safe to re-run on an existing install, and that is the supported way to change infrastructure answers (ports, public origin, vector backend, desktop variant). A re-run maintains what is already there: settings you changed in the admin UI are merged, not replaced; the session secret, the MCP API key and the vector-database API key are preserved, so nobody is logged out, no MCP client breaks and the vector database keeps authenticating; source configurations you have edited are left alone; and setup maintains a project that already exists — answer with its name or its id (the id, when two projects share a name) — and refuses anything else rather than creating a second, half-wired project. A genuinely fresh install means a fresh data directory, not a re-run.

The compose file is a setup artifact, not a shipped file: it carries concrete machine-specific values (user/group IDs, ports, the Qdrant URL for your chosen Qdrant mode) and a version-stamp header, and is regenerated — never hand-edited — with pnpm neuralis:setup --compose-only. Secrets stay out of it by design: the only interpolations are NEXTAUTH_SECRET and QDRANT_API_KEY from .env, and provider API keys are never compose environment values (anything in a container's environment is readable via docker inspect); they live in the encrypted credential store.

The generated file defines up to three services:

ServiceImagePurpose
neuralisneuralisapp/neuralis (or built from the bundled Dockerfile in source checkouts)The Next.js host: UI, API, the in-process package runtime, and the embedded MCP HTTP service. Publishes ports 3100 (app), 3101 (MCP) and 3102 (the isolated origin for MCP app views); with NEURALIS_CODEX_LOOPBACK=on in .env (setup asks it) also 127.0.0.1:1455, so a browser on the Docker host can finish a ChatGPT sign-in directly. That one is off by default: once published, Docker holds the host's port 1455 for as long as the stack runs, and the stack will not start while another program holds it. Off, a ChatGPT sign-in opens no listener on 1455 at all: you finish it by pasting the final callback URL into the Credentials tab.
qdrantqdrant/qdrant (emitted when you chose the compose-managed Qdrant during setup)Vector database for memory and search. Requires the setup-minted API key on every request, and sits on its own compose network: the app spans both networks, while sandboxed child containers (virtual desktops, tool sidecars) live only on the default one and cannot reach the vector store. Published on 127.0.0.1 only; data in the qdrant-data named volume, snapshots in qdrant-snapshots. It runs the Qdrant version your storage is on, recorded at setup. External/binary Qdrant modes emit a direct QDRANT_URL instead.
ollamaollama/ollama, pinned, with memory caps (emitted when you chose a compose-managed Ollama during setup)Free local embeddings / a chat model on your own hardware, without an API key. Pull a model with docker compose exec ollama ollama pull nomic-embed-text.

The app container mounts the Docker socket so the machine package can spawn per-user desktop containers; remove that mount to disable machine features (the widget then reports a clean "docker missing" state). Treat a mounted Docker socket as a privileged host capability.

Container image layout

The image uses a flat /neuralis working directory. Image-built runtime artifacts are isolated in two locations, each protected by its own anonymous volume so that bind-mounting host directories over the working directory can never shadow them:

  • /neuralis/_runtime/ — the Next.js standalone server (server.js, built .next/ output, static assets). The container starts with node _runtime/server.js.
  • /neuralis/node_modules/<name> — every package installed as a real directory, exactly the shape a registry install would produce — the platform's own and every package an administrator registered, from a registry (public or private), a packed tarball or a directory the image build builds. There is no package source tree at runtime.
  • /neuralis/_runtime/build/ — the image build's report: which packages it refused, and why. The platform reads it at start and runs without them.

Extra host directories

To expose an additional host folder to agents, register it with pnpm neuralis:mount add <host-path> --slug <name>. The script writes the bind mount and its environment pair to a machine-local docker-compose.override.yml (the generated base compose file is never edited, and the override survives a base regeneration — the two files have opposite lifecycles); after a restart you attach the mount as a filesystem source from the Files UI. See sources and connectors.

Your own packages

On an install that builds its own image, an administrator adds a first-party package from any of four sources, under any scope or none, and every one arrives the same way — installed into the image as a real directory:

pnpm neuralis:pkg add <name> --path <dir>        # a directory you develop — the image build builds it
pnpm neuralis:pkg add <name> --tarball <x.tgz>   # a packed package, installed as packed
pnpm neuralis:pkg add <name> --version <x.y.z>   # an exact registry version, public or private
pnpm neuralis:rebuild                            # installs it; the package loads at that start

Registering is the trust act (the package runs in-process with first-party trust), and every check runs before the dependency line is written: a directory or a tarball is judged by the same admission check the platform applies at load, a directory also by its build contract, and a registry version must be exact. A registry version must also be in the host's lockfile before the rebuild, which installs from the lockfile and refuses a dependency it does not carry.

A private registry. Point NEURALIS_BUILD_NPMRC in .env at an .npmrc that carries scope lines only — @yourco:registry=<url> and that registry's token line, never a default registry= line. The image build mounts the file as a build secret for its two network steps, so the token is in no image layer, build argument, log or rendered compose file. The host remembers every scope it has seen served privately; from then on registering a package and the image build both refuse such a scope that the file does not route, before anything is fetched, so a private package's name is never asked of the public registry. A scope the machine has never seen served privately looks like any public scope, so route a new private scope in the file before its first use. Releases of the scopes the file routes privately install at once; every public package keeps the one-day minimum release age.

One bad package never takes the platform down. The image build judges every installed package before the image exists: a package built from a directory that fails fails the build, with every problem named; a registry package that fails, or collides with another package over a service, an OAuth prefix or a configuration key, is left out and named, and the build goes on. A package that fails when the platform starts is left out the same way. The platform runs without it, /api/health stays ready and counts the packages it left out, and the admin health view names them.

An added package's default role grants reach every EXISTING project once, at the first start that carries the package (new projects get them when they are created); running add again for a package already registered does the same, which is how a package registered earlier grants its existing projects.

pnpm neuralis:pkg remove <name> takes the line out (byte-exact), drops its development mount, revokes the role grants the package gave by default at the next start, and lists what it leaves in place — the package's data, its stored configuration values and its credentials — with the command to delete each. A pulled prebuilt image carries only what it was built with: setup names a package registered from a directory or a tarball that such an image cannot carry. For developing a package in its own repository, see building your own first-party package.

The host-access plane (optional, operator-provisioned)

Mounting a host folder makes it visible to the container. A separate, opt-in plane lets a command actually run on the host machine — useful when an agent must drive tooling that only exists there. It is off by default and cannot be switched on from inside the application:

pnpm neuralis:host-broker init     # secret + a deny-all allowlist; prints a service unit
pnpm neuralis:host-broker install  # the sandbox helper: copied from the built image and proved by its self-test
pnpm neuralis:host-broker upgrade  # install + unit refresh + broker restart — after every rebuild
pnpm neuralis:host-broker run      # run the broker in the foreground (or install the unit)
pnpm neuralis:host-broker status   # readiness, ceiling, sandbox helper + drift, transport, mode, pending requests
pnpm neuralis:host-broker grant --exec|--read|--write <dir>   # widen the allowlist for a recorded request
pnpm neuralis:setup --compose-only # regenerate compose with the broker binds

The sandbox helper on the host is installed and refreshed by the CLI, never compiled by hand: install copies the image's own binary, runs its self-test through the same check the broker uses (both the filesystem and the socket layers must prove themselves), and places it atomically; status reports when the host copy has drifted from the running image, and upgrade brings it back in line. A helper that cannot prove both layers is reported unusable and the plane stays denied until it is upgraded.

The broker runs on the host, not in a container, and the container reaches it through a read-only bind of the broker runtime directory containing the Unix socket and secret (a loopback TCP fallback exists for Docker Desktop, where a bind-mounted socket cannot be connected to). Directory mounting is deliberate: the broker replaces its socket inode on restart, and the running container must see the replacement. Removing the runtime bind disables the plane entirely, regardless of any in-app setting. The allowlist file ships empty, which denies every host command until you name the paths the broker may reach — "not configured" never means "unlimited". Only then does an administrator flip the Host Plane kill-switch and grant exec.host / terminal.native to the roles that should reach the host.

Tuning what the broker may reach

The allowlist — the operator ceiling — is a plain JSON file the broker owns. init writes it, and it is the one place you change what the host plane can do. It lives beside the broker's secret, under the Neuralis home directory (~/.neuralis/host-broker/ceiling.json for a user-run broker; a service account keeps its own copy under its state directory). The container cannot write it, by design. Send the broker SIGHUP to reload without a restart — and a malformed file reloads as deny-all, never as the previous, more permissive value.

It has three path lists, and they answer different questions. Mixing them up is the most common way to either lock yourself out or grant more than you meant:

ListWhat it does
readClamps a request. A path here appears only if the caller actually asked for it.
writeThe same clamp for mutations. Every write root is also readable — a writable directory nobody can read is not a usable grant.
execThe always-present set, added read-only to every spawn regardless of the request. Interpreters and toolchains belong here.

The distinction is a security boundary, not a convenience: exec grants reachability so a program can start, never readability through the file APIs. That is why listing /proc in exec — which many native binaries need — does not expose process environments to the filesystem tools.

A worked example. To let agents run a CLI installed on the host, name the launcher, the binary and the runtime it needs in exec, and its state directory in write:

{
  "read": ["/home/you/projects"],
  "write": ["/home/you/projects", "/home/you/.config/that-cli"],
  "exec": ["/usr", "/bin", "/lib", "/lib64", "/etc", "/proc",
           "/home/you/.local/bin"],
  "maxTimeoutMs": 600000
}

Know what a write root costs

Every write root is folded into the readable set, and the host filesystem tools then reach it. If a CLI's state directory also holds its saved credentials, naming that directory here makes those files readable from inside the container. Narrow the write root to the subdirectories the tool actually needs, and re-check after upgrading it.

Two failure modes are worth recognising, because neither one names the missing path: a command exiting 126 with permission denied means the binary — or the target its symlink resolves to — is not under an exec root; a native program that hangs and then dies usually probed a part of /proc the baseline does not grant.

Two more keys widen what the plane can do, and both are opt-ins the operator writes. maxDetachedLifetimeMs (absent or 0 = denied) enables background host shells: an agent's background: true command on a host source becomes a run the broker keeps for up to that long, which survives an application rebuild and reports back afterwards — set it above a rebuild's duration if agents are meant to run one. confinement: "unconfined" switches every host spawn to a bare process run as the broker's own account with its login environment, so the agent reaches everything installed for that account — Docker, the node toolchain, the CLIs — exactly as a person at that keyboard would. That is root-equivalent on the host, as the operator, and it is for a single-operator machine only: it loads only beside trustedSingleOperator: true, status shows it in yellow, and every result reports unconfined.

When a command is refused because a path is outside the allowlist, the broker records the request — the paths and the reason the agent gave — and status lists it; grant --exec|--read|--write <dir> is the answer, applied atomically and reloaded without a restart. The agent never edits this file.

The file also carries trustedSingleOperator, which defaults to false and should stay there for any shared deployment. It exists for the case where the person running host commands is the person who owns the machine — a single-operator development box — and it accepts two named residuals: that the broker still runs inside that operator's own login session, with their group memberships; and, when confinement: "unconfined" is set beside it, that the agent's host processes run as that operator outright. In that mode the ceiling file, the broker's service unit, its secret and the sandbox helper are all writable by the agent's own process — so the allowlist and the request channel become documentation rather than enforcement, and switching back to sandboxed is only trustworthy after you re-verify those artifacts (upgrade proves the helper; the ceiling and the unit by eye). Where the caller and the machine owner are the same, that is not an escalation. Where they are different people, it is, and the answer is a dedicated service account rather than this flag. It lives in this operator-owned file rather than in platform configuration precisely because platform configuration is editable from inside the application.

Host commands are confined by the OS sandbox and clamped to that allowlist for every role — no role can bypass it; the one relaxation is the operator's confinement: "unconfined" above. The plane requires a Linux host (or WSL2), because the sandbox helper is a Linux kernel feature and the broker requires it in both modes — on macOS it is unavailable and therefore denied. The full model is on the agent-core security page.

What persists where

All durable state lives outside the container, so image rebuilds and upgrades never touch it:

  • ~/.neuralis/app/ — platform zone: user records, project records, platform configuration, encrypted credentials, the audit log, and the platform-level data and logs of first-party packages (app/data/<package>/).
  • ~/.neuralis/projects/ — per-project zone: agent data, conversations, source configs, project-installed packages.
  • ~/.neuralis/checkpoints/ — the automatic data checkpoints (next section). They are a way back one build, not a backup.
  • The qdrant-data volume — the vector index, and more than an index: brain:// content lives only there. Back it up together with ~/.neuralis; the two must stay consistent.
  • The .env beside the compose file — outside the home, it holds the session secret (NEXTAUTH_SECRET) and the vector-store key (QDRANT_API_KEY).

Backup and restore

A complete backup is four things taken at one consistent point:

  • the data home (~/.neuralis) — the platform and project zones, the checkpoints, the host-access broker's directory, the recorded Qdrant version (qdrant-version.json) and the credential master key (app/config/credential-master.key), without which every stored secret — the MCP API key included — is unreadable. In native mode with the setup-managed Qdrant binary, its storage (qdrant-storage/) lives in the home too. The Qdrant upgrade's own rollback copies (qdrant-backups/) are left out;
  • the Qdrant volume (<project>_qdrant-data, where <project> is the compose project name — the name: line at the top of docker-compose.yml, neuralis unless setup chose another) — primary data, not a rebuildable index;
  • the .env beside the compose file;
  • docker-compose.override.yml — this machine's mounts.

From the host folder:

pnpm neuralis:backup                          # stops the app and Qdrant, copies, starts them again
pnpm neuralis:backup --out <dir>              # default: a <home>-backups/<time> folder beside the home
pnpm neuralis:backup --include-volume <name>  # also a snapshot, desktop-profile or Ollama volume
pnpm neuralis:backup list                     # complete backups, newest first

The stack is down only for the copy; the command prints each part's size and how long it took. It refuses — before stopping anything — without the .env, without enough free space beside the backup folder, or with a folder inside the data home, the host folder or a Docker build context. It refuses while any other container holds the volume, and fails when tar reports a file that changed while it was read — something still writes under the home; stop it and run the backup again. A failed run writes no manifest, so its folder is never offered as a backup, and it starts again exactly the services it stopped. Relative paths are read from the folder you run the command in. The volumes it leaves out by default — <project>_qdrant-snapshots (snapshot exports), neuralis-machine-* (desktop profiles) and <project>_ollama-data — are named in its output. The folder is created 0700 and every file in it 0600: keep it private, it holds every secret of the install.

pnpm neuralis:restore <dir>                               # the app must be stopped; asks for the folder name again
pnpm neuralis:restore <dir> --home <tmp> --volume <clone> # rehearse into a throwaway home and a cloned volume

restore refuses while the app answers on its port or docker compose ps shows it running. It reads every incoming archive before anything moves, then saves the current volume to <home>.before-restore-<volume>.tar, replaces the volume, moves the current home to <home>.before-restore and the current .env and docker-compose.override.yml to *.before-restore — it deletes nothing, and it refuses while an earlier rescue copy sits in any of those places. If a step fails half-way, the error lists every step already done and where each rescue copy is. With --home and --volume the live home, volume and .env are never touched. Afterwards run pnpm neuralis:setup --compose-only — the restored .env names the release and the Qdrant mode the backup was taken on — and docker compose up -d -V. Never docker compose down -v on the way — it deletes the volume.

With a pulled image and no host folder, the same backup is done by hand from the folder that holds the compose file:

docker compose stop neuralis qdrant
docker ps -q --filter volume=<project>_qdrant-data     # must print nothing
tar -C <home> -cpf <dir>/home.tar .
docker run --rm --network none \
  -v <project>_qdrant-data:/from:ro -v <dir>:/to alpine:3.24 \
  sh -c "tar -C /from -cf /to/qdrant-data.tar . && chown $(id -u):$(id -g) /to/qdrant-data.tar"
cp .env docker-compose.override.yml <dir>/
docker compose up -d

Run the tar of the home as a user that can read every file — the master key is readable by its owner only. Any small image with tar works in place of alpine:3.24. The hand restore is the inverse with both services stopped: copy the current volume out first, because emptying it is part of the restore.

mv <home> <home>.before-restore && mkdir <home>
tar -C <home> -xpf <dir>/home.tar
docker compose up --no-start qdrant     # only if the volume itself is gone: Compose creates it
docker run --rm --network none \
  -v <project>_qdrant-data:/to -v <dir>:/from:ro \
  alpine:3.24 sh -c 'find /to -mindepth 1 -delete && tar -C /to -xf /from/qdrant-data.tar'

Then put .env and docker-compose.override.yml back and finish as after restore above.

A checkpoint is a way back one build, not a backup: it covers the control plane only, never the vector store or .env.

Upgrading and going back a version

An upgrade replaces the application, never the data. With the Docker image the compose file runs one exact release — the one recorded in .env as NEURALIS_IMAGE_TAG, never a moving tag. Take a backup, then move that line to the new release: from a host folder, pnpm neuralis:update --version <x> --apply installs the matching packages and moves the line once the install succeeded; with a pulled image and no host folder, edit the line by hand. Then regenerate the compose file (pnpm neuralis:setup --compose-only, the same way it was first generated) and recreate the stack with docker compose up -d -V — the -V renews the anonymous volumes, which would otherwise mask the new image's runtime. Without the line, --compose-only refuses and writes nothing. Qdrant's own version is a separate, deliberate step (pnpm neuralis:qdrant-upgrade, see the production checklist below).

Every stored record kind carries the on-disk format its build reads and writes, recorded in one ledger (~/.neuralis/app/config/data-formats.json). The first boot of a newer build over older data works like this:

  • Older data ⇒ checkpoint first, then upgrade. Before it raises a format, the build copies the control-plane files of the kinds it is about to raise into ~/.neuralis/checkpoints/<time>-<kind>-v<from>-v<to>/. Encrypted credentials and their master key are copied only when the credential format itself is raised; logs never are. dataCheckpointKeep (admin Config, default 5, 1–50) bounds how many are kept.
  • Newer data ⇒ the build does not start. A build that meets data written by a newer one writes nothing; the boot error (and /api/health) names the record kind, both versions, the newest checkpoint and the exact restore command. Nothing is rewritten into a shape the newer build could not read.

Going back is an offline operator act:

pnpm neuralis:checkpoint list           # checkpoints, newest first, and the format ledger
pnpm neuralis:checkpoint restore <id>   # the app must be stopped; asks for the id again

restore refuses while the app answers on its port or docker compose ps shows the neuralis service running, and it replaces the listed files wholesale — everything written after the checkpoint is lost, which is why it asks for the id a second time. Then start the previous image: write the previous release back into NEURALIS_IMAGE_TAG, regenerate the compose file (pnpm neuralis:setup --compose-only) and run docker compose up -d -V. With a pulled image and no host folder, the script is baked in:

docker compose stop neuralis
docker compose run --rm --no-deps neuralis node --import tsx scripts/checkpoint.mts restore <id>

A checkpoint covers the control plane only — never the vector index. A model switch in the vector store is not a format change: it is a rebuild an owner starts and can keep a rollback copy of (see memory and sync).

Role-grant upgrades are one-way. A build that raises the built-in role grants migrates each project record as it reads it, and today such a raise takes no checkpoint of its own — the way back across it is a full backup taken before the upgrade. From the next role-grant raise on, the project record format rises with it, so an older build refuses to start — naming the record kind and the checkpoint to restore — instead of serving the project read-only. Builds that predate the format ledger have no such guard at all: the first published beta is the floor of a safe rollback.

Native Node

The same host runs without Docker on Node 22.22.2 or newer (26 recommended, the version the image runs):

pnpm neuralis:setup
pnpm start

On Linux the install compiles the terminal's native module (node-pty ships no Linux prebuild), so the machine needs python3, make and a C++ compiler — on Debian or Ubuntu, apt install build-essential python3. Docker installs are unaffected.

You provide a Qdrant instance (QDRANT_URL, default http://localhost:6333); the setup script can download and start a Qdrant binary for you. Without a reachable Qdrant the host still starts in a degraded in-memory vector mode. See installation for the distribution channels that deliver the source form.

Runtime topology

Browser
  -> Next.js host (:3100)
    -> auth/session and project membership
    -> package catch-all route (/api/packages/[...path])
      -> package loader / runtime / route dispatcher
        -> package routes, tools, commands, connectors, App surfaces

External MCP client
  -> MCP HTTP service (:3101)
    -> OAuth JWT, per-agent API-key, or platform API-key auth
    -> package-hosted MCP tools, prompts, and resources

Both ports belong to the same process; the MCP service on 3101 is a separate listener for external clients — see MCP access.

Health and readiness

GET /api/health on port 3100 is unauthenticated, side-effect free, and reflects the bootstrap lifecycle — not merely whether the HTTP server has bound:

  • 200 { status: 'ready', vectorBackend, refusedBuiltins, … } — the in-process warm-up (package loader, memory subsystem, Qdrant probe, sync scheduler) completed. refusedBuiltins counts the packages the platform runs without (refused by the image build or at start); the admin health view names them, this unauthenticated probe never does.
  • 503 { status: 'initializing' | 'error' } — still warming up, or failed.
  • vectorBackend: 'inmemory' inside a 200 is a degraded but ready signal: Qdrant was unreachable at init and connectivity is re-probed in the background.

The bundled compose healthcheck polls this endpoint with a 50-second start_period to cover the warm-up, so the container only reports healthy when the platform is actually serving. Point your container platform's readiness probe at the same endpoint. The MCP service exposes a separate /healthz on port 3101 that is process-liveness only.

Graceful shutdown

On a graceful stop — a rolling update, a docker compose recreate, stop, restart, or your orchestrator scaling the pod down — the host receives SIGTERM and drains in-flight agent turns before exiting: each running stream is finalized and persisted to its conversation history first, so a deploy never loses a turn that was mid-response. Background subagent runs — the ones that keep working after the turn that started them ended — drain in that same window and alongside it, and are recorded as interrupted by the shutdown so the reason is visible afterwards rather than looking like an ordinary partial result. Component teardown and the connector/package lifecycle run after the drain, never before it.

The handlers are installed the moment the server process starts, independently of every other startup step — so a deployment that disables the MCP HTTP port, or one restarted while the platform is still warming up, drains exactly the same way.

The drain window is the Shutdown Drain Timeout platform setting (default 25 seconds, admin → Runtime). Keep it below the stop grace period your platform allows: the bundled compose service permits 40 seconds, and on Kubernetes you want an equivalent terminationGracePeriodSeconds. Raising the setting above that window does not extend the time the orchestrator actually grants. A watchdog forces the process to exit shortly after that window in any case, so a teardown step that never settles cannot hold a deploy open until the orchestrator kills it. A hard kill (SIGKILL, an OOM, or a host crash) skips this drain, so prefer graceful stops when agents may be active. A background subagent lost that way is repaired the next time somebody looks at the conversation: a run still marked running from a process that no longer exists is recorded as partial, keeping whatever transcript it had written. A run parked on an approval is deliberately never repaired — it is supposed to outlive a restart, and is still answerable afterwards.

The public origin

NEXTAUTH_URL and APP_URL must name the origin users actually reach — the LAN IP, hostname or domain, including the port. Both default to http://localhost:3100, which is a development convenience and not a fallback that degrades gracefully: authentication and OAuth callbacks are resolved against these values server-side, so a deployment reached over any other origin with the defaults left in place will complete a login and then send the browser to its own machine. Set them (and MCP_BASE_URL when remote MCP clients are supported) before handing the URL to anyone, and re-check them after changing ports or putting the app behind a proxy — the setup wizard writes the values it was given, it cannot detect how users will reach the deployment.

Production checklist

  • Set NEXTAUTH_URL / APP_URL to the public origin (see above).
  • Put the app behind TLS and a reverse proxy — and declare that proxy in NEURALIS_TRUSTED_PROXIES (its address or CIDR block; the setup wizard asks) — the proxy's own address, never the Docker gateway: every direct client reaches the container through the gateway, so declaring it would believe their X-Forwarded-For too. Neuralis believes X-Forwarded-For only from a declared proxy, reading it from the right, so a client cannot choose its own address. Left empty (the default), every login and webhook call is attributed to the connecting socket — behind a proxy that is the proxy itself, so per-client limits collapse into one shared address: login lockout still applies per account, but the per-address spray brake and the webhook rate cap become global.
  • Preserve WebSocket upgrades and disable proxy buffering for SSE routes — chat streaming and live workspace updates depend on both. Live workspace updates (package-runtime invalidation, user presence, workflow runs, growing conversation transcripts, and file changes) all flow over ONE multiplexed /api/events connection, so a streaming workspace holds only a couple of long-lived connections — a reverse proxy or HTTP/2 is not required to avoid browser connection-pool exhaustion, though it remains good practice.
  • Keep Qdrant private (the compose file already binds it to loopback, places it on its own network, and the server itself requires the API key on every request).
  • Keep port 3101 private unless remote MCP clients are intentionally supported. It also carries the packages' companion surfaces (the terminal PTY socket, the machine desktop stream) — each authenticated by its owning package, but the port is not meant to be public.
  • Treat the host-broker runtime-directory bind as a privileged host capability, like a mounted Docker socket, and keep the broker allowlist as narrow as the work actually requires.
  • Back up the home, the Qdrant volume and .env together (backup and restore).
  • Treat the Qdrant version as part of your install. Its on-disk format is forward-only and upgrades one minor version at a time, so setup records the version your storage is on and the generated compose file runs exactly that version. Setup refuses when your install is more than one minor version behind the one this release ships, and names the one command that moves it: pnpm neuralis:qdrant-upgrade. An existing install sees this the first time it regenerates its compose file after the update; a rebuild is unaffected. The command checks the vector store, stops the app, records point counts, copies snapshots out, stops Qdrant, takes a cold backup of the volume, then walks every minor version; each must report no optimizer error and exactly the recorded number of points before the next (a collection still optimizing may stay yellow). Nothing else may rebuild or restart the stack while it runs. On any failure it restores the backup and the previous version. Run it with --dry-run to see the steps, or with --rehearse <volume> on a copy of the volume first. Never recover by removing the volume: that deletes every embedding and forces a full re-index at real provider cost.

On this page