← All notes
A row of datacenter racks lit in blue, each carrying a glowing panel with the NetBox, Technitium, Kea and Proxmox logos
Note

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
RoleWhat runs itWhy this one
IPAMNetBoxReal data model, proper API, changelog on every object
OrchestrationWindmillFlows as versioned Python steps, plus a UI to run them
DNSTechnitiumFull HTTP API, sane zone handling, lightweight
DHCPKeaConfig is JSON and reloadable over a control socket
Network overlayProxmox VE SDNVLANs 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_sync catches, 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 in docker 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 diff as 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:

ConcernCron containern8nWindmill
TriggerFixed interval onlyWebhook + scheduleWebhook + schedule + manual
Logic in GitReal .py filesEscaped JSON stringsReal .py files
Reviewable diffYesNoYes
Per-step resultsNoYesYes
Run historydocker logsYesYes
SecretsEnv varsPlaceholder substitutionTyped 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 objectValueTagBecomes
Prefix10.60.20.0/24dhcpKea subnet, router option .1
IP range.100 to .200dhcpDynamic pool inside that subnet
IP address10.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:

FlowNetBox object typesSchedule
u/admin/sync_dnsipam.ipaddress0 0 * * * *
u/admin/sync_dhcpipam.prefix, ipam.iprange, ipam.ipaddress0 0 * * * *
u/admin/sync_proxmoxipam.vlan0 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

OperationHTTP requestsAtomic
Read NetBoxPaginated, 200 items per request-
Upsert DNS1 per record (overwrite=true)No
Delete DNS1 per recordNo
Sync DHCP1 config-get + 1 config-setYes
Sync Proxmox SDN1 GET zones + 1 GET vnets + N writesNo

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.