
One source of truth for DNS, DHCP and IPAM
A reproducible self-hosted DDI proof of concept where NetBox drives Technitium DNS, Kea DHCP and the Proxmox VE SDN through event-driven Windmill flows. Full Compose stack and configuration included.
Every lab I have run eventually developed the same disease: an IP address lives in four places at once. A DNS record, a DHCP reservation, a VLAN on the Proxmox host, and a spreadsheet that stopped being accurate months ago. Nothing is wrong until you delete a machine, and then three of those four keep pointing at it.
This note is the full write-up of the fix I settled on: one source of truth, and everything else generated from it. All self-hosted, nothing leaving the rack. Everything below is enough to rebuild the proof of concept from an empty directory.
Addresses, zones and hostnames are made up. Substitute your own, and never copy the example secrets.
On this page
Architecture
source of truth orchestrator enforced state
┌────────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ NetBox │ │ Windmill │ ──▶ │ Technitium DNS │
│ │ │ │ ├────────────────────┤
│ prefixes [dhcp] │ ──▶ │ flow: sync DNS │ ──▶ │ Kea DHCP4 │
│ IPs (dns_name) │ │ flow: sync DHCP │ ├────────────────────┤
│ IPs (mac_address) │ │ flow: sync SDN │ ──▶ │ Proxmox VE SDN │
│ VLANs │ │ │ │ (VNets per VLAN) │
└────────────────────┘ └──────────────────┘ └────────────────────┘
▲ ▲ ▲
│ │ │
humans webhook on change hourly reconcile
| Role | What runs it | Why this one |
|---|---|---|
| IPAM | NetBox | Real data model, proper API, changelog on every object |
| Orchestration | Windmill | Flows as versioned Python steps, plus a UI to run them |
| DNS | Technitium | Full HTTP API, sane zone handling, lightweight |
| DHCP | Kea | Config is JSON and reloadable over a control socket |
| Network overlay | Proxmox VE SDN | VLANs already exist in NetBox, so they may as well match |
Three Docker networks, and the split matters: netbox-net carries NetBox with
its Postgres and Redis, ddns-net carries DNS, DHCP and Proxmox, windmill-net
carries Windmill and its own database. Windmill is the only container attached
to all three, so it is the only thing that can talk to both the source of truth
and the targets.
Port 53 and port 67 are deliberately not published on the host. The resolver
and the lease server are reachable from inside ddns-net, and nowhere else.
That single decision removes an entire class of “why is my laptop resolving
through the lab” problems.
Why Windmill, after two attempts that were not
Windmill is the third orchestrator this POC ran on. The first two were not mistakes so much as the shortest path to understanding what the job actually needed, so they are worth writing down.
First attempt: a Python container on a timer
The original version was a sync/ directory with three scripts (sync_dns.py,
sync_dhcp.py, sync_proxmox.py) and an entrypoint that ran them in a loop:
SYNC_INTERVAL = int(os.environ.get("SYNC_INTERVAL", "60"))
def job() -> None:
run_sync("sync_dns")
run_sync("sync_dhcp")
run_sync("sync_proxmox")
job()
schedule.every(SYNC_INTERVAL).seconds.do(job)
while True:
schedule.run_pending()
time.sleep(1)
One SYNC_INTERVAL in .env, defaulting to 60 seconds, and a sync service in
Compose. It worked, and the sync logic in it is essentially the logic still
running today. What it could not do was tell me anything:
- Every run is a full poll. Nothing changed in NetBox for six hours? It still hit every endpoint 360 times. Latency to a real change is bounded by the interval, so you trade responsiveness against pointless load, and the tuning knob is one number for three very different jobs.
- Failures scroll past.
run_synccatches, logs, and carries on. That is the right behaviour (one broken target must not stop the other two), but the only record is a line indocker logs, gone at the next restart. There is no answer to “did the DHCP sync succeed at 14:03, and what did it push?”. - No way to run one on demand. Debugging meant restarting the container, or waiting.
- Read, compute and apply are one function call. When a run failed, finding out whether NetBox returned nonsense or Kea rejected the config meant adding print statements and waiting another 60 seconds.
Second attempt: n8n
Next I moved the same logic into n8n, driven by NetBox webhooks, with the
workflows checked into the repository as n8n/workflows/*.json plus two
provisioning scripts. Event-driven was the right call and stayed. n8n as
infrastructure as code was not.
The problem is what a workflow file actually looks like. Here is one node, as committed:
{
"id": "22222222-2222-4222-8222-222222222222",
"name": "Lire Netbox",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [550, 300],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "const NETBOX_URL = 'http://netbox:8080';\nconst NETBOX_AUTH = '__NETBOX_AUTH__';\n\nasync function netboxGetAll(path) {\n const items = [];\n let url = `${NETBOX_URL}/api/${path}...
}
}
Every real line of code lives inside a single JSON string field, newlines escaped. That one detail poisons the whole workflow:
- It cannot be reviewed. A one-character change to the sync logic shows up in
git diffas one enormous modified line. Code review is impossible, and so is spotting an accidental change. - No tooling reaches it. No syntax highlighting, no linter, no formatter, no type checking. The editor sees a string.
- Version control holds UI state.
"position": [550, 300]is where the box sits on the canvas. Node ids are hand-written UUIDs that must stay stable across redeploys or the workflow is recreated instead of updated. Moving a box in the browser produces a diff. - Credentials fight the format.
__NETBOX_AUTH__is a placeholder, string-replaced at startup by a provisioning script, because the credential store is not meaningfully declarative.
Editing in the browser and exporting is pleasant right up to the moment two sources of truth exist, and then you are diffing generated JSON by hand.
What Windmill changed
Windmill keeps what n8n got right (event-driven, a DAG you can look at, per-step results in a UI) and drops what it got wrong, because a flow step is a normal Python file on disk:
windmill/scripts/sync_dhcp/
├── lire_netbox.py # read
├── calculer_config.py # compute
└── appliquer_kea.py # apply
setup.py reads those files and pushes them into Windmill. The consequences are
the whole reason for the switch:
| Concern | Cron container | n8n | Windmill |
|---|---|---|---|
| Trigger | Fixed interval only | Webhook + schedule | Webhook + schedule + manual |
| Logic in Git | Real .py files | Escaped JSON strings | Real .py files |
| Reviewable diff | Yes | No | Yes |
| Per-step results | No | Yes | Yes |
| Run history | docker logs | Yes | Yes |
| Secrets | Env vars | Placeholder substitution | Typed secret variables |
Concretely: the code is linted and diffed like any other Python, each step’s input, output and duration is inspectable after the fact, a flow can be replayed from the UI while debugging, and secrets are Windmill variables rather than strings interpolated into source at boot.
The honest caveat is that this is not free. Windmill needs its own PostgreSQL, which is one more database to run and back up for a lab. If the sync were a single script with no branching and nobody but me ever looked at it, the cron container was fine, and I would not talk anyone out of it.
Repository layout
.
├── compose.yaml
├── .env
├── config/
│ ├── kea/
│ │ ├── kea-dhcp4.conf # subnet4 is empty, the sync fills it
│ │ └── kea-ctrl-agent.conf # Unix socket exposed over HTTP
│ ├── netbox/
│ │ └── configuration.py
│ └── proxmox/
│ └── init_sdn.py # one-shot: API token + SDN zone
└── windmill/
├── setup.py # deploys variables, flows, schedules, webhooks
└── scripts/
├── sync_dns/{lire_netbox,sync_technitium}.py
├── sync_dhcp/{lire_netbox,calculer_config,appliquer_kea}.py
└── sync_proxmox/{lire_netbox_vlans,sync_proxmox_sdn}.py
The flow steps are plain .py files on disk. setup.py reads them and pushes
them into Windmill, which means the flows are versioned in Git rather than
edited in a web UI and lost on the next redeploy.
The Compose stack
The full file is long, so here are the parts that carry a decision. Start with NetBox and its dependencies:
name: ddi-stack
networks:
netbox-net:
ddns-net:
windmill-net:
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
networks: [netbox-net]
environment:
POSTGRES_DB: netbox
POSTGRES_USER: netbox
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U netbox"]
interval: 10s
retries: 5
netbox:
image: netboxcommunity/netbox:latest
restart: unless-stopped
networks: [netbox-net, ddns-net]
ports:
- "8000:8080"
environment:
SECRET_KEY: ${NETBOX_SECRET_KEY}
NETBOX_TOKEN_PEPPER: ${NETBOX_TOKEN_PEPPER}
DB_HOST: postgres
REDIS_HOST: redis
REDIS_CACHE_HOST: redis
REDIS_CACHE_DATABASE: "1"
volumes:
- ./config/netbox/configuration.py:/etc/netbox/config/configuration.py:ro
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/login/ || exit 1"]
interval: 30s
retries: 10
start_period: 120s
Two things worth copying. start_period: 120s on the NetBox healthcheck:
first boot runs migrations and takes minutes, and without it Compose declares
the container unhealthy and everything downstream gives up. And a separate
netbox-worker running manage.py rqworker with the same environment:
without it, NetBox queues the outgoing webhooks and never sends them, which
looks exactly like “my event rules do not work”.
DNS and DHCP:
technitium:
image: technitium/dns-server:latest
networks: [ddns-net]
ports:
- "5380:5380" # web UI and API only, never 53
environment:
DNS_SERVER_ADMIN_PASSWORD: ${TECHNITIUM_PASSWORD}
volumes:
- technitium-data:/etc/dns
kea:
image: jonasal/kea-dhcp4:2
command: -c /etc/kea/kea-dhcp4.conf
networks: [ddns-net]
volumes:
- kea-leases:/kea/leases
- kea-sockets:/kea/sockets # shared with the control agent
- ./config/kea:/etc/kea
healthcheck:
test: ["CMD-SHELL", "test -S /kea/sockets/kea-dhcp4-ctrl.sock || exit 1"]
interval: 15s
start_period: 20s
kea-ctrl-agent:
image: jonasal/kea-ctrl-agent:2
command: -c /etc/kea/kea-ctrl-agent.conf
networks: [ddns-net]
ports:
- "8080:8000"
volumes:
- kea-sockets:/kea/sockets
- ./config/kea:/etc/kea
depends_on:
kea: { condition: service_healthy }
Kea splits into two containers on purpose. kea-dhcp4 speaks DHCP and exposes a
Unix control socket; kea-ctrl-agent is the only thing that turns that
socket into HTTP. They share it through the kea-sockets volume, and the
healthcheck on the socket file is what stops the agent from starting before
there is anything to talk to.
Windmill and its setup job:
windmill:
image: ghcr.io/windmill-labs/windmill:main
networks: [windmill-net, netbox-net, ddns-net] # the only bridge
ports:
- "8300:8000"
environment:
DATABASE_URL: postgresql://windmill:${WINDMILL_DB_PASSWORD}@windmill-db/windmill
BASE_INTERNAL_URL: http://windmill:8000
SUPERADMIN_SECRET: ${WINDMILL_SUPERADMIN_SECRET}
NUM_WORKERS: "1"
depends_on:
windmill-db: { condition: service_healthy }
windmill-setup:
image: python:3.12-slim
restart: on-failure # retries until Windmill answers
command: sh -c "pip install -q requests && python /setup.py"
networks: [windmill-net, netbox-net, ddns-net]
volumes:
- ./windmill/scripts:/windmill-scripts:ro
- ./windmill/setup.py:/setup.py:ro
environment:
WINDMILL_URL: http://windmill:8000
NETBOX_URL: http://netbox:8080
NETBOX_TOKEN: ${NETBOX_TOKEN}
DNS_ALLOWED_ZONES: ${DNS_ALLOWED_ZONES:-}
KEA_DNS_SERVERS: ${KEA_DNS_SERVERS:-9.9.9.9, 149.112.112.112}
DHCP_TAG: ${DHCP_TAG:-dhcp}
depends_on:
windmill: { condition: service_healthy }
netbox: { condition: service_healthy }
restart: on-failure on the setup container is the cheap way to handle ordering:
it exits non-zero while Windmill is still booting, Compose restarts it, and it
eventually succeeds. No wait loop to write.
Kea, configured to be overwritten
The Kea config file on disk is deliberately almost empty:
{
"Dhcp4": {
"interfaces-config": {
"interfaces": ["*"],
"dhcp-socket-type": "udp"
},
"control-socket": {
"socket-type": "unix",
"socket-name": "/kea/sockets/kea-dhcp4-ctrl.sock"
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/kea/leases/dhcp4.leases"
},
"hooks-libraries": [
{ "library": "/usr/local/lib/kea/hooks/libdhcp_lease_cmds.so" }
],
"valid-lifetime": 4000,
"subnet4": []
}
}
"subnet4": [] is the point. No subnet is ever written by hand. The sync
owns that array entirely, which is what makes “delete the prefix in NetBox”
actually remove the subnet. libdhcp_lease_cmds.so is loaded so lease commands
are available over the control channel, and the control agent just bridges the
socket:
{
"Control-agent": {
"http-host": "0.0.0.0",
"http-port": 8000,
"control-sockets": {
"dhcp4": {
"socket-type": "unix",
"socket-name": "/kea/sockets/kea-dhcp4-ctrl.sock"
}
}
}
}
Bringing it up
cp .env.example .env
# fill in NETBOX_SECRET_KEY, NETBOX_TOKEN_PEPPER, POSTGRES_PASSWORD,
# TECHNITIUM_PASSWORD, WINDMILL_SUPERADMIN_SECRET, WINDMILL_PASSWORD
docker compose up -d
docker compose logs -f netbox # migrations, 2 to 3 minutes on first boot
Create the NetBox superuser and an API token:
docker compose exec -e DJANGO_SUPERUSER_PASSWORD=change-me netbox \
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py \
createsuperuser --username admin --email admin@lab.example --noinput
docker compose exec netbox /opt/netbox/venv/bin/python \
/opt/netbox/netbox/manage.py shell -c "
from users.models import Token
from django.contrib.auth import get_user_model
u = get_user_model().objects.get(username='admin')
print('NETBOX_TOKEN=' + Token.objects.create(user=u).key)
"
Put that token in .env as NETBOX_TOKEN, then replay the setup job. It is
idempotent, so this is safe at any time:
docker compose run --rm windmill-setup
That single command creates the Windmill workspace, pushes the secret and plain
variables, deploys the three flows from windmill/scripts/, registers the
hourly schedules, and creates the NetBox event rules. Nothing is clicked in a
UI, and re-running it updates rather than duplicates.
Declaring the data in NetBox
Four fields carry everything.
Create the dhcp tag once (Customization → Tags), and the mac_address custom
field on IPAM > IP address (Customization → Custom Fields, type Text). Then:
| NetBox object | Value | Tag | Becomes |
|---|---|---|---|
| Prefix | 10.60.20.0/24 | dhcp | Kea subnet, router option .1 |
| IP range | .100 to .200 | dhcp | Dynamic pool inside that subnet |
| IP address | 10.60.20.10 | - | A record when dns_name is set |
| IP address | + mac_address | - | Fixed reservation in Kea |
Via the API, creating a host that gets both a record and a reservation:
curl -s -X POST http://localhost:8000/api/ipam/ip-addresses/ \
-H "Authorization: Token ${NETBOX_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"address": "10.60.20.10/24",
"status": "active",
"dns_name": "web01.lab.example",
"custom_fields": {"mac_address": "aa:bb:cc:00:11:22"}
}'
The default gateway is not a field anybody fills in: the flow computes it as the first host of the network. One less value to get wrong.
The flows, step by step
Each flow is a DAG of small Python steps. Read, compute, apply. When a run fails, you can tell at a glance whether NetBox returned nonsense, the config was built wrong, or the target rejected it.
Sync DNS: read NetBox
Step one is a paginated read, and nothing else:
NETBOX_URL = "http://netbox:8080"
def netbox_get_all(path: str, auth: str) -> list:
items, sep = [], "&" if "?" in path else "?"
url = f"{NETBOX_URL}/api/{path}{sep}limit=200"
while url:
r = requests.get(url, headers={"Authorization": auth})
r.raise_for_status()
data = r.json()
items.extend(data.get("results", []))
url = data.get("next")
return items
def main():
auth = f"Token {wmill.get_variable('u/admin/netbox_token')}"
desired = {}
for ip in netbox_get_all("ipam/ip-addresses/", auth):
if not ip.get("dns_name"):
continue
desired[ip["dns_name"].strip().rstrip(".")] = ip["address"].split("/")[0]
return {"desired": desired, "dns_records": len(desired)}
Sync DNS: apply to Technitium
Step two picks the target zone by longest suffix match, so
web01.dev.lab.example lands in dev.lab.example when that zone exists and in
lab.example otherwise:
def find_zone(fqdn: str, zones: set) -> str | None:
best = None
for z in zones:
if fqdn == z or fqdn.endswith(f".{z}"):
if best is None or len(z) > len(best):
best = z
return best
The write is an upsert, so replaying the sync is a no-op:
requests.get(f"{TECHNITIUM_URL}/api/zones/records/add", params={
"token": token, "zone": zone, "domain": fqdn,
"type": "A", "ipAddress": ip, "ttl": "300", "overwrite": "true",
}).raise_for_status()
Then the deletion pass: list the A records that exist, drop the ones NetBox no
longer knows about. This only runs inside zones the flow is allowed to touch,
controlled by u/admin/dns_allowed_zones. Leave it empty and every existing
non-internal zone is managed; set it, and the sync will never wander outside
that list. Technitium’s own internal zones (localhost, in-addr.arpa) are
excluded automatically.
Sync DHCP: skip the runs that change nothing
The DHCP webhook listens on ipam.ipaddress, because reservations live on a
custom field of the IP rather than in a model of their own. That means it also
fires when someone edits dns_name, which has nothing to do with DHCP. The
first step compares the before and after snapshots and stops early:
def main(object_type: str = None, snapshots: dict = None):
if object_type == "ipam.ipaddress":
snapshots = snapshots or {}
before = (snapshots.get("prechange") or {}).get("custom_fields", {}).get("mac_address")
after = (snapshots.get("postchange") or {}).get("custom_fields", {}).get("mac_address")
if before == after:
return {"status": "skipped",
"reason": "ipaddress change irrelevant to DHCP"}
...
Downstream steps carry skip_if: results.lire_netbox.status == 'skipped', so a
dns_name edit no longer triggers a pointless config-set. On a schedule or a
manual run, object_type is empty and the full sync happens.
Sync DHCP: build the subnets
for prefix in prefixes:
net = ipaddress.ip_network(prefix["prefix"], strict=False)
pools = [
{"pool": f"{r['start_address'].split('/')[0]} - {r['end_address'].split('/')[0]}"}
for r in ranges
if ip_in_network(r["start_address"].split("/")[0], str(net))
]
subnets.append({
"id": prefix["id"], # NetBox id, stable across runs
"subnet": str(net),
"pools": pools,
"option-data": [
{"name": "routers", "data": str(net.network_address + 1)},
{"name": "domain-name-servers", "data": kea_dns_servers},
],
"reservations": [],
})
Reusing the NetBox object id as the Kea subnet id is a small detail that pays off: the id is stable across runs, so a subnet keeps its identity even when the array is rebuilt from scratch.
Sync DHCP: apply atomically
r = requests.post(KEA_URL, json={"command": "config-get",
"service": ["dhcp4"], "arguments": {}})
dhcp = r.json()[0]["arguments"]["Dhcp4"]
dhcp["subnet4"] = subnets # the sync owns this array
r = requests.post(KEA_URL, json={"command": "config-set",
"service": ["dhcp4"],
"arguments": {"Dhcp4": dhcp}})
result = r.json()[0]
if result["result"] != 0:
raise RuntimeError(f"Kea config-set failed: {result['text']}")
Read the whole running config, replace one array, write it back. Two HTTP calls regardless of how many subnets exist, applied without restarting the service. Active leases survive the reload, which is the property that makes running this every hour safe.
Sync Proxmox SDN
VLANs become VNets, one per VLAN, named vl<vid>:
for name, want in desired.items():
if name not in existing:
pve("POST", "/cluster/sdn/vnets",
{"vnet": name, "zone": sdn_zone,
"tag": want["vid"], "alias": want["alias"]})
else:
cur = existing[name]
if cur.get("tag") != want["vid"] or (cur.get("alias") or "") != want["alias"]:
pve("PUT", f"/cluster/sdn/vnets/{name}",
{"tag": want["vid"], "alias": want["alias"]})
for name in existing:
if name not in desired:
pve("DELETE", f"/cluster/sdn/vnets/{name}")
if created + updated + removed > 0:
pve("PUT", "/cluster/sdn", {}) # without this, nothing is applied
That last line costs an afternoon if you miss it. Proxmox stages SDN changes and
only applies them on PUT /cluster/sdn. Until then the API happily reports
VNets that no interface has ever seen.
Proxmox also needs an API token rather than the root password, which is one more provisioning step, run exactly once:
docker compose run --rm proxmox-init
# creates root@pam!sync and the SDN zone, prints the token value
# copy it into .env as PROXMOX_TOKEN_VALUE, then:
docker compose run --rm windmill-setup
The token is stored as a Windmill secret variable in the
PVEAPIToken=user!name=value form, so no script ever holds a password.
Triggers: event-driven, with a safety net
setup.py registers three NetBox event rules, each pointed at a flow:
| Flow | NetBox object types | Schedule |
|---|---|---|
u/admin/sync_dns | ipam.ipaddress | 0 0 * * * * |
u/admin/sync_dhcp | ipam.prefix, ipam.iprange, ipam.ipaddress | 0 0 * * * * |
u/admin/sync_proxmox | ipam.vlan | 0 0 * * * * |
The setup code detects the NetBox major version and registers either plain webhooks (3.x) or webhooks plus event rules (4.x), so the same stack works across both.
Webhooks are the fast path: a DNS change is live seconds after somebody saves the form. The hourly schedule is the honest path. Webhooks get lost, a container restarts, somebody edits the database directly. The scheduled run is a full reconcile that does not care what happened in between, because it never computes a diff from events. It reads the desired state from NetBox, reads the actual state from the target, and makes the second look like the first.
That is the design decision worth stealing. Reconcile against reality, never against an event log. An event-driven system that cannot rebuild its state from scratch will drift, and you will find out at the worst moment.
Verifying it works
DNS, end to end:
TOKEN=$(curl -sf "http://localhost:5380/api/user/login?user=admin&pass=${TECHNITIUM_PASSWORD}" | jq -r .token)
curl -sf "http://localhost:5380/api/zones/records/get?token=$TOKEN&zone=lab.example&domain=lab.example&listZone=true" \
| jq '[.response.records[] | select(.type=="A") | {name, ip: .rData.ipAddress}]'
# resolution from inside ddns-net, where the resolver actually listens
docker run --rm --network ddi-stack_ddns-net nicolaka/netshoot \
dig @technitium web01.lab.example A +short
DHCP:
curl -s -X POST http://localhost:8080 \
-H "Content-Type: application/json" \
-d '{"command":"config-get","service":["dhcp4"],"arguments":{}}' \
| jq '[.[] | .arguments.Dhcp4.subnet4[]
| {subnet, pools: [.pools[].pool],
reservations: [.reservations[]["ip-address"]]}]'
curl -s -X POST http://localhost:8080 \
-H "Content-Type: application/json" \
-d '{"command":"lease4-get-all","service":["dhcp4"],"arguments":{"subnets":[1]}}' \
| jq '.[] | .arguments.leases[]'
Forcing a sync without touching NetBox:
TOKEN=$(curl -s -X POST http://localhost:8300/api/auth/login \
-H "Content-Type: application/json" \
-d "{\"email\":\"${WINDMILL_USER}\",\"password\":\"${WINDMILL_PASSWORD}\"}")
curl -X POST "http://localhost:8300/api/w/ddi/jobs/run/f/u/admin/sync_dns" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
Cost
| Operation | HTTP requests | Atomic |
|---|---|---|
| Read NetBox | Paginated, 200 items per request | - |
| Upsert DNS | 1 per record (overwrite=true) | No |
| Delete DNS | 1 per record | No |
| Sync DHCP | 1 config-get + 1 config-set | Yes |
| Sync Proxmox SDN | 1 GET zones + 1 GET vnets + N writes | No |
Roughly 800 addresses across three zones syncs in under a second. That is not a performance achievement, it is a consequence of the shape: reading NetBox is paginated and cheap, and DHCP collapses into two calls no matter the size. DNS is the only part that scales linearly with the number of records, and at this size linear is free.
The number that actually matters is different. Deleting a VM is now one action: remove the IP in NetBox. The record goes, the reservation goes, and nothing anywhere still claims that address.
What I would tell myself before starting
Decide what the automation is allowed to delete, on day one. A sync that
only ever adds is not a sync, it is an import. But one that deletes everything
it does not recognise will eventually eat a record you created by hand and
forgot about. dns_allowed_zones exists precisely because the first version did
not have it.
Make the setup step idempotent. Workspace, variables, flows, schedules and webhooks are all created by a script that can be replayed at will. Anything provisioned by hand becomes the one thing nobody can rebuild.
Keep the flow steps small and separate. Read, compute, apply. Three steps tell you where a run failed in one glance; one step makes you read logs.
Do not publish DNS and DHCP on the host. They are infrastructure for the stack, not services for whatever network the laptop is on. Keeping them unpublished turned out to be the single easiest security decision in the whole project.