Install on Kubernetes with Helm
Install and run
A chart install from the private registry, end to end.
Deploys the receiver, which every source reports to, and the portal, which reads the findings back. Both are in the chart and the portal is enabled by default; set portal.enabled=false if you want the receiver alone.
If you have no cluster, you do not need one: see the routes.
Prerequisites
A cluster you can deploy to. Anything conformant; nothing here is cloud specific.
A log pipeline that ingests container stdout. The dashboard assumes Loki, but the receiver's only output is JSON lines, so anything that scrapes stdout works.
An ingress your endpoints can reach. Collectors report from off your network, so the receiver has to be reachable, with TLS and a real certificate.
Outbound HTTPS to
registry.nyxus.co.ukfrom the receiver and the portal. This is where the images and the chart come from, and where the receiver confirms the subscription. Blocked, a deployment keeps working for fourteen days and then stops accepting new findings and new enrolments - it says so in the portal from the first day, but a firewall rule missed at install is a fortnight's delay before anybody notices. Worth adding to the allowlist at the same time as Entra and your MDM.
The short way: nyxusctl install
One command makes the namespace, builds the pull secret from your licence key, installs the chart, and tells you what to do next. It is published per release to the same registry the images come from, with the same key as the credential:
It needs Python 3.10 or newer, pipx, and a kubectl and helm that already reach the cluster you mean. --force matters: pipx install does nothing at all when the command is already installed, and still exits successfully. A shell you opened before pipx ensurepath needs reopening before nyxusctl is found.
--kubernetes is worth passing explicitly. Without it the command asks the machine rather than you - 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, because the two produce different deployments.
Before anything changes it checks what would stop the install and reports all of it together rather than one failed command at a time: the two binaries, a cluster kubectl can actually reach, and a release called nyxus already in the namespace. --dry-run prints the plan and the exact commands and installs nothing, which is the honest way to see what you are about to run.
It does not ask for hostnames, deliberately. The chart's own default is no ingress, so a plain install gives you a deployment you reach by port-forward, sign into, and finish in the portal - the shortest path from a key to a running console. A guessed hostname would be worse than none: an ingress pointed at a name that does not resolve is a deployment that looks installed and cannot be reached, and every collector download would have that name baked in.
When you do know them, nyxusctl install --kubernetes -a asks the four things only this deployment knows - the storage class, the address your machines will report to, the address for the console, and the ingress class. Every one may be left empty, and an empty answer is the chart's default rather than a blank written into the release. Leave the receiver's address empty and it stops asking about the ingress at all, because an address for the console means nothing without one for the receiver.
--namespace, --context and --version cover a cluster where the defaults are wrong; they are nyxus, the current context, and the command's own version. The licence key is read from NYXUS_KEY or --key-file - prefer either to --key, which is visible to anyone who can list processes on the machine for as long as the command runs.
Then read the one-time setup code, which is the part an install usually leaves out: a running deployment nobody can sign into is not a finished install. The command prints the exact kubectl line for your namespace when it is done.
With Helm yourself
The same deployment, if you would rather run it. No clone - the chart is published as an OCI artifact beside the images. Only what creates cluster objects goes on the command line (the ingresses); everything else is configured in the portal afterwards:
The chart pins both images by digest for its own appVersion, so that install gets exactly the bytes the release was tested as. Nothing above turns it on and nothing needs to be remembered: it is what a plain helm install does. To see what you are about to run:
That matters more here than it does for most charts. The registry these images come from is named by your licence key and reached over the network, which puts it in the supply chain of this deployment. A tag is a pointer and can be moved to different bytes; a digest is the bytes. If the registry ever serves something else, a pinned pull fails instead of quietly running it.
(On a Tailscale-operator cluster: ingress.className=tailscale and portal.ingress.className=tailscale with short hostnames like nyxus - the operator serves them at https://<host>.<tailnet>.ts.net.)
Then read the one-time setup code the receiver printed:
Open the portal, enter the code, create the admin account, and the first-run wizard takes it from there: the public receiver URL (with a probe that warns about names other machines cannot resolve - on Tailscale it must be the full .ts.net address, not the short ingress name), the log store (self-hosted or Grafana Cloud, with connection tests both ways), corporate domains, a governance baseline, the extension ID, and pre-configured deployment downloads for every surface. Nothing after helm install touches a values file, and everything the wizard sets is editable later under Settings.
Making the hostnames resolve
The chart creates the routing rules inside the cluster; it cannot make nyxus.example.com resolve from the internet. That part is DNS you own:
Your ingress controller sits behind one entry point - a load balancer IP, or a hostname on a cloud provider.
kubectl get ingress -n nyxusshows it under ADDRESS once the chart is installed.Point both names at it: an A record to the IP, or an ALIAS/CNAME to the hostname (in Route 53, an alias to the ELB). Both hosts go to the same place - the ingress controller routes by the Host header, which is how two names share one entry point. A wildcard record (
*.example.com) does the same job once for every service you will ever add.TLS comes after DNS, not before: cert-manager with Let's Encrypt can only issue a certificate for a name that already resolves. Set
ingress.tls.secretNameto what it issues.
Teams that automate step 2 run external-dns, which watches Ingress resources and writes the records itself.
Two audiences, one nuance: the receiver's name must resolve from every machine that reports - a laptop on hotel wifi included - so it needs real public DNS. The portal's name only has to resolve for the people who open it, so internal DNS or VPN-only is a legitimate choice there.
The Tailscale route above skips all of this: the operator registers the names in your tailnet's own DNS and issues the certificates itself, with nothing exposed publicly. The wizard's probe button is the check either way - it tells you when a saved name does not resolve or the certificate does not match.
Reading the shared token
Every source that has not enrolled authenticates with one token the chart generated. The portal's pre-configured downloads carry it already; by hand it is:
The chart's own README - helm show readme oci://registry.nyxus.co.uk/nyxus-enterprise/charts/nyxus - covers every value, including bringing your own token or your own registry ConfigMap, and which version number means what. (A checked-out repo installs the same chart from charts/nyxus instead of the OCI reference.)
Where classic mode went
Nyxus used to have a second mode: configuration in files and environment variables, no server-side state, basic auth on the console. It is gone, and what replaced it is the only mode - enrollment, accounts, central settings and the fleet are always on, backed by SQLite on a volume.
managed.enabled=false is refused rather than ignored: the chart stops the render and says so. A deployment that had deliberately done without server-side state must not quietly acquire a database on an upgrade, which is the one thing a silently ignored value would do here.
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.
Upgrading an existing deployment: remove managed.enabled from your values and nothing else changes. portal.auth.* is no longer read and is left alone rather than refused - it was already inert, because the admin account was what guarded the console - and the chart's install notes list anything in your values that has stopped meaning something. Refusing those would fail helm upgrade --reset-then-reuse-values for every deployment in existence, in exchange for no safety at all.
Then continue at getting started.
By hand
The same thing without Helm, if you would rather see the pieces.
The images
Built and published by CI, and served to a licensed deployment by registry.nyxus.co.uk, so nothing needs building. The licence key is the credential: sign in to that host and the images resolve like any other.
Built for linux/amd64 and linux/arm64 under one tag, so it resolves on Graviton, Apple Silicon and a Pi without anything extra:
Pin by digest, not by tag. latest moves when a release is tagged, and even a version tag is a pointer somebody can repoint - so a pod rescheduling onto a node without the image cached can quietly pick up different bytes under a name that has not changed. The Helm path does this for you; by hand it is:
Every image and the chart are signed. Check one against the Nyxus public key, nyxus-images.pub, which is printed in the chart's README (helm show readme nyxus/nyxus), before you deploy it. That key signs 0.2.0 and later; 0.1.0 was signed with nyxus-images-0.1.0.pub, which is published alongside it so those images stay checkable:
--insecure-ignore-tlog=true because Nyxus signs without a public transparency log; the signature is still checked against the key. The image's SBOM is in its index: docker buildx imagetools inspect <image> --format '{{ json .SBOM }}'.
1. Namespace and token
Pick a namespace and use it consistently: the Secret, the ConfigMap and the Deployment all have to land in the same one.
Every source authenticates with this one token: collectors, scanners and the browser extension. (Each of those can carry an enrollment token instead and hold a credential of its own, which is what makes one machine, one browser profile or one scanner revocable on its own - see below.)
2. The registry
The list of AI tools to detect. Collectors fetch it from the receiver at runtime, so it lives in a ConfigMap the receiver serves.
build.py needs pyyaml and jsonschema. On current Homebrew or Debian Python (PEP 668), use a venv:
The receiver reads the registry per request and the kubelet syncs ConfigMap changes into the pod, so updating it later is a rebuild and a kubectl apply with no restart.
3. The receiver
Deploy it with the Secret mounted as AUTH_TOKEN and the ConfigMap at /etc/nyxus. It listens on 8080.
helm template renders a working receiver manifest to adapt, and How Nyxus is put together describes every variable and endpoint.
If you deploy before the ConfigMap exists, the pod sits in ContainerCreating with a FailedMount event. That is normal: create the ConfigMap and the kubelet mounts it within a minute, no restart needed.
Optional, all off by default:
LOKI_PUSH_URL- the full push endpoint,/loki/api/v1/push, not the base URL. Unset means stdout only, for pipelines that scrape logs.LOKI_USERNAMEandLOKI_PASSWORD- basic auth for a hosted log store.ALERTMANAGER_URL- unset means findings are logged and dashboarded but nothing pages. With companies it is the hosting company's; see Companies.DISPLAY_TZ- timezone for the human-readable timestamp on alerts.CORP_DOMAINS(chart valuecorpDomains) - corporate domains, served to the endpoint collectors inside/registry/collector. Collectors prefer this list to their locally configured one, so a change here reaches the fleet on its next check-in with no MDM re-push per platform.
Not optional, and not a setting: device enrollment, per-device credentials and revocation are always on, backed by SQLite at STATE_DB_PATH - a PVC on the chart, a named volume on Compose. On first boot with no account the receiver prints a one-time setup code to its log (kubectl logs deploy/<release>-nyxus | grep setup_code - NOTES prints the exact command), and the portal's first screen turns it into the admin account. From there the console shows the fleet, mints and revokes enrollment tokens, and downloads pre-configured collector scripts. No admin secret is provisioned by default; set managed.adminToken.value if automation drives the /admin API, or as break-glass when the portal password is lost. The portal has its own narrow credential, generated by the chart, for what it reads server-side. Enrollment tokens go wherever the shared token goes today - collector parameters, the extension's managed policy, the scanner Secret - one surface at a time; each enrolls and holds its own credential. Once every surface has, managed.requireDeviceCredentials: true turns the shared token off for ingest. See How Nyxus is put together for the details.
Confirm it is up:
And that it serves the registry:
The portal
Enabled by default and deployed alongside the receiver. It reads findings back out of the log store, so it needs to be told where that is:
That is the BASE url, not the push endpoint: the portal appends its own query path, while loki.pushUrl is POSTed to verbatim by the receiver. Two values, same host, and getting them crossed produces a receiver that stores findings and a portal that finds none.
A log store that needs credentials
Grafana Cloud and most hosted Loki want basic auth, and both containers need it: the receiver to write, the portal to read.
The username is a value; the password comes from a Secret, because a password passed as a value ends up in helm get values and in whatever holds your values file.
Setting them for one container and not the other produces a deployment where findings are stored and cannot be read back, and neither container reports an error you would attribute to the right cause. On Grafana Cloud the username is a numeric instance id rather than an email, and the access policy needs both logs:write and logs:read: a read-only token produces a 401 the receiver counts, logs, and answers to the collector as a 503 - so findings retry and are not stored. nyxus_loki_push_total staying at zero while findings arrive is that failure.
Upgrading
System health tells you when a newer release exists, with the helm upgrade command for your release and namespace filled in. Two ways to apply it:
By hand. --reset-then-reuse-values keeps everything you set at install and still takes the new release’s own defaults — which matters here, because this chart pins its images by digest. With --reuse-values those pinned digests survive the upgrade: Helm reports success, helm list shows the new version, and the pods keep running the release before it.
Then check what is actually running rather than trusting the success message — the receiver answers its own version, and System health names it:
The scanner and discovery CronJobs are outside the chart; set their image tags to the same version.
Or from your machine with nyxusctl, which does exactly that with your own kubeconfig, finds the CronJobs by their image, shows the plan, waits for an owner to approve in the portal, and reports progress to System health:
--force matters: pipx install does nothing at all when the command is already installed — it says so and exits successfully, which reads like it worked, and leaves you running the old one. Check with nyxusctl --version rather than trusting the install.
Download the wheel from the portal first, under System health › Updates › Upgrade from your own machine, as an owner or administrator of the company hosting Nyxus. It needs Python 3.10 or newer and pipx on the machine that runs it; a shell opened before pipx ensurepath needs reopening before the command is found. Nothing in the cluster gains a right either way; see the security guidance on upgrading.
Authentication
The portal names who runs what on which machine, so it refuses to start without authentication. There is nothing to configure here and no password in Helm values: it signs in with the receiver's admin account, created from the setup code the receiver prints to its log on first boot (see the short way above).
Basic auth, a username and a chart-generated password, was classic mode's and is gone with it. portal.auth.* is no longer read; a values file that still carries it is left alone rather than refused, and the chart's install notes say which of your values have stopped meaning anything.
One setting remains. If your ingress can do OIDC or mTLS, do it there and set portal.auth.mode=none behind it - reads are then open to whoever your proxy let through, admin actions still need the login, and the portal logs a warning on every start. Correct behind something that authenticates for it; wrong on anything reachable.
Sign-in can also be federated to Microsoft Entra, configured from the portal rather than from values: there is nothing to set here, and the wizard checks each step against the tenant as you go. Once an owner has signed in that way you can require it, which puts your conditional access in front of the portal. The account the setup code created keeps its password as the break-glass path, so make sure that password is stored somewhere reachable without this portal before you turn enforcement on.
Outbound mail
Optional, and worth setting here rather than in the portal when the relay credential is already a Secret in this cluster - which it usually is, because whatever else you run that sends mail is reading one already:
An in-cluster relay normally wants exactly that shape: no username, no password, security none, because it decides what to accept by the sending pod rather than by a credential. For a relay that does want one, point at the Secret you already have rather than pasting the password anywhere:
Every field is also settable from the portal, and a value saved there wins - the same precedence every other setting in this chart follows. It is used to tell somebody an account has been made for them, and nothing else; with none of it set, accounts are created exactly as before.
Governance and identity
Both optional, both ConfigMaps:
The first records approvals, owners and review dates (governance). The second maps device keys to people, so reports carry a name rather than a serial. Without either, the register falls back to the registry's own approved flag and devices show as serials.
Backups
Nothing is backed up until the receiver has a key. Make one, keep a copy somewhere other than the cluster, and give it to the receiver as a Secret:
Then choose where backups go under Deployment › Backups in the portal. Backups covers the key, S3 and Azure, and restoring.
When findings stop arriving
With a push target configured, a failed push is a 503 and collectors retry; the failure is also visible where the receiver writes to the log store fails. That is deliberate - a log store being down should not lose a collector's finding, which is on stdout regardless - but it means a misconfigured push is invisible from the collector's side.
Push failures log at error with the URL and, for the codes that account for almost every misconfiguration, the likely cause. A 404 usually means LOKI_PUSH_URL is the base URL rather than the push endpoint.
nyxus_loki_push_last_success_timestamp on /metrics is the one to alert on: findings arrive irregularly, so a failure counter alone cannot tell "nothing is happening" from "everything is failing".
For failures outside the log pipeline, including chart installs that stop on existing resources, ImagePullBackOff, and sources the portal lists as silent while they are sending, see Troubleshooting.
Then
Getting started picks up here: rolling out a collector, seeing the first finding, the dashboard, and the portal.