Prerequisites
Check these first. Every one of them fails late and confusingly if you skip it — which is why make doctor exists.
Run the preflight
Read-only apart from pulling two small images. It checks the docker daemon and the RAM it has, amd64 emulation on Apple Silicon, that the repo bind-mounts at its own path, docker networks overlapping the lab’s subnets, the containerlab image, and that python3 imports the host scripts — and prints the fix next to each failure.
Host requirements
| Requirement | Why | Used by |
|---|---|---|
Docker + docker compose ≥ 2.20 |
The whole control plane, the devices, and containerlab itself | everything |
openssl |
Mints the dev RSA keypair the CMS needs for RS256 JWTs (OpenSSL 3 or macOS LibreSSL) | make lab-jwt |
python3 ≥ 3.9 |
The host scripts are stdlib-only and import under a stock macOS /usr/bin/python3 |
gen_clab_topology, run_workflow, wait_ready |
uv |
ruff / pyrefly / pytest and the MkDocs tooling | make check, make doc-* |
| Quay pull access | The CMS, engine, web client and worker images are private | make local-env-init |
| RAM | 5 SR Linux nodes (≈1.5–2 GB each) + Elasticsearch + control plane ≈ 14–16 GB for containers | make local-lab-up |
| A worker image with the discovery block | Published with neops-worker-sdk-py#127; until then build it from a checkout | Images |
containerlab — one command, both hosts, no install
Every make target and every command in these docs calls ./containerlab, a small launcher committed at the repo root. It runs the official ghcr.io/srl-labs/clab image privileged through the docker socket — on Linux exactly as on macOS — so the containerlab version is pinned (CLAB_IMAGE) and the host needs nothing but docker. This is containerlab’s documented container mode; it needs a rootful docker daemon (podman is not supported by clab’s container mode — set CLAB_NATIVE=/path/to/containerlab to use a host binary there).
Three things follow from the container mode:
- The repo is mounted at the same absolute path inside the clab container, because containerlab hands the topology’s bind paths to the docker daemon as absolute host paths. On Docker Desktop the checkout must therefore live under a shared path (
/Users,/Volumes,/private,/tmpby default) and must not contain spaces —make doctorchecks the mount round-trips. - containerlab’s convenience entries (
/etc/hosts,ssh_config.d) land inside the ephemeral container, on both hosts. Reach devices withdocker exec <node>/docker logs <node>— the topology setsprefix: "", so node names are container names. - On plain Linux the lab runtime dir (
generated/clab-neops-lab/) is written as root; the launcher chowns it back to you after each run.
Verify with the two-node probe — it confirms the whole chain (docker socket, privileged mode, veth wiring) without pulling the NeOps stack:
# Minimal containerlab probe: confirms containerlab can deploy on this host
# (privileges) and that a `linux`-kind FRR node takes a `swpN` interface over a
# real veth link. Uses the same multi-arch frrouting/frr:v8.4.1 the lab image
# is built from (`latest` is amd64-only), so no local build is needed.
name: neops-clab-probe
prefix: ""
topology:
nodes:
r1:
kind: linux
image: frrouting/frr:v8.4.1
r2:
kind: linux
image: frrouting/frr:v8.4.1
links:
- endpoints: ["r1:swp1", "r2:swp1"]
./containerlab deploy -t clab/probe.clab.yml
./containerlab destroy -t clab/probe.clab.yml --cleanup
Native binary (optional)
CLAB_NATIVE=$(command -v containerlab) makes the launcher exec a host
binary instead. On Linux that path needs sudo-less operation — membership in
clab_admins plus the SUID bit; make clab-suid does the SUID half for you:
idempotent, it prompts for sudo only when the bit is actually missing, and
warns if you are not in clab_admins.
Re-run make clab-suid after every containerlab upgrade
A package upgrade replaces the binary and silently drops the SUID bit.
The next make local-lab-up then fails with “This containerlab command
requires root privileges or root via SUID to run” — group membership
survives the upgrade, so id looks fine and only the file mode gives it
away.
macOS notes
- Docker Desktop VM sizing. The VM’s memory is the whole budget: Settings → Resources → Memory ≥ 16 GB (the 8 GB default cannot hold 5 SR Linux nodes plus Elasticsearch), then Apply & restart.
- Apple Silicon. The four
quay.io/zebbraimages are amd64-only; enable Use Rosetta for x86_64/amd64 emulation (Settings → General) or they run under QEMU, much slower. containerlab, FRR and SR Linux are native arm64. - No route from the Mac to
lab-net(172.30.0.0/24): the make targets poll device readiness from inside the worker, and nothing in these docs reaches a device from the host. - A slow first run wants larger wait budgets, not fewer waits:
make local-lab-up WAIT_READY_TIMEOUT=600 WAIT_DEVICES_TIMEOUT=600. make doc-fix-symlinksneedsbrew install symlinks; nothing else in the docs workflow differs.
Registry access
The lab as documented needs no registry credentials. Every image on the default path pulls anonymously:
| Image | Service | |
|---|---|---|
quay.io/zebbra/neops-cms-free:develop |
cms |
public |
quay.io/zebbra/neops-web-client:develop |
web_client |
public |
quay.io/zebbra/neops-workflow-engine-preview:${NEOPS_ENGINE_TAG:-develop} |
workflow_engine, workflow-engine-client |
public — the developer preview engine |
ghcr.io/nokia/srlinux:26.3 (the SR Linux devices) and ghcr.io/srl-labs/clab (containerlab) are public as well.
When docker login is needed
Only to swap the developer preview for the full licensed engine, which is
not public — an anonymous pull is rejected with 401:
docker login quay.io
export NEOPS_WORKFLOW_ENGINE_IMAGE=quay.io/zebbra/neops-workflow-engine:develop
quay.io/zebbra/neops-worker-sdk is not public either, but it never enters the
documented path: its published tag is unusable, so the worker is built from a
local checkout either way — see Images.
NPM_TOKEN is not needed here
Most NeOps repos require an NPM_TOKEN to install @zebbra/* npm
packages. This repo installs no npm packages and publishes nothing — and on
the default path it needs no registry credentials at all.
Running locally-built images instead
Every published image can be swapped for a local tag through an environment variable; edit .env (docker compose reads it automatically, and make local-lab-up creates it from .env.example if missing) or export the variable:
| Variable | Default |
|---|---|
NEOPS_WORKFLOW_ENGINE_IMAGE |
quay.io/zebbra/neops-workflow-engine-preview:${NEOPS_ENGINE_TAG:-develop} |
NEOPS_WEB_CLIENT_IMAGE |
quay.io/zebbra/neops-web-client:develop |
NEOPS_WORKER_SDK_IMAGE |
quay.io/zebbra/neops-worker-sdk:develop |
Every overridable service sets pull_policy: missing, so a local image already present is used without a registry call; the make targets pull the published tags with --policy always --ignore-pull-failures, so a local override tag is a warning, not an error.
Files the lab needs but does not ship
Two things are git-ignored and created on first run; without them the stack does not start:
cms/jwt/{private,public}.pem— dev-only RSA keypair minted bymake lab-jwt(openssl genpkey, so OpenSSL and LibreSSL emit the same PKCS#8 PEM). Idempotent; a throwaway lab credential.cms_api_key.env— the CMS API key the engine authenticates with, minted bymake local-env-init.

