Skip to content

Docker Compose

The reference installation

Edit this page
On this page

The reference installation. One file, five services, and a database you can point somewhere else when you would rather run your own.

If you only want to see it working, Quick start is shorter. This page is the one to follow for something people depend on.

What you are deploying

ServiceRequiredImageNotes
postgresYespostgres:17-alpineNot published on the host. Every piece of state lives here
serverYesghcr.io/bendaamerahmed/loomscope-serverUI, REST API, cloud collectors. Stateless
workerYesghcr.io/bendaamerahmed/loomscope-serverSame image, scheduled work only
daemonAt least oneghcr.io/bendaamerahmed/loomscope-daemonHost networking, on the network it scans
cve-ingestNoghcr.io/bendaamerahmed/loomscope-cve-ingestOnly to isolate mirror load or to feed an air-gapped mirror

Every image is pinned to a semantic version. There is no :latest tag in the compose file and there should not be one in yours — a moving tag turns a restart into an unplanned upgrade.

Getting access to the images

The images live in GitHub Container Registry and the packages are private, so docker compose up fails with denied or unauthorized until Docker is signed in. This is the first thing that stops a new collaborator, and the error says nothing about why.

Create a personal access token with the read:packages scope, then:

bash
echo "$GITHUB_TOKEN" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin

The packages inherit their access from the repository, so anyone with read access to it can pull once they have signed in. Somebody without repository access cannot, and no token will change that.

Building from source instead. The repository contains every Dockerfile, so an image is one command away and needs no registry at all:

bash
docker compose -f infra/docker-compose.yml build

That is also the honest answer for an air-gapped install where the registry is unreachable by design.

Requirements

CPURAMDisk
Up to ~5 000 hosts4 vCPU8 GB40 GB
Tens of thousands8 vCPU16 GB200 GB SSD, PostgreSQL on its own volume
Daemon1 vCPU512 MBnegligible

The daemon is light, but it needs to be able to send an ICMP echo and to have layer-2 reachability to anything it should discover by ARP. See Deploying daemons.

Install

bash
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 use the ${VAR:?} form in the compose file, which means compose aborts rather than starting with a blank value.

bash
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:

bash
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, complete first-run setup, create a daemon under Settings → Daemons, copy its key into LOOMSCOPE_DAEMON_API_KEY, then:

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

The full variable list is in Configuration.

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.

bash
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 behind.

The Kubernetes path does this differently: a Helm hook Job runs once, before the Deployments are touched. See Kubernetes.

Using an external database

Point DATABASE_URL at your own PostgreSQL 17 and stop starting the postgres service. Nothing else changes — the control plane holds no state of its own.

Two things the database needs:

  • max_connections headroom. Each control-plane process opens up to LOOMSCOPE_DB_POOL_MAX connections (default 25) across its pools, and the worker holds one per scheduled job on top of that. Budget (servers + workers + 1) × pool_max and leave room for a psql session and for a rolling deploy running old and new processes at once.
  • The pgcrypto extension, used for credential encryption at rest. The migrations create it; the role running them needs to be allowed to.

Starting only some services

The compose file has no profiles — services are named, so start what you want:

bash
# Control plane only, e.g. while a daemon is deployed elsewhere
docker compose -f infra/docker-compose.yml up -d postgres server worker

# Everything including the CVE mirror worker
docker compose -f infra/docker-compose.yml up -d

LOOMSCOPE_JOBS_IN_WEB is set to false for the server service because the worker service owns scheduled work. If you deliberately run without a worker container, unset it — the default is on, so a deployment that has not yet added the worker still runs its scans rather than silently running none.

What the compose file deliberately does not do

  • No TLS. Port 3000 is published in cleartext. Put a reverse proxy in front; see TLS and reverse proxy.
  • No resource limits. A runaway scan can consume the host. Set them for anything shared.
  • No published PostgreSQL port. Keep it that way.
  • No privileged: true anywhere. The daemon drops every capability except NET_RAW, runs read-only with no-new-privileges, and mounts its config read-only.

Next steps