Nexus explainer · companion to One host, many tenants
Nexus runs on systemd units and cgroup slices, not Kubernetes. That is a choice for its current scale: one client and one production host. Most controls Nexus relies on have a Kubernetes counterpart, and a few behave differently enough to matter for a trading system. This page maps one onto the other, sets out what Kubernetes would add and what it would cost, and says when I would move.
Today's figures read from the production host on 2026-09-30, read-only, as on the One host and Scheduling pages.
On the left, how Nexus separates its workloads today. On the right, a sketch of the same workloads on a small cluster. The boundaries move from Linux users and cgroup slices to namespaces, nodes and resource requests.
Each row is something Nexus does today, the Kubernetes way to do it, and what would change. Most map cleanly. The ones that don't are about single instances, memory floors and start order.
| Nexus today | Kubernetes | What changes | |
|---|---|---|---|
| A service | Type=simple, Restart=on-failure, its own Linux user | A Deployment; the kubelet restarts a failed container | Liveness probes also restart a process that hangs while staying alive. systemd's WatchdogSec= does the same, but no Nexus unit uses it today, so in practice this is a gain. Repeated crashes back off to five minutes between restarts by default, where systemd retries every 10 seconds. |
| One at a time | The broker price capture and the trading scheduler each run as one unit on one host | replicas: 1 with strategy: Recreate, or a StatefulSet; a Lease for leader election | The default rolling update starts the new pod before stopping the old one, so replicas: 1 still gives two pods for a few seconds. Recreate covers rollouts only: a deleted or evicted pod is replaced while the old one is still terminating. A StatefulSet waits until the old pod is confirmed gone, unless someone force-deletes it. A lease is only safe with a fencing check where orders are placed. Two broker sessions, or two loops placing orders, even for seconds, is the failure a trading system most needs to rule out. |
| Schedule | OnCalendar=Mon..Fri 17:30 Europe/London; the 15-minute fetch fires 20 seconds past the quarter-hour | A CronJob with timeZone | Both are time-zone aware. Cron schedules have no seconds field, so the 20-second offset becomes a sleep at the start of the job. A pod also takes seconds to schedule and start, where a systemd oneshot starts almost at once. |
| Overlap | A fire while the job still runs is absorbed | concurrencyPolicy: Forbid | Close, not equal. Forbid defers rather than drops: unless a starting deadline has passed, the skipped run starts as soon as the last one finishes. A failed Job retries up to six times by default, where a failed oneshot stops and alerts, and a CronJob creates about one Job per run, so jobs must be idempotent. |
| Missed runs | Persistent=true runs once at boot; false where a late run would be wrong | startingDeadlineSeconds: unset, the controller starts the most recent missed run however late; set, only inside the deadline | Close in intent: no deadline behaves like Persistent=true, a short one like false. The end-of-day snapshot that must not run late gets a short deadline, of at least 10 seconds as the documentation advises. |
| Start order | After=postgresql on 64 units, added after catch-up runs fired before the database was up | No start order between pods; init containers, or retries in the service | Each service waits for its own dependencies. That is more robust, but it is code to write. |
| Failure alerts | OnFailure= on every first-party service, to one template unit that posts the alert | Nothing built in beyond Events and Job status: Prometheus, kube-state-metrics and Alertmanager rules on failed Jobs and restarting pods | More parts to run. Alert rules become configuration rather than per-unit wiring. |
| CPU share | CPUWeight=20 keeps backtests behind trading | CPU requests set the cgroup weight; a PriorityClass orders scheduling and eviction | Equivalent. CPU limits, unlike weights, throttle in 100 ms periods, so a latency-sensitive pod gets requests without limits, or whole cores under the static CPU manager. Whole cores need Guaranteed QoS: integer CPU, with requests equal to limits. |
| Memory floor | Postgres MemoryMin=18G with no ceiling, held all the way up the tree | Requests reserve memory when a pod is placed; a run-time floor through memory.min is not on by default | The closest fit is Guaranteed QoS: requests equal to limits for memory and CPU. That adds the memory ceiling Nexus left off on purpose, and a CPU limit unless it gets whole cores. In return the pod is evicted last and is the OOM killer's last choice, and on a node without swap its buffer pool can't be swapped out, the failure the floor guards against today. A pod that grows past its request is among the first evicted under node memory pressure. |
| Heavy jobs | A capped transient scope per job, swap off; a memory gate refuses new work when headroom is low | A Job with memory limits; a pod waits as Pending until a node has room; ResourceQuota per namespace | Partly a gain. The scheduler admits by declared requests, not measured memory or pressure, so it replaces Nexus's gate only if requests are honest. Kueue adds queueing and quotas for batch Jobs. |
| Swap | MemorySwapMax=0 on agent jobs, after a host swapped for 100 minutes on 6 September | Nodes have traditionally run with swap off; swap is opt-in per node | The lesson Nexus learned the hard way is Kubernetes' default. |
| Identity, secrets | One Linux user per service; secrets in a file only it can read; ProtectSystem, NoNewPrivileges | ServiceAccounts and RBAC; Secrets; securityContext and the restricted Pod Security profile | Close, with two catches. Secrets sit unencrypted in etcd unless encryption at rest is configured; base64 in a manifest is encoding, not protection. And anyone who can create a pod in a namespace can mount its Secrets, so per-service namespaces do the job the per-service Linux users do now. |
| Privileged actions | An agent drops a request file; a root path unit runs a job only if its name is on a root-owned allow-list | RBAC that lets one account create Jobs in one namespace, plus an admission policy on images and commands | RBAC controls who may create a Job, not what it runs, and a Job can run as any ServiceAccount in its namespace. The allow-list moves into admission policy, such as a ValidatingAdmissionPolicy that accepts only listed images and commands. |
| Deploy | A queued deploy ships the merged tree, runs the service's deploy gate and a health check, then restarts its units | CI builds an image and pushes it to a registry; Argo CD or Flux applies the manifest from git | The deploy is an image digest, and rollback is the previous one, though a database migration does not roll back with it. Nothing is copied onto a host. |
| Logs | journald per unit, 30 days, 16 GB cap | stdout per container, rotated on the node; Loki or Elasticsearch for retention | One more system to run for the same 30 days. |
| CI runners | 18 runners in their own slice with an 8 GiB floor | Actions Runner Controller: one short-lived runner pod per job | Runners scale to zero when idle and burst across nodes. |
| Work queues | Postgres FOR UPDATE SKIP LOCKED; a supervisor keeps N backtest workers alive | The queue is unchanged; workers become a Deployment scaled on queue depth by KEDA's PostgreSQL scaler | Workers pull their work, so they can run on any node without a message broker. Two things change: the reset of orphaned runs at start is only safe with one instance, so runs need a heartbeat or lease; and a scale-down stops a worker mid-backtest, so a run must be requeued on SIGTERM. |
Containers change how software is shipped. Kubernetes changes how it is run, and where. A container can run on one host without Kubernetes, in two common ways, and for Nexus they differ more than they look: one keeps the scheduling, ordering, alerting and cgroup controls in the first column of the table above, and the other hands most of them to a daemon.
[Unit] After=postgresql.service OnFailure=nexus-unit-alert@%n.service [Container] Image=registry/nexus-data-api@sha256:… EnvironmentFile=…/data-api.env ReadOnly=true [Service] Restart=on-failure CPUWeight=100 [Install] WantedBy=multi-user.target
Podman has no daemon. A .container file like this one becomes an ordinary systemd service when systemd loads its units, and the container's processes run in a cgroup beneath that service's. Timers, OnFailure=, start order, memory floors and CPU weights apply exactly as they do today. What changes is what the unit starts: an image built once in CI and pinned by digest, rather than code on the host. This is a rootful unit, in /etc/containers/systemd. The per-service Linux user moves into the container, with User= and UserNS=auto, and unit sandboxing such as ProtectSystem gives way to the container's own options. Rootless Quadlet runs under each user's own systemd manager, which the system slices, After=postgresql.service and the alert template don't reach.
services:
api:
image: registry/app@sha256:…
restart: unless-stopped
env_file: .env
read_only: true
healthcheck:
test: ["CMD", "/app/healthcheck"]
deploy:
resources:
limits: { memory: 512M }
reservations: { memory: 256M }
An illustrative file. The Docker daemon starts, restarts and owns the containers and their cgroups. One file describes a set of services, and docker compose up -d brings them to that state. Nexus's alert service runs on Compose on its own server, where it fits: a small stack, no timers, nothing running next to trading.
| Nexus today | Under systemd (Quadlet) | Docker Compose | |
|---|---|---|---|
| Deploy | The merged tree is shipped to the host and units restart | Pull the image by digest, change the digest in the unit file, reload systemd and restart the unit | Pull an image and docker compose up -d, which stops the old container and starts the new one |
| Rollback | Deploy the previous commit | Point the unit at the previous digest | Point the file at the previous digest |
| Dependencies | Installed on the host, on its Python and system libraries | Baked into the image; the host's Python and libraries stop mattering | |
| Filesystem | ProtectSystem makes the host read-only around one writable path | Each service sees only its own image and the volumes mounted into it | |
| Scheduled jobs | 62 timers start oneshot services | Unchanged: a timer starts a containerised oneshot (Type=oneshot in the file's [Service]) like any other service | No scheduler. Timers or cron on the host run docker compose run, or a scheduler runs in its own container; the job's container then runs in Docker's cgroup, not the timer's service, so that unit's limits don't reach it |
| Restarts, alerts | Restart=, and OnFailure= on every first-party service | Unchanged. HealthCmd= with HealthOnFailure=kill lets Restart= recover a hung process too | Restart policies retry with a back-off; unless-stopped never gives up, so nothing reaches a failed state. A failing health check only marks the container unhealthy, and outside Swarm nothing restarts it. No failure hook: alerting watches the daemon's events or the containers from outside |
| Memory floor, CPU | MemoryMin=, MemoryMax=, CPUWeight= on each unit, set directly | Unchanged, on the unit | Limits and a soft reservation (memory.low) per container, set through Docker, which has no hard-floor option. A floor means cgroup_parent into a slice configured in systemd, outside Compose |
| Logs | journald per unit | journald, under the unit's name | Docker's log driver: JSON files by default, unrotated unless max-size is set, or journald if configured |
| One instance | One unit, one process tree | Unchanged | One container per service unless scaled; replaced, not doubled, on deploy |
| Network | Services listen on the host; the host firewall applies | Host networking keeps today's behaviour. On Podman's own networks, rootful published ports are NAT rules too, so name the bind address there as well | Docker's NAT rules run before the host firewall, so a published port must name its bind address |
| In Nexus | Every production service | The likely next step for the production host | The alert service, on its own server |
For Nexus, Quadlet is the better middle step: it brings images, digests and rollbacks without giving up the cgroup controls the production host depends on. Compose stays where it already fits. Either way, the images built for this step are the same images Kubernetes would run.
One client and one production host. The database dominates memory, and cgroups are controlled directly, next to trading. That describes Nexus today.
When a second host joins the production path. Build an image per service in CI and run it under systemd with Podman Quadlet, as in the middle step. The deploy becomes an image, and the cgroup controls stay as they are.
When several clients need isolation from each other (namespaces with NetworkPolicy for soft separation, a cluster per client for hard), when backtests outgrow one machine, when trading needs failover to a second machine, or when a client's platform team already runs Kubernetes.
Much of the design already maps. Each service has its own account and its own database login. The work queues live in Postgres and need no broker. Configuration sits in environment files, which become ConfigMaps and Secrets. The order below moves the stateless parts first and the trader last.
Sources: Nexus figures from the One host, Scheduling, CI and Alerts pages, read from the production host on 2026-09-30 (systemctl, cgroupfs, pg_stat_database). Kubernetes behaviour from the Kubernetes documentation on workloads, CronJobs, resource management, Pod quality of service, node-pressure eviction, RBAC and Pod Security Standards, and from the KEDA, Actions Runner Controller and Podman Quadlet documentation. Nexus does not run Kubernetes; nothing in the Kubernetes column has been measured here.