Everything needed to install and run an Orbs L3 validator node, and to keep it updated and observable once it is running.
A node is a Docker Compose stack plus one host process. This repo holds four things, and they are separable:
| Component | Lives in | Runs as |
|---|---|---|
| Installer | install.sh, scripts/ |
one-shot, as root |
| Updater | control/src/updater.py |
inside control's cron tick |
| Control | control/src/ |
host process, root's crontab, once a minute |
| Logger | logging/ |
container in the compose stack |
Supporting pieces: docker-compose.yml (the stack and the update descriptor),
nginx/conf.d/default.conf (the single HTTP surface), .env.template.
A node keeps the repo checked out at /opt/orbs/v5-node-setup and follows one branch,
named by DOCKER_COMPOSE_REMOTE_GIT_PATH in .env (origin/main:docker-compose.yml by
default). Control polls that branch once a minute and applies what it finds, so whatever
is on it is what every node following it will run. See Updater for what
"applies" means, and for why a commit takes two polls to take effect.
Everything is served on port 80 by the nginx container.
| Path | Serves |
|---|---|
/service/<name>/status |
.data/<name>/status.json, as a static file |
/service/<name>/logs |
that container's Docker logs, proxied to logger |
/service/{control,updater,recovery}/logs |
.data/<name>/log.txt, via the logger (tail only) |
/service/vm-<name>/<path> |
proxied into the vm-<name> container |
Note the singular /service/, and note that a component's status is at
/service/<name>/status, not /status.
The logger upstream goes through a variable (set $logger_upstream logger;) rather than a
literal hostname, and that is deliberate — see the comment next to it. nginx resolves a
literal name in proxy_pass once at configuration load and caches it for the life of the
process, so once the logger is recreated with a new address every log request 404s. Only
a variable re-resolves. An upstream block does not fix it either.
Four legacy names are rewritten: /services/… → /service/…,
/service/management-service/… → /service/ethereum-reader/…,
/service/logs-service/… → /service/logger/…, and /service/boyar/… →
/service/control/….
Location order in default.conf is load-bearing: the status and logs locations are
declared before the vm-* proxy block, so /service/vm-lambda/status serves the status
file rather than proxying into the container.
install.sh → scripts/run.sh → scripts/install-dependencies.sh,
scripts/prompt_and_env.py, scripts/install-control-cron.sh.
One-shot, run as root. Ubuntu only on Linux; macOS gets a reduced path
(scripts/run-macos.sh) that sets up .env and keys and nothing else.
curl -sSL "https://github.com/orbs-network/v5-node-setup/raw/main/install.sh?t=$(date +%s)" -o /tmp/install.sh && sudo bash /tmp/install.shDownload first, then run under sudo — piping into bash leaves no TTY, which silently
skips the prompts (see below).
To install from a branch other than main, name it in both the URL and BRANCH:
curl -sSL "https://github.com/orbs-network/v5-node-setup/raw/<branch>/install.sh?t=$(date +%s)" -o /tmp/install.sh && sudo BRANCH=<branch> bash /tmp/install.shEnvironment overrides: BRANCH (default main), INSTALL_DIR (default
/opt/orbs/v5-node-setup), REPO_URL.
Re-running on an existing install fetches and pulls the branch rather than recloning, and
skips the key prompts if .env already has a NODE_PRIVATE_KEY.
- Node key — press Enter to generate a new secp256k1 key, or paste an existing
private key (64 hex, optional
0x). - Guardian name and Guardian website.
- The clone at
$INSTALL_DIR, checked out on$BRANCH. .env, copied from.env.templateand then filled in withNODE_PRIVATE_KEY,NODE_ADDRESS,BASE_DIRandDOCKER_COMPOSE_FILE.scripts/keys.json—{node-address, node-private-key}, both unprefixed. The address is stored in EIP-55 mixed case;NODE_ADDRESSin.envcarries no0xprefix, and control adds one when reporting it.scripts/venv— the installer's own Python venv (coincurve,eth-hash), used only for key generation. This is not control's venv.- A root crontab entry for control (see Control).
.data/<component>directories for the components that mount one.- The running stack, via
docker compose up -d.
Dependencies it installs on Ubuntu when missing: build tools (build-essential,
autoconf, automake, libtool, pkg-config), Docker via get.docker.com, Docker
Compose (standalone v2.30.2, or a /usr/local/bin/docker-compose shim onto docker compose), and python3/python3-venv/python3-pip.
This is the part that surprises people.
- It never learns, stores or verifies an eth address. A node installation establishes
only a node address. The eth address is the guardian identity, and it is coupled to
the node address on chain, by a human on a web page, never during installation.
Control resolves it afterwards at runtime, by matching the node address against
CurrentTopologyin the ethereum-reader status, and publishes it inPayload.Identity— but the installer itself neither asks for it nor stores it. - Guardian name and website are prompted and then discarded. They are not written to
.env. Their only use is to build a registration URL:https://guardians.orbs.network?name=…&website=…&ip=…&node_address=…, printed once, mid-install, before the cron and compose output scrolls it away. Nothing writes it to a file, and a re-run with a key already present never prints it again. - It does not verify registration. An unregistered node installs, starts and runs
indefinitely looking entirely healthy. Control reports that as
Registration: unregistered. - It does not create control's venv.
control/.venvis created lazily byscripts/run-control.shon the first cron tick.
If there is no TTY — curl … | sudo bash, or any non-interactive context —
prompt_and_env.py returns immediately: no key, no .env values, no registration URL.
The rest of the install proceeds, so you end up with a started stack and no node key.
Fix it afterwards with:
sudo /opt/orbs/v5-node-setup/scripts/venv/bin/python3 /opt/orbs/v5-node-setup/scripts/prompt_and_env.pycontrol/src/updater.py. Not a separate process: it runs inside control's once-a-minute
cron tick, when control is invoked as poll.
Git is the deployment mechanism. The node follows a branch, and a commit on that branch is a deployment.
The thing that drives updates is a commented-out YAML block at the top of
docker-compose.yml on the remote branch, between
# ---- UPDATE-DESCRIPTOR-BEGIN ---- and # ---- UPDATE-DESCRIPTOR-END ----. It is read
with git show <ref>:<path>, never from the local working copy.
Which ref and path is set by DOCKER_COMPOSE_REMOTE_GIT_PATH, e.g.
origin/main:docker-compose.yml. There is deliberately no default — an unset
value raises immediately rather than failing later with a confusing "descriptor section
not found".
Every line in the block is prefixed # so the compose file stays valid; the updater
strips exactly one # per line before parsing. A line written # # … therefore parses
as a genuine YAML comment. That is how the inline documentation in the block survives.
Fields:
| Field | Meaning |
|---|---|
targetNodes[].id |
node addresses this applies to; * means all. Both sides are normalised, so prefix and casing do not matter |
updateInAction |
master switch. False means no node updates |
updateMode |
immediate, or scheduled |
updateResolution |
minutes to spread a scheduled rollout across |
commit |
a commit hash, or latest to resolve the branch tip |
Under scheduled, each node hashes its own node address to pick a deterministic minute
within the window, anchored on the target commit's own timestamp — so the fleet rolls out
staggered but reproducibly, and a node that is not yet due reports
Extra: {"status": "updating", "time": <unix>}.
trigger_update(), in order:
- Bail out if
DONT_UPDATE=true. - Read
DOCKER_COMPOSE_FILEbefore touching the checkout, so a missing setting fails before the working copy has been moved halfway. git fetch origin.- Report any local modifications to tracked files. A node is a deployment target, not somewhere to edit, so a modified tracked file is itself the anomaly worth surfacing — and the checkout is about to discard it.
git checkout -f -B <branch> <commit>.-Bkeeps the working copy on the branch rather than detached, so the state is legible to whoever logs in next.-fdiscards local edits rather than stashing them; stashing used to leave a stash entry behind on every single update and never dropped one.- Check disk headroom (
MIN_FREE_DISK_GB, default 5). A pull downloads new images while the old ones are still on disk, so pulling without headroom can fill the filesystem and take the node down. If short, prune dangling images and re-check; still short, raise. docker compose pull.docker compose up -d --remove-orphans.docker image prune -f— dangling only, never-a: an untagged image can still be the one a stopped container needs.- Record the commit in
.data/control/applied_commit.json.
Images are pulled only inside trigger_update(), never on a poll. This is deliberate
and must stay that way. Upstream images use mutable tags, so pulling on every poll would
mean an upstream re-push could restart production validators with no commit, no review and
no record. Promotion of a git commit is the only thing allowed to move images.
A commit is applied by the previous commit's code. Control is a fresh process on
each cron tick, started from whatever is checked out at that moment. So a change to
trigger_update merged as commit N is not what applies commit N — the code at N-1 does
that. It stays dormant until commit N+1 triggers the next update. Plan two commits when
changing the apply path, or exercise it on a single node first.
Checked out is not applied. The checkout happens before compose runs, so a compose
failure leaves the working copy sitting at the target commit while nothing was actually
applied. applied_commit.json records the commit whose update ran all the way through,
and the two are compared separately — without it a half-failed update reported itself as
up to date forever. On the first poll after this check was introduced there is no marker,
so the updater adopts whatever is checked out rather than forcing a restart of every
node's services to tell us something we can already see.
git ls-remote ambiguity is an error, not a guess. It exits zero and prints nothing
when a branch does not resolve, and prints several lines when a name is ambiguous. Both
are raised rather than taking the first field.
Branch parsing strips only the remote name. origin/feature/v5-ready → feature/v5-ready.
Keeping only the last path segment used to turn that into v5-ready, which git matched by
trailing component right up until two branches shared a final segment.
DONT_UPDATE=truein.env— the node polls, reports, and refuses to apply anything.updateInAction: falsein the descriptor — nothing in the fleet updates.
control/src/: main.py (entry point), system_monitor.py (what to report),
image_drift.py, staleness.py, identity.py, config.py, utils.py, logger.py.
A host process, not a container. It has to be, since it drives docker compose.
Root's crontab, once a minute:
* * * * * /opt/orbs/v5-node-setup/scripts/run-control.sh poll >> /opt/orbs/v5-node-setup/.data/control/log.txt 2>&1
Installed by scripts/install-control-cron.sh, which is idempotent and replaces any
previous run-control.sh poll line.
scripts/run-control.sh is the wrapper. Each tick it:
- Sources
.envand exportsBASE_DIRandDOCKER_COMPOSE_FILE. - Rotates
.data/control/log.txtif it is overLOG_MAX_BYTES(default 10MB), keepingLOG_KEEP(default 3) generations, so the log cannot grow without bound. Rotating by rename is safe here precisely because cron opens the file fresh each run, so nothing holds a descriptor across ticks. - Creates
control/.venv(Python 3.12 on the nodes) if missing, and reinstallscontrol/requirements.txtwhenever its sha256 differs from the marker stored inside the venv. Installing only on venv creation meant a new dependency never reached an existing node, and control then failed to import it once a minute, forever. The marker is written only after a successful install, so a failure is retried next tick. - Runs
control/src/main.pywith whatever arguments it was given.
poll is the only argument cron passes, and the only one that runs the updater. Running
run-control.sh with no argument also issues a docker compose up -d (this is what
make control does); make control-poll is the cron-equivalent.
One file, rewritten every tick: .data/control/status.json, served at
http://<node>/service/control/status.
The consumer-facing contract is documented in STATUS_PAGE_INTEGRATION.md — read that before building anything against the endpoint. In brief:
Timestamp,Status,Error,ExtraPayload.Version.Semantic—<git commit> / <tag or "untagged">Payload.Identity—NodeAddress,EthAddress,RegistrationPayload.Metrics— CPU, memory, uptime,Disks[],Processes[]Payload.Services[]— one entry per running container, includingImageTagandImageDigestread from the running container, not from the compose filePayload.ImageDrift[]— only the services that have driftedPayload.StaleComponents[]— only the components not reporting freshly
Other files under .data/control/: log.txt (+ .1….3), applied_commit.json,
image_drift.json (the registry digest cache).
identity.py resolves the eth address. A node knows only its own node address; the
coupling to an eth address happens on chain, so it is read back from CurrentTopology in
.data/ethereum-reader/status.json, matching on OrbsAddress. Registration is
registered, unregistered or unknown — and unknown means the topology could not be
read, which is not the same as being absent from it.
image_drift.py compares three things per compose service: the image the compose file
asks for, the image on disk, and the digest the registry currently serves. Two different
failures:
ContainerOutdated— a pull landed but the container was never recreated.ImageOutdated— the image on disk is behind the registry. Nothing local can see this, because the upstream tags are mutable and get re-pushed: every local signal says the tags match while the content behind them has moved on.
Registry reads count against Docker Hub's anonymous pull rate limit, so remote digests are
cached and refreshed every IMAGE_DRIFT_REFRESH_MINUTES (default 360). A refresh only
counts as done once every image was read, so a transient registry failure is retried next
poll instead of being held for six hours. Services with a build: are skipped — there is
no registry image to compare against.
staleness.py reads each component's own status.json and reports how old its
Timestamp is. Docker only knows whether a process is alive; a container can sit at
Up 6 months (healthy) doing nothing. What a component last said about itself is the
better signal.
Only components that declare they report are checked: declaring means mounting a
directory ending /opt/orbs/status in the compose file. nginx mounts .data in order to
serve those files and writes none of its own, and holding it to a contract it never
entered would leave a permanent unknown that people learn to skip.
Threshold is STATUS_STALE_AFTER_MINUTES (default 10) for every component, whatever its
own tick interval — a component that cannot meet it is a bug to fix, not an exception to
configure. Timestamp precision varies across components (milliseconds, microseconds and
nanoseconds all appear), so the fraction is truncated to 6 digits before parsing.
Disk usage warns at DISK_WARN_PERCENT (80) and escalates into Error at
DISK_CRITICAL_PERCENT (90). Pseudo filesystems are skipped — squashfs, iso9660,
tmpfs, devtmpfs, overlay, ramfs. On a typical Ubuntu host most mounts are
squashfs snap images sitting at 100% by design, and alerting on those would fire on every
node forever, which is how a signal stops being read. Error is only claimed if nothing
more specific already holds it: a failed update says more than a filling disk does.
Empty or unknown is never OK. Registration has a third value, unknown. Image drift
records an empty RegistryDigest when the registry is unreachable, rather than claiming a
match. Staleness reports unknown rather than an age when the file or timestamp cannot be
read. Not knowing is a different thing from knowing it is fine, and conflating the two
makes a broken signal look healthy.
The node reports facts; the status page owns comparisons. Control does not decide
good or bad beyond the summaries it writes into Status and Error. In particular: a
component's self-reported version is not comparable to its Docker tag. They are different
naming schemes, and comparing them produces false "behind" markers on nodes that are
completely up to date.
A check that cannot run must not take the poll down. Drift and staleness both catch broadly and return empty — a node that cannot check is still a node that should report its metrics.
Status is per-tick. Control is a fresh process each minute, so the in-memory list
behind Status starts empty every tick and contains only what this tick found. It reads
OK when the tick found nothing. Entries are joined with , and individual messages
never contain a comma, so splitting on , is safe — which is why the drift and staleness
summaries join with |.
Logging is quiet by design. The poll runs 1440 times a day, so routine progress is at
DEBUG and INFO is reserved for what actually happened. LOG_LEVEL=DEBUG gets the
detail back.
cd control
make install # venv + requirements
make test # pytest
make lint # pylint + flake8
make typecheck # mypyTests live in control/tests/ and run in CI on PRs to main
(.github/workflows/control.yml).
logging/. A small TypeScript/Express service, the only service in
docker-compose.yml built from source (build: ./logging); everything else runs a pinned
image from a registry.
It mounts the Docker socket and exposes container logs over HTTP, read straight from the
Docker API. nginx proxies /service/<container>/logs to it.
Because it is built from source rather than pulled, the updater has to build it
explicitly — pull skips a service with a build: section and up -d reuses whatever
image is already on disk. Without that step a logging/ change would be checked out,
recorded as applied, and never actually run.
GET /service/<container>/logs
Returns text/plain. With no parameters you get the whole log, which is what this
endpoint has always done. The query parameters are Docker's own, passed through to the
daemon, so anyone who knows docker logs knows this endpoint.
| Parameter | Values | Meaning |
|---|---|---|
tail |
non-negative integer, or all (default) |
only the last N lines |
since |
unix seconds, RFC3339, or a relative age like 15m / 2h |
only entries after this point |
until |
same as since |
only entries before this point |
timestamps |
1/0, true/false (default off) |
prefix each line with its timestamp |
stdout |
1/0, true/false (default on) |
include stdout |
stderr |
1/0, true/false (default on) |
include stderr |
That is the complete set. A malformed value returns 400 naming the parameter rather
than being silently dropped; unknown parameters are ignored.
curl 'http://<node>/service/ethereum-writer/logs?tail=100'
curl 'http://<node>/service/vm-lambda/logs?since=15m×tamps=1'
curl 'http://<node>/service/signer/logs?since=2026-09-30T10:00:00Z&until=30m'Full detail in logging/README.md. Worth knowing here:
followreturns501. Poll withtailinstead. A live stream would needproxy_buffering offon this location, which would unbuffer every other request through it too, and hold a Docker log stream open per viewer.docker logs -fover SSH is the tool for the cases that genuinely need it.head, byte counts andgrepare not supported. None of them map to a Docker parameter, so they would have to be implemented locally, with early teardown of the upstream request and a cap on user-supplied patterns.- The response is streamed, not buffered. A container keeps up to 150MB of logs
(
50m×3, set by thex-logginganchor indocker-compose.yml) and the daemon reads across the rotated files, so an unfiltered request can return all of it. Streaming makes that cost bandwidth rather than memory. A client that disconnects halfway tears down the daemon request too. - Docker's framing does not respect chunk boundaries.
demux.tsis aTransformthat carries partial headers and payloads across chunks; decoding each chunk independently produces garbage for anything over one chunk.
control, updater and recovery run on the host, so there is no Docker log stream to
ask the daemon for. They go through the logger as well, which reads the same files from ./.data mounted
read-only at /opt/orbs/logs. Same URL, same component names, and tail behaves the same
way as it does for a container:
curl 'http://<node>/service/control/logs?tail=300'Three things differ from a container, deliberately:
- Only
tailis supported.sinceanduntilwould mean parsing a timestamp out of every line, which would tie the logger to a log format that is not a contract; the timestamp is already in the line, sotimestampshas nothing to add; and a file has no separate streams to select between. All of them return400naming the parameter rather than being accepted and ignored. - Only the live file is read, never the rotated
log.txt.1beside it. Ataillarger than the current file returns everything it holds rather than reaching further back. recoveryhas a route but nothing writes its file, so it returns404.controlandupdaterboth have logs; the updater's is written by a filter on its own records so it rotates independently of control's.
tail reads backwards from the end of the file in 64KB chunks rather than loading it, so
asking a 10MB log for 300 lines reads about one chunk.
Logger also writes its own .data/logger/status.json every 5 minutes, so it participates
in the same staleness contract as everything else.
Every vm-* service, plus logger, uses the same healthcheck: the component's own
status.json was written within the last 10 minutes.
test: "test $$(( $$(date +%s) - $$(stat -c %Y /opt/orbs/status/status.json) )) -lt 600"A check like ping -c 1 logger would test nothing about the service, and pass or fail
purely on whether the image happens to ship a ping binary. Freshness of the component's
own report means a red is always worth looking at.
The $$ is compose escaping for a literal $.
The Node components (ethereum-reader, matic-reader, ethereum-writer) use their own
node healthcheck.js; signer uses the healthcheck binary shipped in its image.
.env lives at the repo root, is written by the installer from .env.template, and is
read both by docker compose and by scripts/run-control.sh.
| Variable | Used by | Notes |
|---|---|---|
NODE_PRIVATE_KEY |
signer, ethereum-writer | written at install |
NODE_ADDRESS |
most services, control | no 0x prefix on disk |
ETHEREUM_ENDPOINT, MATIC_ENDPOINT |
readers, writer | RPC |
SIGNER_ENDPOINT |
ethereum-writer | |
BASE_DIR |
control | repo root; control resolves .data/ from it |
DOCKER_COMPOSE_FILE |
control, updater | absolute path |
DOCKER_COMPOSE_REMOTE_GIT_PATH |
updater | <remote>/<branch>:<path>, no default |
DOCKER_SOCKET_PATH |
logger, vm-* | default /var/run/docker.sock |
DONT_UPDATE |
updater | true disables applying updates |
MIN_FREE_DISK_GB |
updater | default 5 |
DOCKER_DATA_ROOT |
updater | default /var/lib/docker |
IMAGE_DRIFT_REFRESH_MINUTES |
control | default 360 |
STATUS_STALE_AFTER_MINUTES |
control | default 10 |
DISK_WARN_PERCENT / DISK_CRITICAL_PERCENT |
control | default 80 / 90 |
LOG_LEVEL |
control | default INFO |
LOG_MAX_BYTES / LOG_KEEP |
run-control.sh | default 10MB / 3 |