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.
atlasd binary from 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.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 on one laptop: two nodes bonded over two networks, one encrypted tunnel, one link killed mid-ping.
The three files
compose.yamlname: 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
$ 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 lossThe real dashboards: node-a at http://localhost:9800, node-b at :9801.
Now kill a network
$ 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 service; in this demo, a real relay server in a fourth container).
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:
[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
$ 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
$ 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 lossMeasured 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, 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).
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:
$ 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 lossThat 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 on your own links, then to per-link tuning for the bearers you actually fly, drive or ship with.