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.
| Subject | Kind | Label | Score | Main limit |
|---|---|---|---|---|
| Host (neuralis) | package | stable | 90 % | Sign-in is email and password; single sign-on (OIDC, SAML) is not available. |
| Platform: Linux | topic | experimental | 85 % | No run on a standalone Linux host is recorded; the live-tested setup is Docker on WSL2. |
| Platform: Windows with WSL2 | topic | stable | 90 % | Use an in-distro Docker engine; with Docker Desktop the host broker needs its loopback TCP fallback. |
| Platform: macOS | topic | experimental | 35 % | Neuralis has not been run on macOS so far. |
| Platform: Windows (native) | topic | experimental | 35 % | 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) + qdrantThe 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:
| Service | Image | Purpose |
|---|---|---|
neuralis | neuralisapp/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. |
qdrant | qdrant/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. |
ollama | ollama/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 withnode _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 startRegistering 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 bindsThe 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:
| List | What it does |
|---|---|
read | Clamps a request. A path here appears only if the caller actually asked for it. |
write | The same clamp for mutations. Every write root is also readable — a writable directory nobody can read is not a usable grant. |
exec | The 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-datavolume — 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
.envbeside 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 — thename:line at the top ofdocker-compose.yml,neuralisunless setup chose another) — primary data, not a rebuildable index; - the
.envbeside 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 firstThe 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 volumerestore 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 -dRun 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 againrestore 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 startOn 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 resourcesBoth 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.refusedBuiltinscounts 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 a200is 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_URLto 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 theirX-Forwarded-Fortoo. Neuralis believesX-Forwarded-Foronly 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/eventsconnection, 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
3101private 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
.envtogether (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-runto 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.