Skip to content

Configuration

Every environment variable

Edit this page
On this page

Loomscope is configured entirely by environment variable. There is no config file, no command-line flags and nothing to template — which means a deployment is reproducible from its environment alone.

.env.example in the repository is the annotated version of this page; this one is the reference.

Control plane

Required

VariablePurposeGenerate with
DATABASE_URLPostgreSQL connection string—
BETTER_AUTH_SECRETSigns session cookiesopenssl rand -hex 32
BETTER_AUTH_URLPublic URL. Must match the browser address, scheme and port—
LOOMSCOPE_KMS_KEYAEAD key encrypting stored credentialsopenssl rand -base64 32

BETTER_AUTH_URL must match exactly. Better-Auth signs callbacks against it, so a wrong value produces sign-ins that appear to work and then fail on redirect — including http where the browser uses https, or a missing port.

LOOMSCOPE_KMS_KEY cannot be recovered. It encrypts every credential in the credentials table: SNMP community strings, cloud keys, SSH material. Losing it makes all of them permanently unreadable; leaking it means treating all of them as compromised. It belongs in a secret manager, with a copy somewhere that is not the server.

Optional

VariableDefaultPurpose
LOOMSCOPE_DB_POOL_MAX25PostgreSQL connections this process may hold in total, across all its pools
LOG_LEVELinfotrace, debug, info, warn, error
LOOMSCOPE_JOBS_IN_WEBonSet false once a separate worker container owns scheduled work
LOOMSCOPE_DEPENDENCY_WINDOW_HOURS24Flow window the application topology considers

| LOOMSCOPE_MAGIC_LINK_ECHO | off | Prints magic-link sign-in URLs to the log. Development only |

LOOMSCOPE_DB_POOL_MAX is a per-process budget divided across seven internal pools by fixed shares, not a per-pool limit. The default of 25 leaves room, against a stock max_connections of 100, for a rolling deploy (two servers), a worker — which holds one connection per scheduled job on top of this — and a psql session. Raise it only alongside max_connections.

LOOMSCOPE_JOBS_IN_WEB defaults to on deliberately. Making it opt-in would mean any deployment that had not yet added the worker container silently ran no scans, no CVE matching and no snapshots. Running it in both places is safe rather than harmful — every tick takes a PostgreSQL advisory lock, so only one process executes each one — but it puts the work in the process serving requests, which is the thing worth moving away from.

AI assistant

Off unless a provider is configured. See AI assistant.

VariablePurpose
LOOMSCOPE_AI_DEFAULT_PROVIDERanthropic, openai or ollama
LOOMSCOPE_ANTHROPIC_API_KEYEnables the assistant with Anthropic
LOOMSCOPE_OPENAI_API_KEYAlternative provider
LOOMSCOPE_OLLAMA_BASE_URLLocal models — nothing leaves the network with this one

Daemon

The daemon is configured entirely by environment. Under systemd, use EnvironmentFile=/etc/loomscope/daemon.env.

VariableRequiredDefaultPurpose
LOOMSCOPE_SERVER_URLYes—Control plane URL
LOOMSCOPE_DAEMON_API_KEYYes—Authentication. The process exits if unset
LOOMSCOPE_DAEMON_NAMENodefaultIdentity in the UI. Give each daemon a distinct one
LOOMSCOPE_DAEMON_SITE_CODENo—Binds to a site on first registration. UI assignment wins
LOOMSCOPE_NETWORK_CIDRSNo—Comma-separated ranges to scan without registering them
LOOMSCOPE_SIGNATURES_DIRNo/etc/loomscope/signatures.dCustom YAML signatures, reloaded on SIGHUP
LOOMSCOPE_DOCKER_DISCOVERYNofalseRead the Docker socket. Requires mounting it
LOOMSCOPE_TLS_DISABLEDNofalseSkip the TLS handshake probe
LOOMSCOPE_NETFLOW_PORTSNo2055,6343UDP listeners for NetFlow v5/v9, IPFIX and sFlow
LOOMSCOPE_FLOW_DELIVERY_INTERVALNo60sHow often collected flows are posted, independent of scans
LOG_LEVELNoinfoSame levels as the control plane

LOOMSCOPE_DOCKER_DISCOVERY is opt-in because mounting the Docker socket grants effective root on the host. It is worth it for a container-dense node and worth refusing everywhere else.

CVE ingest worker

VariablePurpose
DATABASE_URLSame database as the control plane
LOOMSCOPE_CVE_MIRRORDirectory holding the offline OSV/NVD mirror, for air-gapped installs
LOOMSCOPE_CVE_MASTER_SOURCEWhich feed wins when two describe the same CVE. Default nvd. The master overwrites and nothing overwrites it; other feeds still write advisories the master never issued
LOOMSCOPE_OPENCVE_URLA self-hosted OpenCVE instance to ingest from. Unset means the feed is off
LOOMSCOPE_OPENCVE_TOKENOrganization API token, opc_org.<token_id>.<secret>
LOOMSCOPE_OPENCVE_FIRST_RUN_DAYSHow far back the first pass reaches when there is no cursor. Default 30

Database bootstrap (compose only)

These are read by the postgres service in the compose file, not by the application.

VariableDefaultNotes
POSTGRES_USERloomscope
POSTGRES_PASSWORD—Required. Generate with -hex, never -base64 — see the note below
POSTGRES_DBloomscope

Generate the password with openssl rand -hex 24. Base64 output contains / and + about half the time, and this value ends up inside DATABASE_URL — where those characters make the connection string an invalid URL that the driver refuses outright.

Secret handling

  • Nothing secret belongs in .env on a shared host. In Kubernetes, every secret comes from a Secret you create yourself; the Helm chart references them and refuses to generate any. See Kubernetes.
  • Secrets are never logged. A redacting logger drops credentials, API keys, SNMP community strings and session tokens before they reach a log line.
  • API keys are hashed at rest, so a daemon key or a SCIM token is shown once and then only ever compared.

Verifying configuration

bash
# Is the control plane healthy, and is the database reachable?
curl -fsS http://localhost:3000/api/health

# Did the daemon lose its ICMP privilege and fall back?
docker logs loomscope-daemon | grep "falling back to TCP"

# Is the connection budget being respected?
psql "$DATABASE_URL" -c "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database();"

A variable that used to be here

NEXT_PUBLIC_AUTH_URL is gone. It was documented as "the client-side auth URL, when it differs from BETTER_AUTH_URL", and it could not work: NEXT_PUBLIC_* is substituted at build time, so a released image carried whatever the build machine had — in practice the fallback, http://localhost:3000 — compiled into the JavaScript sent to browsers.

A page served from any real hostname then tried to sign in against the visitor's own machine, and the browser refused it as cross-origin before the request left. The symptom was a sign-in that span forever rather than failing, because the rejected fetch had nothing waiting on it. No server-side setting could fix it; the wrong value was already in the bundle.

The client now uses the origin the page came from, which is where the auth routes are. There is nothing to configure.