> Source: https://nexusatlas.dev/quickstart/docker — Nexus Atlas developer documentation (Getting started). Converted from the HTML page; the page is canonical.

# Docker quickstart planned

One laptop, no second machine, no radio hardware — three demonstrations of the product's actual behavior, in containers. The *planned* part is the official prebuilt images; the recipes themselves run today with an evaluation binary, and every output on this page comes from a real run performed before publication.

> **What you need:** Docker with Compose (Docker Desktop on macOS/Windows works — containers run in its Linux VM), and the `atlasd` binary from [Downloads](https://nexusatlas.dev/downloads) placed next to the files. The demos need `NET_ADMIN` and `/dev/net/tun` inside the containers, which the compose files request. Prefer real machines and real bearers? That's the [main quickstart](https://nexusatlas.dev/quickstart).

## The topology trick, honestly

None of this is a simulation with a toy switch. Containers are joined by **separate Docker networks, and each network is a genuine underlay link**: each `[[link]]` binds its own in-container interface and address, probes flow over every path, and the scheduler places packets exactly as it would across physical bearers. Which means `docker network disconnect` is a *real link kill*, `docker stop` is a *real node death*, and `tc`/netem gives loss and delay knobs — the same injected-degradation method our own test rigs use. The dashboards are the real ones. And one property worth noticing because it is the product's posture in miniature: **no identity is ever baked into an image** — every node runs `atlasd keygen` on first boot and peers exchange only public keys through a shared volume.

## Example 1 — two nodes, two links, kill one

The [fifteen-minute outcome](https://nexusatlas.dev/quickstart) on one laptop: two nodes bonded over two networks, one encrypted tunnel, one link killed mid-ping.

Live multipath topology2 nodes · 2 links · 1 tunnel

Bonded pathNode upDisconnect target

### The three files

**compose.yaml**

```
name: atlas-quickstart
services:
  node-a:
    build: .
    environment: { NODE: a, PEER: b }
    cap_add: [NET_ADMIN]
    devices: ["/dev/net/tun:/dev/net/tun"]
    networks:
      wired:    { ipv4_address: 172.28.10.11 }
      wireless: { ipv4_address: 172.28.20.11 }
    volumes: ["keys:/shared"]
    ports: ["9800:9800"]
  node-b:
    build: .
    environment: { NODE: b, PEER: a }
    cap_add: [NET_ADMIN]
    devices: ["/dev/net/tun:/dev/net/tun"]
    networks:
      wired:    { ipv4_address: 172.28.10.12 }
      wireless: { ipv4_address: 172.28.20.12 }
    volumes: ["keys:/shared"]
    ports: ["9801:9800"]
networks:
  wired:
    ipam: { config: [{ subnet: 172.28.10.0/24 }] }
  wireless:
    ipam: { config: [{ subnet: 172.28.20.0/24 }] }
volumes:
  keys: {}
```

**Dockerfile**

```
FROM debian:13-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      iproute2 iputils-ping ca-certificates && rm -rf /var/lib/apt/lists/*
COPY atlasd /usr/local/bin/atlasd
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh /usr/local/bin/atlasd
ENTRYPOINT ["/entrypoint.sh"]
```

**entrypoint.sh**

```
#!/bin/sh
# Keys are generated on first boot — never baked into an image.
set -eu

PEER_IDX=$([ "$NODE" = "a" ] && echo 12 || echo 11)
TUN_ME=$([ "$NODE" = "a" ] && echo 10.87.0.1 || echo 10.87.0.2)
TUN_PEER=$([ "$NODE" = "a" ] && echo 10.87.0.2 || echo 10.87.0.1)

# Resolve which container interface carries which demo network.
DEV_WIRED=$(ip -o -4 addr show | awk '/172\.28\.10\./ {print $2}')
DEV_WIRELESS=$(ip -o -4 addr show | awk '/172\.28\.20\./ {print $2}')
ADDR_WIRED=$(ip -o -4 addr show | awk '/172\.28\.10\./ {print $4}' | cut -d/ -f1)
ADDR_WIRELESS=$(ip -o -4 addr show | awk '/172\.28\.20\./ {print $4}' | cut -d/ -f1)

# First boot: mint this node's identity, publish only the public half.
if [ ! -f /shared/$NODE.key ]; then
  OUT=$(atlasd keygen 2>/dev/null)
  echo "$OUT" | sed -n 's/.*private_key = "\(.*\)".*/\1/p' > /shared/$NODE.key
  echo "$OUT" | sed -n 's/.*public_key *= "\(.*\)".*/\1/p' > /shared/$NODE.pub
  chmod 600 /shared/$NODE.key
fi
until [ -s /shared/$PEER.pub ]; do sleep 0.2; done

mkdir -p /etc/atlas
cat > /etc/atlas/config.toml << CFG
[interface]
name = "nexus0"
address = "$TUN_ME/24"
private_key = "$(cat /shared/$NODE.key)"
listen_port = 51820

[[link]]
name = "wired"
bind_device = "$DEV_WIRED"
bind_address = "$ADDR_WIRED"

[[link]]
name = "wireless"
bind_device = "$DEV_WIRELESS"
bind_address = "$ADDR_WIRELESS"

[[peer]]
public_key = "$(cat /shared/$PEER.pub)"
endpoint = ["172.28.10.$PEER_IDX:51820", "172.28.20.$PEER_IDX:51820"]
allowed_ips = ["$TUN_PEER/32"]
persistent_keepalive = 5
CFG
chmod 600 /etc/atlas/config.toml

atlasd config check
atlasd web -i nexus0 -l 0.0.0.0:9800 &
exec atlasd run -c /etc/atlas/config.toml
```

### Run it

**your laptop**

```
$ docker compose up --build -d
✔ Container atlas-quickstart-node-a-1  Started
✔ Container atlas-quickstart-node-b-1  Started
$ docker compose logs node-a | grep handshake
…  all peer handshakes established
$ docker compose exec node-a ping -c 4 10.87.0.2
4 packets transmitted, 4 received, 0% packet loss
```

The real dashboards: node-a at `http://localhost:9800`, node-b at `:9801`.

### Now kill a network

**your laptop — two terminals**

```
$ docker compose exec node-a ping -i 0.2 10.87.0.2
$ docker network disconnect atlas-quickstart_wired atlas-quickstart-node-a-1
#  wired is now gone — watch the ping window and the dashboard
$ curl -s localhost:9800/api/stats | jq '[.links[] | {name, alive}]'
[ { "name": "wired", "alive": false }, { "name": "wireless", "alive": true } ]
```

In our verification run, **3 of 69 pings were lost** — the packets in flight during the five-missed-probes detection window — then nothing. Reconnect (the static address must be restated) and the link rejoins as its measurements recover:

```
docker network connect --ip 172.28.10.11 atlas-quickstart_wired atlas-quickstart-node-a-1
```

## Example 2 — ground station, mast, drone, and the relay network

A field topology, straight from real deployments. The mast exists as its own Atlas node for a physical reason: RF coax runs must stay short or antenna gains drown in cable losses, so the radios and a small SBC live *on the mast* — while the ground-station computers can't. That makes the GS↔mast backhaul its own bonded pair (RJ45 primary, 2.4 GHz Wi-Fi backup: the cable that gets tripped over during repositioning is not allowed to sever C2). The mast reaches the drone over two radios — a 900 MHz omni that always hears something and a 5.8 GHz directional that is fast while it's aligned — and the drone additionally carries a 5G modem that reaches the ground station *directly* through the relay network (the [nexusatlas.net](https://nexusatlas.net) service; in this demo, a real relay server in a fourth container).

Field topology4 containers · 5 bearers · 2 routes to the drone

Bonded bearerNode upRelay network (nexusatlas.net)

**compose.yaml (services + networks)**

```
name: atlas-field
services:
  relay:                     # stands in for the hosted relay network
    build: .
    environment: { ROLE: relay }
    networks:
      wan: { ipv4_address: 172.28.95.10 }
    volumes: ["keys:/shared"]
  gs:
    build: .
    environment: { ROLE: gs }
    cap_add: [NET_ADMIN]
    devices: ["/dev/net/tun:/dev/net/tun"]
    networks:
      eth:    { ipv4_address: 172.28.30.11 }
      wifi24: { ipv4_address: 172.28.40.11 }
      wan:    { ipv4_address: 172.28.95.11 }
    volumes: ["keys:/shared"]
    ports: ["9810:9800"]
  mast:
    build: .
    environment: { ROLE: mast }
    cap_add: [NET_ADMIN]
    devices: ["/dev/net/tun:/dev/net/tun"]
    networks:
      eth:     { ipv4_address: 172.28.30.12 }
      wifi24:  { ipv4_address: 172.28.40.12 }
      omni900: { ipv4_address: 172.28.50.12 }
      dir5800: { ipv4_address: 172.28.60.12 }
    volumes: ["keys:/shared"]
    ports: ["9811:9800"]
  drone:
    build: .
    environment: { ROLE: drone }
    cap_add: [NET_ADMIN]
    devices: ["/dev/net/tun:/dev/net/tun"]
    networks:
      omni900: { ipv4_address: 172.28.50.13 }
      dir5800: { ipv4_address: 172.28.60.13 }
      wan:     { ipv4_address: 172.28.95.13 }
    volumes: ["keys:/shared"]
    ports: ["9812:9800"]
networks:
  eth:     { ipam: { config: [{ subnet: 172.28.30.0/24 }] } }
  wifi24:  { ipam: { config: [{ subnet: 172.28.40.0/24 }] } }
  omni900: { ipam: { config: [{ subnet: 172.28.50.0/24 }] } }
  dir5800: { ipam: { config: [{ subnet: 172.28.60.0/24 }] } }
  wan:     { ipam: { config: [{ subnet: 172.28.95.0/24 }] } }
volumes:
  keys: {}
```

The entrypoint is the Example-1 pattern extended per role (the relay container runs `nexus-atlas-relay` with a first-boot keypair and publishes its public key on the shared volume). The configs it generates are where the topology lives — the interesting excerpts:

**drone — /etc/atlas/config.toml (excerpt)**

```
[interface]
node_name = "drone01"
node_role = "uav"           # advertised in LSAs; topology views pick a symbol

[traversal]
enabled = true              # the relay link below needs the traversal stack

[[link]]
name = "omni900"
capacity = "2 Mbps"         # honest ceilings — cost functions divide by these
probe_interval_ms = 500     # narrowband radio, slower probe cadence

[[link]]
name = "dir5800"
capacity = "40 Mbps"

[[link]]
name = "modem5g"
type = "relay"              # 5G → the relay network → the ground station
relay_server = "172.28.95.10:3478"
relay_pubkey = "…published by the relay at first boot…"
capacity = "30 Mbps"

[[peer]]                     # the mast: two radio bearers, addressed per link
public_key = "…mast…"
endpoint_per_link = { "omni900" = "172.28.50.12:51820", "dir5800" = "172.28.60.12:51820" }
allowed_ips = ["10.88.0.2/32"]

[[peer]]                     # the ground station: NO endpoint at all —
public_key = "…gs…"          # its identity is the key, its address is
allowed_ips = ["10.88.0.1/32"]   # discovered through the relay
```

### Run it, and read what the daemon tells you

**your laptop**

```
$ docker compose up --build -d && docker compose logs gs | grep -iE 'relay|handshake'
…  relay link present — fragment payload capped at 1362 B (from 1428 B)
…  relay 172.28.95.10:3478 session established (link 2)
…  all peer handshakes established
$ curl -s localhost:9810/api/stats | jq '.topology.nodes[] | {node_name, node_role}'
{ "node_name": "ground-station", "node_role": "ground-station" }
{ "node_name": "mast",           "node_role": "relay" }
{ "node_name": "drone01",        "node_role": "uav" }
```

Three things just happened that are the documentation coming alive: the fragment budget shrank by exactly the relay-envelope overhead the moment a relay link existed; the ground station established a session with a drone *whose address it never knew* (endpoint-less peer, discovered through the relay); and the roles you set are flooding in the LSAs.

### The failure story: lose the directional, then lose everything on the mast

**your laptop**

```
$ docker network disconnect atlas-field_dir5800 atlas-field-drone-1
#  the directional lost alignment — the omni still hears the drone
$ docker network disconnect atlas-field_omni900 atlas-field-drone-1
#  the mast is now deaf. The 5G relay path is all that's left.
$ curl -s localhost:9812/api/stats | jq '[.links[] | {name, alive}]'
[ { "name": "omni900", "alive": false },
  { "name": "dir5800", "alive": false },
  { "name": "modem5g", "alive": true } ]
$ docker compose exec gs ping -c 20 -i 0.2 10.88.0.3
20 packets transmitted, 20 received, 0% packet loss
```

Measured in our verification run, phase by phase after each transition settled: baseline **0%** loss, directional dead **0%**, *both* mast radios dead — ground station to drone over the relay path alone — **0%**, and 0% again after both radios were reconnected and rejoined. The transition itself costs a brief burst of loss while probes declare the dead paths (seconds, bounded by the probe cadence you configured) — and then the bond simply has fewer members. Nothing reconnected; nothing was restarted.

## Example 3 — the mesh diamond: lose a whole node

Four nodes, no controller, and the only configuration each node carries is *its own neighbors*: `a` peers with `b` and `c`; `d` peers with `b` and `c`; `a` and `d` know nothing about each other. Link-state advertisements and SPF do the rest — this is the [mesh routing machinery](https://nexusatlas.dev/docs/concepts/mesh), and note what's absent: no `[mesh]` table at all, because multi-hop routing runs unconditionally for established peers (the `[mesh]` section adds gossip *membership*, a separate concern).

Mesh diamond4 nodes · 4 edges · 0 controllers

Mesh edgeLearned route (SPF)Node upStopped mid-ping

Compose: four services, four networks — one per edge of the diamond (`ab`, `ac`, `bd`, `cd`), same first-boot key exchange, each node's config listing only its two neighbor peers with `endpoint_per_link`. Then the demonstration:

**your laptop**

```
$ docker compose exec node-a ping -c 10 -i 0.2 10.89.0.4
10 packets transmitted, 10 received, 0% packet loss   # two hops, zero config between a and d
$ docker compose exec node-a ping -i 0.2 -w 14 10.89.0.4 &
$ docker compose stop -t 1 node-b
#  an entire relay node just died mid-ping
69 packets transmitted, 69 received, 0% packet loss
$ docker compose exec node-a ping -c 10 -i 0.2 10.89.0.4   # now riding a → c → d
10 packets transmitted, 10 received, 0% packet loss
```

That 69/69 is the verification run's actual number: we stopped a whole middle node during a 0.2-second-interval ping and lost nothing — the alternate branch of the diamond was already measured, warm, and carrying probes, so the reroute cost no visible packets. Two honest footnotes: with less lucky timing the transition can cost a few packets (bounded by dead-probe detection, as in the other examples), and the stopped node lingers in the topology *view* until its advertisements age out (~100 s) — routing reacts in seconds, the map is deliberately slower to forget.

## Official prebuilt images planned

Prebuilt images will remove the one manual step in all three examples (supplying the binaries yourself): published through the same access-gated registry as release binaries, signed with cosign exactly like every other release artifact, versioned with the release line — and shipping all three compose bundles ready to `docker compose pull`. Until then, the recipes above are the supported path and work with any granted evaluation binary.

## What this proves — and what it doesn't

These demos prove the product's behavior: real daemons, real encryption, real probes, a real relay server, real links and nodes dying and real bonds surviving them. What they cannot prove is *your bearers* — a Docker bridge fails cleanly, while a fading radio fails slowly and strangely. When the demos have done their job, step up to the [two-machine quickstart](https://nexusatlas.dev/quickstart) on your own links, then to [per-link tuning](https://nexusatlas.dev/docs/concepts/scheduling#tuning) for the bearers you actually fly, drive or ship with.
