DevOps guide
Edit this pageFor 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
- Requirements
- Installation
- Environment reference
- Deploying the daemon
- TLS and reverse proxy
- Migrations
- Backup and restore
- Upgrades
- Air-gapped installation
- Monitoring
- Hardening
- Troubleshooting
What you are deploying
Three components, of which two are required.
| Component | Required | Role |
|---|---|---|
| PostgreSQL 17 | Yes | Every piece of state. Jobs, inventory, sessions, realtime notifications. |
| Control plane | Yes | Web UI, REST API, schedulers, cloud collectors. Stateless — all state is in PostgreSQL. |
| Daemon | Yes, at least one | Scans networks. Runs where the networks are. Polls outbound only. |
| CVE ingest worker | No | Mirrors 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_KEYlike 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
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string |
BETTER_AUTH_SECRET | Yes | Session signing key, 32 random bytes hex |
BETTER_AUTH_URL | Yes | Public URL, must match the browser address exactly |
LOOMSCOPE_KMS_KEY | Yes | AEAD key for credentials at rest, 32 bytes base64 |
LOG_LEVEL | No | trace…error, default info |
LOOMSCOPE_AI_DEFAULT_PROVIDER | No | anthropic, openai or ollama |
LOOMSCOPE_ANTHROPIC_API_KEY | No | Enables the assistant |
LOOMSCOPE_OPENAI_API_KEY | No | Alternative provider |
LOOMSCOPE_OLLAMA_BASE_URL | No | Local models; nothing leaves the network |
LOOMSCOPE_DEPENDENCY_WINDOW_HOURS | No | Flow window for application topology, default 24 |
NEXT_PUBLIC_AUTH_URL | No | Client-side auth URL when it differs from BETTER_AUTH_URL |
Daemon
| Variable | Required | Purpose |
|---|---|---|
LOOMSCOPE_SERVER_URL | Yes | Control plane URL |
LOOMSCOPE_DAEMON_API_KEY | Yes | Authentication. The process exits if unset |
LOOMSCOPE_DAEMON_NAME | No | Identity in the UI. Give each daemon a distinct one |
LOOMSCOPE_DAEMON_SITE_CODE | No | Binds to a site on first registration. UI assignment wins |
LOOMSCOPE_NETWORK_CIDRS | No | Scan these ranges without registering them first |
LOOMSCOPE_SIGNATURES_DIR | No | Custom signatures, default /etc/loomscope/signatures.d |
LOOMSCOPE_DOCKER_DISCOVERY | No | Read the Docker socket. Opt-in; requires mounting it |
LOOMSCOPE_TLS_DISABLED | No | Skip the TLS handshake probe |
LOOMSCOPE_NETFLOW_PORTS | No | UDP listeners, default 2055,6343 |
LOOMSCOPE_EBPF_DISABLED | No | The 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:
- Read the changelog for the target version.
- Back up the database and confirm you hold
LOOMSCOPE_KMS_KEY. - Pull the new images and update the tag in your compose file. Pin a version; do not track a moving tag.
- Stop the daemons — they tolerate a control plane that is briefly away.
- 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_atin 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_RAWandNET_ADMIN, runs read-only, non-root, withno-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
serverandpostgresservices the way the daemon already is — neither currently setsread_onlyor drops capabilities. - Rotate
LOOMSCOPE_DAEMON_API_KEYif 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.