Bird's-eye view of a self-hosted NetBird deployment:
a long-running audit-event forwarder plus a handful of operator scripts,
packaged as a single Docker image you can run alongside your existing
NetBird docker-compose stack.
Targets self-hosted NetBird (not NetBird Cloud). Uses the unofficial
netbirdPyPI SDK (community-maintained, not affiliated with NetBird).
The birdseye container polls /api/events/audit on your NetBird
management API and fans matching events out to three sinks:
| Sink | Format | Toggle |
|---|---|---|
| stdout | One line per event | always on (read with docker logs) |
| Mattermost | Compact markdown via incoming webhook, one message per poll | MATTERMOST_WEBHOOK_URL empty → disabled |
| Plain text via SMTP | EMAIL_MODE=off | immediate | digest |
It also runs cleanup_ephemeral.py on a cron schedule (default every
15 min) to delete stale ephemeral peers that NetBird's built-in cleanup
ticker sometimes misses, and an optional weekly
backup that mails two encrypted 7z archives: a
volume snapshot for byte-identical restore, and an API config export
in readable JSON.
Highlights:
- No event loss across restarts —
last_idpersisted to a named volume, resumes exactly where it left off. - Bounded catch-up — if the container's been down for a while,
MAX_CATCHUP(default 200) caps how many backlog events get forwarded to Mattermost/email so a 3-day outage doesn't flood your channel. - Self-alert on extended API outage — if the NetBird API is
unreachable for more than
OUTAGE_ALERT_MINUTES(default 10), the forwarder posts a🚨 API unreachablemessage to Mattermost (which usually lives on a different host) and a recovery message when polling resumes. - Per-sink filters — each sink takes a comma-separated list of
fnmatchglobs overactivity_code. Defaults: stdout/Mattermost see everything, email is curated to config-change events (policy.*,user.*,setupkey.*,personalaccesstoken.*,account.*).
Pre-built images are published per-release to Docker Hub and GHCR:
styliteag/birdseye:latestghcr.io/styliteag/birdseye:latest
Clone the repo for the compose file and env template, then:
cd docker/
cp .env.example .env
# Edit .env — minimum: NB_URL, NB_API_KEY, NB_ADMIN_API_KEY,
# MATTERMOST_WEBHOOK_URL (or leave empty), TZ.
docker compose up -d
docker compose logs -fOnce running you should see [forwarder] first boot — seeded last_id=N, no backlog forwarded. Trigger any audit event in NetBird (e.g. toggle a
policy) to confirm the pipeline works.
You can deploy birdseye in two ways. Pick one.
birdseye runs as its own docker compose project, talks to NetBird over
its public DNS name. Zero coupling between the two stacks.
In docker/.env:
NB_URL=https://netbird.example.com
NB_API_KEY=nbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxThen docker compose up -d from inside docker/. This is the default
the shipped docker-compose.yml uses — no edits needed.
Join the docker network that your NetBird services share. The forwarder reaches the management API by internal hostname, so traffic never leaves the host.
First, find your NetBird network name:
docker network ls | grep netbird
# Typical output: netbird_defaultThen edit docker/docker-compose.yml — uncomment the networks:
blocks at the bottom and on the birdseye service, replacing the
network name to match what docker network ls showed:
services:
birdseye:
# ... existing config ...
networks:
- netbird
networks:
netbird:
external: true
name: netbird_default # match `docker network ls`And in docker/.env, point NB_URL at the internal service name
(check docker compose ps in the NetBird stack to see what your
management service is named — typically management or
netbird-management):
NB_URL=http://management:33073The port (33073 here) varies by NetBird version and how your
self-hosted compose exposes the management API. Check the NetBird
management container's ports with docker port netbird-management.
If you'd rather have one docker-compose.yml for everything, copy the
birdseye: service block from docker/docker-compose.yml into your
existing NetBird compose file, plus the birdseye-state volume. The
service can then reference NetBird services directly without an
external: true network declaration.
All knobs are env vars. Full list with defaults in
docker/.env.example. Most important:
| Env var | Default | Purpose |
|---|---|---|
NB_URL |
(required) | NetBird management URL |
NB_API_KEY |
(required) | Read-only API token (forwarder) |
NB_ADMIN_API_KEY |
(optional) | Admin token for cleanup_ephemeral cron job |
POLL_INTERVAL |
60 |
Seconds between audit-API polls |
MAX_CATCHUP |
200 |
Cap on backlog events forwarded per restart |
OUTAGE_ALERT_MINUTES |
10 |
Mattermost self-alert threshold |
BACKLOG_WARN_THRESHOLD |
1000 |
Log a one-shot WARN when a single poll returns more than this many audit events. The NetBird audit endpoint has no cursor, so each poll re-downloads everything; growing past the threshold means it's time to lower server-side retention. |
STDOUT_INCLUDE |
* |
Per-sink fnmatch glob list |
MATTERMOST_INCLUDE |
* |
|
EMAIL_INCLUDE |
policy.*,user.*,setupkey.*,personalaccesstoken.*,account.* |
|
STDOUT_EXCLUDE |
(empty) | Subtracted from STDOUT_INCLUDE. Lets you say "everything except X" without listing every other category. Example: STDOUT_INCLUDE=* + STDOUT_EXCLUDE=peer.login.expired to drop the noisy expiry events from stdout. |
MATTERMOST_EXCLUDE |
(empty) | Same semantics for the Mattermost sink. |
EMAIL_EXCLUDE |
(empty) | Same semantics for the email sink. |
MATTERMOST_WEBHOOK_URL |
(empty = disabled) | Mattermost incoming webhook |
MATTERMOST_USERNAME |
birdseye |
Bot username on the webhook |
MATTERMOST_STARTUP_TEST |
false |
When true, posts a one-shot smoke message to the webhook at container start so you can verify routing before the first audit event arrives. Failure is logged but does not abort the forwarder. |
EMAIL_STARTUP_TEST |
false |
When true, sends a one-shot smoke mail (host, time, transport, recipients) at container start. Confirms SMTP host/port/TLS/credentials work without waiting for a configured event to fire. |
EMAIL_MODE |
off |
off | immediate | digest |
EMAIL_DIGEST_MINUTES |
15 |
Digest flush interval |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / SMTP_FROM / SMTP_TO |
(empty) | SMTP settings (SMTP_TO is comma-separated). When SMTP_PORT is empty the default is derived from SMTP_TLS_MODE: 587 / 465 / 25. |
SMTP_TLS_MODE |
(legacy: SMTP_STARTTLS=true → starttls, else none) |
Transport: starttls (submission, port 587), tls (implicit TLS / SMTPS, port 465), none (plain SMTP, port 25). |
SMTP_STARTTLS |
(empty) | Deprecated — kept for backward compatibility. Prefer SMTP_TLS_MODE. true maps to starttls, false to none. |
CRON_CLEANUP_EPHEMERAL |
*/15 * * * * |
Empty disables the cron job |
CRON_BACKUP_NETBIRD |
(empty = disabled) | Cron schedule for backup_volumes.py (typical: 0 3 * * 0) |
BACKUP_PATHS |
(empty) | Comma-separated paths inside the container to back up |
BACKUP_ZIP_PASSWORD |
(empty) | Passphrase for the AES256-encrypted 7z archive |
BACKUP_EMAIL_TO |
(falls back to SMTP_TO) |
Recipient(s) of the volume-backup mail |
EXPORT_EMAIL_TO |
(falls back to BACKUP_EMAIL_TO → SMTP_TO) |
Recipient(s) of the API-export mail |
BACKUP_MAX_ATTACHMENT_MB |
20 |
Above this, an error mail is sent in place of the attachment (applies to each mail) |
BACKUP_LABEL |
(empty) | Free-form tag in the subject and filename (e.g. prod) |
BACKUP_EXCLUDE |
(empty) | Comma-separated 7z wildcards excluded from the volume archive (case-insensitive, recursive) |
TZ |
UTC |
Timezone for displayed timestamps |
A single cron entry (CRON_BACKUP_NETBIRD, e.g. 0 3 * * 0 for Sunday
03:00) drives two independent jobs and sends two independent mails:
- Volume snapshot (
backup_volumes.py) — packs mounted NetBird volumes into an encrypted 7z. For byte-identical restore. - API config export (
export_objects.py) — fetches every configuration endpoint from the management API and stores it as readable JSON in a second encrypted 7z. For seeing what was configured on a given date.
Either step can be disabled by leaving its inputs empty: skip volumes
by leaving BACKUP_PATHS empty, skip the API export by leaving
NB_ADMIN_API_KEY empty. If both are set, both run sequentially in
the same job — a failure in one does not block the other.
Setup, step by step:
-
Find the volumes you want to back up in your NetBird stack:
docker volume ls | grep netbird # Typical: netbird_management, netbird_signal, netbird_caddy_data
-
Mount them read-only into birdseye. Edit
docker/docker-compose.yml, uncomment the example mounts undervolumes:on thebirdseyeservice, and the matchingexternal: truedeclarations at the bottom. Match the volume names from step 1. -
Configure
.env:CRON_BACKUP_NETBIRD=0 3 * * 0 # Sunday 03:00 # --- Volume snapshot --- BACKUP_PATHS=/backup/management,/backup/signal,/backup/caddy BACKUP_EXCLUDE=geo* # skip GeoIP DBs etc., optional BACKUP_EMAIL_TO=ops@example.com # or leave empty to reuse SMTP_TO # --- API config export --- NB_ADMIN_API_KEY=nbp_xxxx # admin scope to read all objects EXPORT_EMAIL_TO= # optional — falls back to BACKUP_EMAIL_TO, then SMTP_TO # --- Shared --- BACKUP_ZIP_PASSWORD=<long random passphrase, store offline> BACKUP_MAX_ATTACHMENT_MB=20 BACKUP_LABEL=prod # SMTP_HOST / SMTP_PORT / SMTP_FROM / SMTP_USER / SMTP_PASSWORD # are reused from the existing email sink configuration.
-
Trigger each one manually to verify before relying on cron:
docker compose exec birdseye \ /app/.venv/bin/python /app/backup_volumes.py --dry-run docker compose exec birdseye \ /app/.venv/bin/python /app/export_objects.py --dry-run # or both, in the same order the cron uses: docker compose exec birdseye /app/run_backup.sh
-
Restore. Both archives use the same
BACKUP_ZIP_PASSWORD:# Volume restore — byte-identical: 7z x netbird-prod-<timestamp>.7z # then stop NetBird, replace the volume contents, restart # # Config inspection — readable JSON: 7z x netbird-export-<timestamp>.7z ls export/ # peers.json, groups.json, policies.json, … + manifest.json
If an archive exceeds BACKUP_MAX_ATTACHMENT_MB, you receive a
— FAILED mail with the actual size instead of a truncated attachment
— raise the limit, trim BACKUP_PATHS, or move to off-host storage.
The limit is checked against the base64-encoded payload (≈1.4× the
raw archive), which is the size SMTP servers actually count. Gmail
caps at 25 MB encoded, many corporate relays at 10 MB.
The cron line is only rendered when all of CRON_BACKUP_NETBIRD,
BACKUP_ZIP_PASSWORD, SMTP_HOST, SMTP_FROM, a recipient
(BACKUP_EMAIL_TO / EXPORT_EMAIL_TO / SMTP_TO), and at least one
source (BACKUP_PATHS or NB_ADMIN_API_KEY) are set. Missing
prerequisites print a one-line warning on startup and disable the
job.
NetBird's management service writes to store.db (SQLite) continuously.
A 7z of the live file may capture an in-progress transaction and the
restored database can fail with database is malformed or silently lose
the last few writes. The Sunday-03:00 default minimises but does not
eliminate the risk.
For a strict hot-consistent backup of the management volume, either:
- Pause NetBird briefly before the backup (in a wrapper cron job)
and resume afterwards:
docker compose pause management && \ docker compose exec birdseye /app/.venv/bin/python /app/backup_volumes.py; \ docker compose unpause management
- Or pre-snapshot the DB with
sqlite3 ".backup"and back up the snapshot file (works without stopping NetBird).
The image bundles the long-running forwarder plus the operator scripts
that were already in this repo. supervisord is PID 1, supervising:
event_forwarder.py— long-running audit pollercron -f— runscleanup_ephemeral.pyon theCRON_CLEANUP_EPHEMERALschedule and, when configured,run_backup.shonCRON_BACKUP_NETBIRD(which sequentially invokesbackup_volumes.pyandexport_objects.pydepending on what is configured)
The one-shot operator scripts are also baked in and can be invoked via
docker exec:
docker exec birdseye /app/.venv/bin/python /app/list_policies.py
docker exec birdseye /app/.venv/bin/python /app/netbird_overview.py
docker exec birdseye /app/.venv/bin/python /app/cleanup_ephemeral.py --dry-run
docker exec birdseye /app/.venv/bin/python /app/allow_ping.py --help
docker exec birdseye /app/.venv/bin/python /app/manage_posture.py --help
docker exec birdseye /app/.venv/bin/python /app/setup_keys.py --help
uvlives only in the builder stage, so inside the running image use the venv interpreter directly. The same pattern applies to the backup scripts shown earlier in this README.
If you'd rather hack on the scripts directly:
uv sync
cp .env.example .env # at repo root, edit with NB_URL + NB_API_KEY
uv run events.py # streaming console viewer (the dev predecessor of event_forwarder)
uv run list_policies.py # one-shot
uv run docker/event_forwarder.py # forwarder, with /var/lib/birdseye replaced by $STATE_FILE./release.sh bumps the version, updates CHANGELOG.md,
tags the commit, and pushes — which triggers the
release-docker workflow to build
and publish multi-arch images to Docker Hub and GHCR.
./release.sh patch # 0.1.0 → 0.1.1 (default)
./release.sh minor # 0.1.0 → 0.2.0
./release.sh major # 0.1.0 → 1.0.0- The
netbirdPyPI package is community-maintained and not affiliated with NetBird. Some of its pydantic models reject valid values (notably thenetbird-sshprotocol enum) —allow_ping.pyandmanage_posture.pywork around this by bypassing the typed write path and callingclient.post()/client.put()with raw dicts. SeeCLAUDE.mdfor the gotcha details. - Network-traffic events (
/api/events/network-traffic) are cloud-only; the audit-event endpoint is the only event stream available on self-hosted NetBird. Tracking upstream issue: netbirdio/netbird#3935.