Security
The parser sandbox, secrets, malware scanning and network exposure.
Security
What protects a self-hosted install: the parser sandbox, malware scanning, secrets and encryption keys, and network exposure. For how the pieces connect, see the trust boundaries diagram.
On this page
Parser sandbox
Document parsers run untrusted input. The worker starts each parser through worker/scripts/parser-sandbox.sh, which wraps it in bubblewrap (bwrap).
What the sandbox does:
- Unshares the network, PID, IPC and UTS namespaces (
--unshare-net,--unshare-pid,--unshare-ipc,--unshare-uts). The parser has no network. - Drops all capabilities (
--cap-drop ALL), starts a new session, and dies with its parent. - Mounts a fresh
/procand/dev, and an empty/tmp. - Binds read-only only the runtime paths it needs:
/usr,/bin,/sbin,/lib,/lib64, and a few files under/etc(ld.so.cache,fonts,ssl,localtime,passwd,group, ImageMagick config) plus/var/cache/fontconfig, where they exist. The worker adds the input files and any unpacked vendor bundles throughDOCSLURP_PARSER_SANDBOX_READABLE_PATHS, also read-only. The application checkout, other jobs and worker credentials are not mounted. - Binds writable only the per-run scratch directory and the paths the worker lists in
DOCSLURP_PARSER_SANDBOX_WRITABLE_PATHS. The script refuses any writable path that is not under/tmp/docslurp-*or<host temp root>/docslurp-*, and any path with..in it. It exits with code 70 (refusing broad writable mountorrefusing traversal in writable mount) rather than mounting more. - Rewrites
HOME,TMPDIR,TMP,TEMPand theXDG_*directories to a private per-run directory, which is deleted when the command exits. - Passes a small environment. Only these variables reach a parser, when set:
LANG,LC_ALL,LC_CTYPE,PATH,SSL_CERT_DIR,SSL_CERT_FILE,NODE_EXTRA_CA_CERTS,OPENSSL_CONF,PYTHON_VENV,TMP,TMPDIR,TEMPandTZ(defaultParserEnvAllowlistinworker/src/tools/process-env.ts). Add more names, comma-separated, withDOCSLURP_PARSER_ENV_ALLOWLIST. Database, storage, auth and AI keys are not on the list.
If bwrap is missing, the launcher exits with code 70 and refusing to run parser without isolation. It never falls back to running the parser unsandboxed.
The sandbox is always on when NODE_ENV=production, which the Compose stack sets. Outside production it is on by default except on macOS, and DOCSLURP_PARSER_SANDBOX_ENABLED can turn it off. The inherit mode (DOCSLURP_PARSER_SANDBOX_MODE=inherit) skips isolation, and the worker honours it only outside production (resolveParserSandboxSettings in worker/src/tools/runner.ts).
Why Compose sets systempaths=unconfined
The worker services in compose.selfhost.yml set systempaths=unconfined so bubblewrap can mount a fresh /proc. Docker masks some /proc paths by default, and that blocks the mount. The worker keeps cap_drop: [ALL], no-new-privileges:true, a pids limit of 256 and the seccomp profile worker/security/seccomp-doc-extract.json. The parser still runs inside the bubblewrap namespaces.
The error you see without it
At startup, scripts/start-worker.sh runs scripts/check-worker-prerequisites.mjs with --sandbox. It runs the launcher against /usr/bin/true. If that fails, the worker prints a line like this and exits without starting:
[worker-prerequisites] parser isolation unavailable: <reason from bubblewrap>Common causes are a host that forbids unprivileged user namespaces or mounts (sysctl or AppArmor), or removing systempaths=unconfined from the Compose file. See Requirements. The output ends with status=failed.
Commands that skip the sandbox
Two kinds of command do not go through the launcher:
- The vector writer (
vector_backend_write.pyandorama_write.mjs). It has to reach object storage, and the sandbox has no network. It receives the vector backend URIs (LANCEDB_URI,STORAGE_URI,CHROMA_PATH,CHROMA_URL,MILVUS_URI,ORAMA_PATH) and AWS credentials includingAWS_SESSION_TOKEN, and nothing else beyond the default allowlist. Only these two trusted scripts get that list; an input filename cannot ask for it (isVectorBackendCommandinworker/src/tools/runner.ts). - The embedding pool. It is a long-lived Python process started by the worker (
getEmbedPoolin the same file) rather than a per-call command. It has its own environment allowlist (defaultPythonPoolEnvAllowlist, extended withDOCSLURP_PYTHON_POOL_ENV_ALLOWLIST). It handles text to embed, not file parsing.
Malware scanning
The worker scans files with ClamAV. At startup, scripts/start-worker.sh prepares ClamAV, updates signatures with freshclam and checks that clamd is ready when the daemon is required or managed. DOCSLURP_CLAMAV_ENABLED=false turns that startup step off (default true).
The scan policy comes from resolveMalwareScanPolicy in worker/src/tools/runner.ts:
| Variable | Default in production | Default otherwise | Meaning |
|---|---|---|---|
DOCSLURP_CLAMAV_REQUIRE_DAEMON |
true |
false |
Require clamdscan. If it is unavailable, the scan returns an error (“clamdscan is required but unavailable”). |
DOCSLURP_CLAMAV_FAIL_OPEN_ON_STALE_DB |
false |
true |
When false, a stale or missing signature database blocks scanning. When true, it only produces a warning. |
DOCSLURP_CLAMAV_MAX_DB_AGE_HOURS |
24 |
24 |
Age after which signatures count as stale. |
DOCSLURP_CLAMAV_DB_DIR |
/var/lib/clamav |
/var/lib/clamav |
Signature directory. |
“Production” means NODE_ENV=production, which the Compose stack sets. If no scan engine is available at all and the daemon is not required, the scan result is skipped-unavailable.
Secrets and encryption keys
node scripts/selfhost-init.mjs generates distinct random values and writes them to .env.selfhost, which Git ignores. Back this file up somewhere private. If you lose it, or change an encryption key, stored data protected by that key becomes unreadable.
| Variable | What it protects |
|---|---|
POSTGRES_PASSWORD, DATABASE_URL |
The database. Keep the password in DATABASE_URL in step with POSTGRES_PASSWORD. Postgres reads the password only when its volume is created, so editing the file does not change an existing database. |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
Object storage. In this stack they are also the MinIO root user and password. |
BETTER_AUTH_SECRET |
Better Auth, the sign-in layer. It is also the last fallback root for file proxy URL tokens. |
OAUTH_TOKEN_ENCRYPTION_KEY |
OAuth tokens persisted in oauth_accounts. |
CREDENTIALS_ENCRYPTION_KEY |
Connector credentials. |
SECRETS_ENCRYPTION_KEY |
Workspace secrets stored at rest. It is also the first fallback root for file proxy URL tokens. |
METRICS_SECRET |
Access to the metrics endpoint. |
Rotation caveat: the three *_ENCRYPTION_KEY values encrypt data already in Postgres, and this repository documents no re-encryption procedure. Replacing a key makes the rows encrypted under the old key unreadable. Do not rotate them casually. If you must replace one, take a backup first.
Provider keys (OPENAI_API_KEY, OPENROUTER_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY) and OAuth client secrets are optional and live in the same file. See Configuration.
Network exposure
The Compose file publishes only the server, on ${SERVER_BIND_ADDRESS:-127.0.0.1}:${SERVER_PORT:-8787}. Do not publish the Postgres, Redis, MinIO or Temporal ports, and do not add ports: entries for them. None of them is meant to face the internet or an untrusted network. If you run a reverse proxy on another host, bind the server to an address that only your private network can reach and firewall the port.
For TLS, a reverse proxy, forwarded headers and client IP handling, see Public access.