Docker Compose
The reference installation
Edit this pageOn 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
| Service | Required | Image | Notes |
|---|---|---|---|
postgres | Yes | postgres:17-alpine | Not published on the host. Every piece of state lives here |
server | Yes | ghcr.io/bendaamerahmed/loomscope-server | UI, REST API, cloud collectors. Stateless |
worker | Yes | ghcr.io/bendaamerahmed/loomscope-server | Same image, scheduled work only |
daemon | At least one | ghcr.io/bendaamerahmed/loomscope-daemon | Host networking, on the network it scans |
cve-ingest | No | ghcr.io/bendaamerahmed/loomscope-cve-ingest | Only 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:
echo "$GITHUB_TOKEN" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdinThe 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:
docker compose -f infra/docker-compose.yml buildThat is also the honest answer for an air-gapped install where the registry is unreachable by design.
Requirements
| CPU | RAM | Disk | |
|---|---|---|---|
| Up to ~5 000 hosts | 4 vCPU | 8 GB | 40 GB |
| Tens of thousands | 8 vCPU | 16 GB | 200 GB SSD, PostgreSQL on its own volume |
| Daemon | 1 vCPU | 512 MB | negligible |
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
git clone https://github.com/bendaamerahmed/loomscope.git
cd loomscope
cp .env.example .envFill 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.
openssl rand -hex 32 # BETTER_AUTH_SECRET
openssl rand -base64 32 # LOOMSCOPE_KMS_KEY
openssl rand -hex 24 # POSTGRES_PASSWORDBring 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.tsSign in at http://localhost:3000, complete first-run setup, create a daemon
under Settings → Daemons, copy its key into LOOMSCOPE_DAEMON_API_KEY,
then:
docker compose -f infra/docker-compose.yml up -d daemon workerThe 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.
docker compose -f infra/docker-compose.yml run --rm server node --experimental-strip-types apps/server/server/db/migrate.tsThe 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_connectionsheadroom. Each control-plane process opens up toLOOMSCOPE_DB_POOL_MAXconnections (default 25) across its pools, and the worker holds one per scheduled job on top of that. Budget(servers + workers + 1) × pool_maxand leave room for apsqlsession and for a rolling deploy running old and new processes at once.- The
pgcryptoextension, 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:
# 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 -dLOOMSCOPE_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: trueanywhere. The daemon drops every capability exceptNET_RAW, runs read-only withno-new-privileges, and mounts its config read-only.
Next steps
- Configuration — every environment variable
- Deploying daemons — capabilities, systemd, one per site
- TLS and reverse proxy
- Backup and restore — before you need it