DevOps guide

Edit this page

For people deploying and operating Loomscope. If you are using it, read the user guide; if you are changing it, the developer guide.

This guide states known gaps as plainly as it states procedures. Where something is missing, it says so rather than describing an ideal.

Contents


What you are deploying

Three components, of which two are required.

ComponentRequiredRole
PostgreSQL 17YesEvery piece of state. Jobs, inventory, sessions, realtime notifications.
Control planeYesWeb UI, REST API, schedulers, cloud collectors. Stateless — all state is in PostgreSQL.
DaemonYes, at least oneScans networks. Runs where the networks are. Polls outbound only.
CVE ingest workerNoMirrors OSV and NVD. Only needed to isolate that load or feed an air-gapped mirror.

The control plane is stateless, so it scales horizontally behind a load balancer. PostgreSQL is the ceiling.

Requirements

Minimum, for up to a few thousand hosts: 4 vCPU, 8 GB RAM, 40 GB disk.

Recommended, at tens of thousands: 8 vCPU, 16 GB RAM, 200 GB SSD, with PostgreSQL on its own volume.

The daemon is light — 1 vCPU and 512 MB is enough — but needs NET_RAW and NET_ADMIN, and layer-2 reachability to anything it should discover by ARP.

Installation

git clone https://github.com/bendaamerahmed/loomscope.git
cd loomscope
cp .env.example .env

Fill in everything .env marks REQUIRED. POSTGRES_PASSWORD, BETTER_AUTH_SECRET, LOOMSCOPE_KMS_KEY and LOOMSCOPE_DAEMON_API_KEY all use the ${VAR:?} form in the compose file, which means compose aborts rather than starting with a blank value. That is deliberate.

openssl rand -hex 32     # BETTER_AUTH_SECRET
openssl rand -base64 32  # LOOMSCOPE_KMS_KEY
openssl rand -hex 24     # POSTGRES_PASSWORD

Bring up the database and control plane first, because the daemon needs a key that does not exist yet:

docker compose -f infra/docker-compose.yml up -d postgres server
docker compose -f infra/docker-compose.yml run --rm server \
  node --experimental-strip-types apps/server/server/db/migrate.ts

Sign in at http://localhost:3000, create a daemon, copy its key into LOOMSCOPE_DAEMON_API_KEY, then:

docker compose -f infra/docker-compose.yml up -d daemon

Guard LOOMSCOPE_KMS_KEY like a private key. It encrypts every credential Loomscope stores — SNMP community strings, cloud keys, SSH material. Lose it and those are unrecoverable; leak it and treat them all as compromised. It belongs in a secret manager, and a copy belongs somewhere that is not the server.

Environment reference

Control plane

VariableRequiredPurpose
DATABASE_URLYesPostgreSQL connection string
BETTER_AUTH_SECRETYesSession signing key, 32 random bytes hex
BETTER_AUTH_URLYesPublic URL, must match the browser address exactly
LOOMSCOPE_KMS_KEYYesAEAD key for credentials at rest, 32 bytes base64
LOG_LEVELNotraceerror, default info
LOOMSCOPE_AI_DEFAULT_PROVIDERNoanthropic, openai or ollama
LOOMSCOPE_ANTHROPIC_API_KEYNoEnables the assistant
LOOMSCOPE_OPENAI_API_KEYNoAlternative provider
LOOMSCOPE_OLLAMA_BASE_URLNoLocal models; nothing leaves the network
LOOMSCOPE_DEPENDENCY_WINDOW_HOURSNoFlow window for application topology, default 24
NEXT_PUBLIC_AUTH_URLNoClient-side auth URL when it differs from BETTER_AUTH_URL

Daemon

