Both daemons log handshake complete once the second one starts, and the ping runs. monitor is the terminal dashboard — per-link RTT, jitter, loss and each link’s live share, refreshed twice a second. It reads the same once-per-second JSON snapshot the browser dashboard and the REST API read, so nothing you see in one view can disagree with another. the whole CLI ↗
Two machines, two links, twenty minutes.
There is no download button on this site, and an install one-liner would be fiction: Nexus Atlas is proprietary, licensed by subscription, and builds are issued to people we have spoken to. So this page answers the question that actually stops people short of the contact form — what am I signing up for, and can I even run it? Everything below is what happens once a build lands: what you supply, what the daemon needs from a host, the smallest configuration that does something real, and what we would like to see when you are done.
Nothing you would have to order.
The entry bar is deliberately low, because an evaluation that needs a lab, a second site and a shipment of radios is an evaluation that never starts. The first run is meant to happen on hardware already on your desk.
Two Linux machines
Or one machine and a VM on it. They do not have to match — dissimilar is the entire point. A laptop and a single-board computer is a perfectly good pair, and so is a server and a virtual machine on the same server.
Two links, any kind
Anything that carries a UDP datagram. Ethernet beside Wi-Fi is enough. Two VLANs on one switch is enough. A tethered phone beside the office network is enough. Nothing in the first run needs a radio, a satellite terminal or a second site.
Root on both
The daemon creates a TUN device and pins each link’s socket to a named interface. Both are privileged operations. Run it as root for the evaluation; the reference systemd unit shows the hardened form for later.
About twenty minutes
Generate a keypair on each node, write seventeen lines of TOML on each side, prove the file loads, start the daemon, ping across the tunnel — then pull one of the two cables and watch nothing happen to the ping.
What it needs to run.
Collected here for the first time, from the shipping source and the reference packaging rather than from a datasheet. Where a figure has not been measured, this table says so instead of guessing.
| Requirement | What it actually is |
|---|---|
| Architecture | Linux on x86-64 or ARM64 — the two release targets are x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu, each built on its own native runner. There is no musl target today, which rules out Alpine and musl-only OpenWrt images. |
| Distribution | A glibc userland with OpenSSL 3 present — stock on Debian 12, Ubuntu 22.04, RHEL 9, Rocky 9 and anything newer, and absent on Debian 11 and Ubuntu 20.04. Those four are what the reference packaging is written and tested against; the Ansible role refuses to run outside the Debian and Red Hat families rather than guessing. |
| Kernel | No minimum version is documented, and there is nothing in the daemon that would create an interesting floor: no kernel module, no DKMS, no eBPF, no io_uring. It needs the TUN driver and ordinary socket options. The practical test is one line — if ls /dev/net/tun succeeds, the kernel side is finished. |
| Privileges | Two privileged operations: creating the TUN device (root, or CAP_NET_ADMIN) and pinning each link’s socket to its device with SO_BINDTODEVICE (root, or CAP_NET_RAW). Run it as root for an evaluation. The reference systemd unit is the hardened form for afterwards: a dedicated unprivileged user whose only capability is CAP_NET_ADMIN, everything else dropped from the bounding set, a default-deny syscall filter, and exactly three devices it may open. |
| Ports | One UDP port, which you choose — [interface] listen_port has no default, so the file has to name it. Every link binds that port unless the link overrides it with its own; two links that share a device and an address must differ, or the kernel load-balances the two sockets against each other. Validation catches that case by name. |
| Inbound | One reachable UDP port between the pair is enough. The daemon only ever initiates towards a peer it has an endpoint for, so the reachable end can be either one and the other may sit behind NAT — it learns its peer’s address from the first packet that authenticates, and a persistent_keepalive keeps the mapping open. On a desk both ends have addresses and none of this arises. When neither end is reachable — carrier-grade NAT on both sides — that is the job of the opt-in traversal stack, not of this first run. |
| Anything else running | Nothing. No controller, no orchestrator, no licence server, no phone-home, no database, no message bus. The dashboard and REST API are a separate process on TCP 9800, and the tunnel does not care whether you ever start it. systemd is optional too — the readiness, stopping and watchdog notifications become no-ops when there is no init socket to send them to, so the binary runs correctly in the foreground. That is how a first evaluation should start. |
| Disk | The binary, one config file, and logs with hard ceilings: the structured event journal is capped at 4 × 2 MiB per interface by default, and the crash spool at twenty reports or 8 MiB, oldest evicted. Nothing grows unbounded on a node you cannot reach. |
| CPU and memory | No figure has been measured and published, so this table will not invent one. What is known is below. |
Seventeen lines, and nothing left to remove.
The site has shown plenty of configuration and never the floor. Here it is: the smallest file that produces a real bonded tunnel between two peers over two links. Three tables, seventeen lines, and every key in it is one the config types carry no default for — delete a line and the daemon refuses to load. Everything else that makes this site interesting, strategies and classes and mesh and prediction, has a default, and the defaults are what run here. The full parameter surface is on operations.
[interface]
name = "nexus0"
address = "10.0.100.1/24" # this node, inside the tunnel
private_key = "…" # from `atlasd keygen`
listen_port = 51820
[[link]]
name = "wired"
bind_device = "eth0"
bind_address = "192.168.1.10"
[[link]]
name = "wifi"
bind_device = "wlan0"
bind_address = "192.168.2.10"
[[peer]]
public_key = "…" # node B's public key
endpoint = ["192.168.1.20:51820", "192.168.2.20:51820"]
allowed_ips = ["10.0.100.2/32"]Node B runs the mirror image: its own private key, address = "10.0.100.2/24", allowed_ips = ["10.0.100.1/32"], and node A’s two addresses as its endpoints. Both peers must know each other — there is no discovery in a point-to-point config. And give each link its own bind_address rather than 0.0.0.0: device pinning is best-effort by design, so if the process cannot set it the daemon warns and carries on, and a distinct source address is what keeps the two sockets separate anyway.
Then four commands
# 1 — identity, on each node. It prints the two lines ready to paste.
atlasd keygen
# 2 — prove the file loads, using the daemon's own pipeline minus the engine.
# Exit 0 = provably loadable, warnings listed. Exit 1 = fatal.
atlasd config check -c /etc/atlas/config.toml
# 3 — start it. Root, because it creates the TUN device and pins the sockets.
sudo atlasd run -c /etc/atlas/config.toml
# 4 — watch, from a second terminal, then prove it end to end.
atlasd monitor -i nexus0 -r 500
ping 10.0.100.2Now pull one of the two cables while the ping runs. That is the demonstration, and it takes ten seconds — the ping does not stop, because the other link was already carrying part of the stream and simply carries the rest. Everything else on this site is an elaboration of that one behaviour.
Bring the link you don’t trust.
This is where an evaluation stops being a tutorial. Add the bearer you would never put a production flow on by itself — the cellular modem that folds under load, the VSAT with 600 ms of path inside it, the serial radio that manages 64 kbps on a good day — as a third [[link]] on both nodes. Nothing else in the file changes.
[[link]]
name = "vsat"
bind_device = "eth1"
bind_address = "10.20.0.5"
capacity = "10 Mbps" # declared honestly, used mathematically
probe_interval_ms = 1000 # don't stack probes inside 600 ms of path
[[peer]]
# endpoints are positional — the third entry is the third link's
endpoint = ["192.168.1.20:51820", "192.168.2.20:51820", "10.20.0.9:51820"]Four lines and one honest number. Declare what the link really is — the scheduler divides fragment size by that declared capacity and adds the RTT it measures, so an honest figure is worth more to you than a flattering one — and give a high-latency bearer its own probe clock while you are there, because probing a geostationary path every 250 ms stacks probes inside the path latency and reads phantom loss. Then validate, restart, and the bond is three links wide. If the two nodes disagree about how many links exist, the config check on each side is where you find out, not the tunnel.
Then watch three specific things
It will not get a third of the traffic, and that is the point
At the default strategy every fragment goes to the path with the lowest size/capacity + RTT, so a thin or distant link earns the traffic it can actually carry and nothing more. Watch the per-link share move in atlasd monitor or on the dashboard while you load the tunnel. the eight strategies ↗
The bad link sheds its own load
Each link has its own bounded send queue — 32 packets, with a 200 ms send timeout — so a saturated 64 kbps radio drops its own excess instead of back-pressuring the TUN reader and stalling the fast links queued behind it. This is the property a single-link setup cannot have, and it is easiest to see by flooding the tunnel while the thin link is in it.
The share moves long before anything is declared dead
At defaults a link is dead after five consecutive missed probes at 250 ms — about 1.25 s. But the interesting behaviour happens before that: quality scoring moves traffic off a degrading link continuously as its loss and jitter rise. Unplug the link and you see the failover; impair it with tc netem and you see the part that matters. gradual, not binary ↗
Afterwards, the decision log answers “why did it pick that path at 14:07?” from the record rather than from memory — timestamp, strategy, chosen links, costs, over the REST API.
What we would want back.
Four things, and none of them are a testimonial. An evaluation is worth more to us as measurement than as endorsement — your numbers, from your links, on your hardware, are data we cannot generate ourselves.
The bonding tax, measured on your CPU
The daemon continuously measures the latency its own pipeline adds and publishes the digest. The figure quoted across this site — p50 0.18 ms / p95 0.42 ms — is from our reference run, and it is a property of that machine and that traffic. Yours will differ. Send us the panel. Measured the ledger ↗
The decision log around the failure
Not the summary — the minutes either side of the moment your distrusted bearer degraded, with the costs the scheduler computed. If it made a decision you would not have made, that is the most useful message you can send us, and it is the one that changes code.
Everything config check told you
Which warnings should have been hard errors on your topology, which errors were unhelpfully worded, and — most valuable — anything the file got past the validator that then misbehaved at runtime. Every field-aware check in there exists because someone lost an evening to it once.
The thing that didn’t work at all
The bearer that would not present an IP interface, the radio we have no telemetry adapter for, the field you went looking for and did not find. The roadmap is mostly a list of these, and the ones that arrive from evaluations move first.
What would waste the evaluation.
Stated up front, because a boundary discovered in week three costs more than one read in ten seconds. Being explicit here deflects some evaluations; the ones it saves are worth more.
Where it runs, exactly
- Linux is the supported platform: x86-64 and ARM64. Android runs the same engine in evaluation builds; there is no Windows or macOS daemon.
- The inner tunnel is IPv4. The underlay — link binds and peer endpoints — speaks IPv6, but IPv6 addressing inside the tunnel is still an open gap.
- It carries IP: a Layer-3 tunnel, not an Ethernet bridge and not a Layer-2 fabric. If the requirement is transparent bridging, stop here.
- One build across the fleet, by policy. There is no version negotiation on the wire, so mixed builds fail loudly rather than corrupting quietly — plan on updating both ends together.
- Debian 11 and Ubuntu 20.04 are below the floor. Anything from Debian 12, Ubuntu 22.04 or RHEL 9 onwards is fine.
Off until you ask for it
- Transport classes are opt-in: the qos block defaults to disabled, and with it off the data path is byte-identical to a build without them.
- Mesh and its gossip control plane are opt-in and default to disabled. A point-to-point bond needs no control plane at all.
- NAT traversal is opt-in and defaults to disabled — with it off, no netcheck task is spawned at all.
- Prediction stays off until the link monitor is given a prediction source. Range knobs configured without one are inert, and the validator warns about that rather than leaving you to wonder.
- Erasure coding is doubly gated: a global switch and a per-class policy. Neither one on its own does anything.
- Redundancy is off in the sense that matters — one copy per packet by default. Mirroring costs bandwidth, so you turn it on deliberately.