Install with Docker Compose
Install and run
The smaller deployment, and what it gives up.
Nyxus is two stateless containers. The receiver accepts findings and writes
them to your log store; the portal reads them back and derives everything you
see. Neither needs an orchestrator, and this route exists because requiring
Kubernetes to run two stateless containers is a barrier rather than a design.
If you already run Kubernetes, install with the chart
instead. Both deploy the same images.
Before you start
Sign in to the registry. The images are private, and your licence key is
the registry credential. nyxusctl install below does this for you; by
hand it is:
Without it, the first pull fails with an authorisation error. That is the
expected answer to an unlicensed install rather than a fault to diagnose.
Allow outbound HTTPS to registry.nyxus.co.uk from both containers. It is
where the images come from and where the receiver confirms the subscription.
If it is blocked, the deployment keeps working for fourteen days and then
stops accepting new findings and new enrolments. The portal says so from the
first day - but a firewall rule missed at install is a fortnight's delay
before anybody notices. Add it to the allowlist at the same time as Entra and
your MDM.
What it does not include
A log store. Production points at whatever you already run, because most
people deploying this have one, and adding a second is not a favour.
If you have neither Loki nor Grafana, a profile in the compose file that comes
with your subscription starts both:
The short way: nyxusctl install --compose
One command writes the stack and brings it up: the compose file, the compiled
registry, three generated secrets, and a volume for the receiver's state. It is
published per release to the registry the images come from, with your licence key
as the credential:
It needs Python 3.10 or newer, pipx, and a reachable Docker daemon
with docker compose. --force matters: pipx install
does nothing at all when the command is already installed, and still exits
successfully. --directory writes somewhere other than the current one,
and --dry-run prints the plan and writes nothing.
Pass --compose. Without a flag the command asks the machine which
route it is - a reachable cluster means Kubernetes, Docker alone means Compose -
and a machine that can do both is told to choose rather than having one picked
for it.
It signs in to the registry itself with that key, before anything is written,
so a refused key costs nothing to clean up, and then runs one docker compose up.
-d
Before anything is written it checks what would stop it: the daemon, docker itself, both loopback ports, and a
composedocker-compose.yml already in the
directory. An existing project is refused rather than half written over, which
is why the command above makes an empty directory first.
What it writes is yours to keep and to read: docker compose reads it again on
every restart, and an upgrade is the same file with a new IMAGE_TAG. It is
written before the stack starts rather than generated by it, so what is running
can be reviewed and committed afterwards.
Two things it deliberately leaves out. No log store, because production points
at whatever you already run - give it one with install -a, or in the portal
afterwards. No scanner and no discovery job: those need credentials for the
systems they scan, and an install is not where you paste them. The compose file
that comes with a subscription carries both as services; add them to this one when
you are ready, or run that file instead.
nyxusctl install --compose -a asks what only this deployment knows - the log
store's base URL, the public address your machines will report to, your corporate
email domains, and the two loopback ports. Every one may be left empty and set in
the portal later.
Setting it up by hand
The compose file and its .env.example come with your subscription. Put them in a
directory of their own, create the secrets, edit the environment, start it:
The receiver listens on 127.0.0.1:8080 and the portal on 127.0.0.1:8091.
Both bind to loopback deliberately: reachable is a decision you make with a
reverse proxy that terminates TLS in front, not something a compose file makes
for you.
Open the portal, enter the setup code, create the first account, and the
first-run wizard does the rest - the log store included.
One thing to check in that file. The copy in the repository compiles the
registry from a checkout, in a one-shot service that mounts the repository root
and that the receiver waits for. Without that checkout the service fails, the
receiver never starts, and the console shows no tools at all - a stack that looks
installed and is not. If what you have is only a compose file, either usenyxusctl install --compose above, which writes the compiled registry beside
it, or replace that service with a mount of registry.json andcollector.json.
The three secrets
auth_tokenis the receiver's shared bearer, used by anything reporting
that has not enrolled.portal_tokenis the portal's own credential for the receiver: the reads a
page is built from, and recording estate history. Nobody needs to know its
value; both containers read the file.report_signing_keysigns evidence snapshots and the AI inventory. Keep
a copy. A report signed with it can only be checked against its public
key, which System health shows.
That chown line
Both images run as uid 65532, and a Linux bind mount preserves ownership - so
a secret file only its owner can read is a file the container cannot read.
Mode 640 with group 65532 means the container can read it and other users
on the host cannot; chmod 700 secrets stops anyone listing the directory.
Both containers refuse to start if a secret is missing or unreadable. That is
deliberate: falling back would start the receiver with an empty token, or the
portal with an empty password that an empty input would match. It is correct,
and it looks like nothing happening, so it is worth knowing.
Docker Desktop on macOS and Windows maps ownership into its VM, so 600
works there and fails on a Linux host. Worth knowing if you develop on a Mac
and deploy on Linux.
Secrets are files, not environment variables
An environment variable is visible in docker inspect, in /proc, and in the
environment of every child process. A file is confined to the filesystem.
That is better and it is not encryption. It is still plaintext on the same
disk, and the mode on the directory is what protects it. If you want more, both
containers read whatever is at those paths - a tmpfs populated by your
configuration management, or a Vault agent sidecar writing the file, work
without changing anything here.
Pointing at a log store you already run
Two variables, and they are not the same value:
LOKI_PUSH_URLis the full push endpoint the receiver posts to
verbatim, ending/loki/api/v1/push. Posting to the base URL returns 404.LOKI_URLis the base URL the portal appends its own query path to.
LOKI_USERNAME and LOKI_PASSWORD are used by both containers - the
receiver to write, the portal to read. Set them for one and not the other and
you get a deployment where findings are stored and cannot be read back, and
neither container reports an error you would attribute to the right cause.
Three things about Grafana Cloud specifically, each of which fails in a way
that points somewhere else:
The username is a numeric instance id, not an email address. It is on
the Loki details page for your stack.The push URL and the base URL are the same host with two different paths,
as above.The access policy needs both
logs:writeandlogs:read. A read-only
token produces a 401 on every push.
Check rather than assume:
A push_total that stays at zero while findings arrive is that failure.
When findings stop arriving
A failed push answers the reporting source with a 503, not a quiet 200, so
collectors keep the finding and retry on their next run. A store outage delays
evidence rather than losing it.
To see the failure itself:
Push failures are logged with the URL and, for the two status codes that
account for almost every misconfiguration, the likely cause. If collectors are
reporting POST failures, check the receiver's log for a log-store error before
anything else.
Where classic mode went
Nyxus used to have a second mode: configuration in files and environment
variables, basic auth on the console, no server-side state. It is gone, and
what replaced it is the only mode - the console gets a real login, and the log
store, corporate domains, governance and registry additions are all set in the
portal at runtime.
There is no docker-compose.classic.yml any more, and on Kubernetes the chart
refuses managed.enabled=false rather than ignoring it.
If files and no server-side state is what you want, that is Shadow AI
Guard, which is still maintained and still free: github.com/AmanSK5/shadow-ai-guard.
What else runs
The compose file that comes with your subscription also brings up the scanner
and the discovery job - the one nyxusctl install writes does not. Neither is
a cron entry on the host: each runs, sleeps and runs again, atNYXUS_RUN_INTERVAL - a day for the scanner, a week for discovery by default.
Their credentials - Entra, Jamf, SentinelOne - live in a separate env file
rather than in .env, so the file holding secrets is not the file you edit
for everything else.