VariableRequiredPurpose
LOOMSCOPE_SERVER_URLYesControl plane URL
LOOMSCOPE_DAEMON_API_KEYYesAuthentication. The process exits if unset
LOOMSCOPE_DAEMON_NAMENoIdentity in the UI. Give each daemon a distinct one
LOOMSCOPE_DAEMON_SITE_CODENoBinds to a site on first registration. UI assignment wins
LOOMSCOPE_NETWORK_CIDRSNoScan these ranges without registering them first
LOOMSCOPE_SIGNATURES_DIRNoCustom signatures, default /etc/loomscope/signatures.d
LOOMSCOPE_DOCKER_DISCOVERYNoRead the Docker socket. Opt-in; requires mounting it
LOOMSCOPE_TLS_DISABLEDNoSkip the TLS handshake probe
LOOMSCOPE_NETFLOW_PORTSNoUDP listeners, default 2055,6343
LOOMSCOPE_EBPF_DISABLEDNoThe collector is a scaffold today; see below

The daemon is configured entirely by environment. There is no config file and no command-line flags. Under systemd, use EnvironmentFile=/etc/loomscope/daemon.env.

The templated unit loomscope-daemon@.service sets LOOMSCOPE_DAEMON_NAME from the instance name, so systemctl enable --now loomscope-daemon@paris registers a daemon called paris. Override it in the per-instance env file if you want a different label.

Deploying the daemon

The daemon must sit where the networks are. Three supported shapes:

Docker with host networking — the compose default. Needed for ARP and for NetFlow listeners.

systemd on the host — units in infra/systemd/. Hardened with ProtectSystem=strict, a syscall filter, and only CAP_NET_RAW plus CAP_NET_ADMIN. Start from the shipped template:

sudo install -m 0640 -o root -g loomscope   infra/systemd/daemon.env.example /etc/loomscope/daemon.env

It contains an API key, so it is deliberately not world-readable.

One daemon per site. Bind each with LOOMSCOPE_DAEMON_SITE_CODE, or from the site page in the UI.

Capabilities

NET_RAW for ICMP and ARP, NET_ADMIN for netlink. Never privileged: true — nothing in Loomscope requires it, and the Compose file drops everything else.

eBPF would additionally need CAP_BPF and CAP_PERFMON, which the shipped systemd units do not grant. This is currently moot: the eBPF collector performs capability checks and attaches no probes.

TLS and reverse proxy

Loomscope ships no TLS termination. The compose file publishes port 3000 in cleartext, which is fine for a laptop and wrong for anything else.

Put a reverse proxy in front — Caddy, nginx or Traefik — terminate TLS there, and set BETTER_AUTH_URL to the public HTTPS URL. If it does not match what the browser shows, sign-in redirects break in confusing ways.

Do not publish port 3000 externally once a proxy is in place; bind it to loopback and let the proxy reach it.

Migrations

Migrations are never applied automatically, in any environment. With more than one control-plane replica, automatic migration means every replica racing to alter the same schema at boot.

docker compose -f infra/docker-compose.yml run --rm server \
  node --experimental-strip-types apps/server/server/db/migrate.ts

The runner records each file in a __migrations table and skips what is already applied, so it is safe to re-run. Each file executes as one multi-statement query, which PostgreSQL wraps in an implicit transaction — a failing migration rolls back whole rather than leaving half a schema.

Backup and restore

There is no backup tooling yet. It is on the roadmap; until it lands, do it yourself. Two things must be backed up together:

1. The database.

docker compose -f infra/docker-compose.yml exec -T postgres \
  pg_dump -U loomscope -Fc loomscope > loomscope-$(date +%F).dump

2. LOOMSCOPE_KMS_KEY. A database backup without it is only partly restorable: everything in the credentials table stays encrypted and unreadable forever. Store the key separately, in a secret manager.

Restore.

docker compose -f infra/docker-compose.yml up -d postgres
docker compose -f infra/docker-compose.yml exec -T postgres \
  pg_restore -U loomscope -d loomscope --clean --if-exists < loomscope-2026-08-14.dump

Then restore the same LOOMSCOPE_KMS_KEY into .env before starting the server. Verify by opening a stored credential in the UI — if it fails to decrypt, the key does not match the dump.

Test your restore. An untested backup is a hypothesis.

Upgrades

There is no documented upgrade path yet — Loomscope has had one release. The procedure that will be supported is:

  1. Read the changelog for the target version.
  2. Back up the database and confirm you hold LOOMSCOPE_KMS_KEY.
  3. Pull the new images and update the tag in your compose file. Pin a version; do not track a moving tag.
  4. Stop the daemons — they tolerate a control plane that is briefly away.
  5. Start the new control plane, run migrations, then start the daemons.

Migrations follow expand-and-contract, so a new schema stays readable by the previous release. Skipping several versions is not tested; upgrade one minor at a time.

Air-gapped installation

Loomscope is designed to work with no internet access. Two things normally reach out, and both have offline paths.

Container images. Pull them on a connected machine, docker save them to a tar, carry it across, docker load. There is no bundled script for this yet.

CVE feeds. The ingest worker reads a local mirror directory given by LOOMSCOPE_CVE_MIRROR. Populate it on a connected machine and copy it in.

scripts/airgap-cve-snapshot.sh builds the mirror on a connected machine and packages it with a checksum. infra/AIRGAP.md covers both paths in full, including carrying the container images across.

Nothing else phones home. There is no telemetry, no licence check and no update ping.

Monitoring

Health. GET /api/health returns non-200 when the database round trip fails. The server image has a HEALTHCHECK; the daemon has none.

There is no /metrics endpoint yet. Prometheus support is on the roadmap. Until then, monitor:

  • The health endpoint.
  • Daemon liveness — daemons.last_seen_at in the database, or the UI. A daemon quiet for more than five minutes is a problem.
  • PostgreSQL: connections, disk, replication lag if applicable.
  • Snapshot cadence. The 60-day strip on the History page shows at a glance whether the scheduler is still running.

Logs are structured JSON on stdout — pino for the control plane, zerolog for Go. Ship them with whatever you already use. Credentials, API keys and SNMP community strings are redacted.

Hardening

Shipped by default:

  • The daemon drops every capability except NET_RAW and NET_ADMIN, runs read-only, non-root, with no-new-privileges.
  • Images are pinned to a semantic version, never a moving tag.
  • Credentials are encrypted at rest; API keys are hashed.
  • Row-level security is enabled and FORCEd on every org-scoped table, so a query missing its tenant filter returns nothing.

Worth doing yourself:

  • Put TLS in front and stop publishing port 3000.
  • Do not publish PostgreSQL. The compose file does not.
  • Set resource limits. The compose file sets none, so a runaway scan can consume the host.
  • Harden the server and postgres services the way the daemon already is — neither currently sets read_only or drops capabilities.
  • Rotate LOOMSCOPE_DAEMON_API_KEY if a daemon host is ever decommissioned.

Troubleshooting

docker compose up exits immediately with a variable error. A REQUIRED value in .env is blank. The message names it.

The daemon will not register. In order: is LOOMSCOPE_DAEMON_API_KEY set and correct — the process exits without it; can the daemon reach LOOMSCOPE_SERVER_URL from inside its network namespace; does the control plane log show a 401.

Hosts are not being discovered. A network must exist in the UI before anything is scanned. Then check the daemon is online, that it has layer-2 reachability to the range, and that a firewall is not dropping ICMP and the TCP probes. A host that answers nothing will not be found by active scanning.

Sign-in redirects to the wrong address. BETTER_AUTH_URL does not match the URL in the browser. It must include the scheme and the port.

Stored credentials fail to decrypt. LOOMSCOPE_KMS_KEY differs from the one used when they were written. Restore the original; there is no recovery path without it.

Snapshots stopped. The scheduler runs in the control plane process. If it restarted, check its logs for a scheduler start line.

Vulnerability data looks stale. Check when the CVE ingest last completed. In an air-gapped install the mirror is only as fresh as the last copy in.