Bringing Nebula connectivity to everyone
A free, hosted managed lighthouse is now part of every new Managed Nebula network — sign up, enroll two machines, and they'll be instantly connected.
Now that Nebula supports IPv6 on the overlay, the need for simple names for your hosts is more apparent than ever. Typing ssh db-01.dn.example.com into a terminal or web-frontend.dn.example.com into a browser is much preferred to memorizing 100.100.1.43 or fdef:c0:c0:2966:d3bf:1a96:fa72:db82. With the Defined Networking API and your existing DNS provider, you can set this up today.
This guide walks through fetching your hosts from the API and syncing them as DNS records to Netlify DNS. The same concept works with any DNS provider that has an API, e.g. Cloudflare, Route 53, or Google Cloud DNS. A self-hosted resolver like CoreDNS works too, with a different write path; more on that at the end.
Note: This guide syncs host names to public DNS names. An enumeration of your domain could discover your host names, but with Nebula, your traffic is encrypted and the internal overlay IPs are of no use to outsiders. Consider reviewing your host names for personal information.
hosts:list and hosts:update scopesexample.com with a dn subdomain throughout)In the admin panel, create an API key with the hosts:list and hosts:update scopes. hosts:list lets the script read your hosts; hosts:update lets the script tag each host that has a DNS record, making it easy to see which hosts have successfully been processed. That second scope means the key can edit hosts, not just list them, so keep it in a secret store2. If youʼd rather the key stay read-only, create it with hosts:list alone and delete the tagging loop at the end of the script. Leave the expiration on, too. The form defaults to 30 days and the date canʼt be changed later; a key that leaks stays useful only until it expires, and a sync that runs on a schedule will tell you loudly when itʼs time to make a new one.
Copy the key somewhere safe; youʼll only see it once. Save it to a variable for the next steps:
export DN_API_KEY="dnkey-XXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXXXXXXXXXXXXXX..."Netlify needs a token too. Under Applications → Personal access tokens, pick New access token, give it a name and an expiration, and copy it the same way. Netlify tokens arenʼt scoped: this one can do anything your account can, not just edit DNS, so it belongs in the same secret store.
export NETLIFY_TOKEN="nfp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"Confirm the key works by listing your hosts.
curl -s -H "Authorization: Bearer $DN_API_KEY" \ https://api.defined.net/v2/hosts | jq '.data[] | {name, ipAddresses}'{ "name": "lighthouse-sfo", "ipAddresses": ["100.100.0.1", "fdef:c0:c0:2966:d3bf:55c:e10d:3356"]}{ "name": "db-01", "ipAddresses": ["100.100.1.43", "fdef:c0:c0:2966:d3bf:1a96:fa72:db82"]}{ "name": "web-frontend", "ipAddresses": ["100.100.1.20", "fdef:c0:c0:2966:d3bf:7115:615e:5361"]}{ "name": "Calebʼs Laptop", "ipAddresses": ["100.100.2.12", "fdef:c0:c0:2966:d3bf:d23b:37f5:e38c"]}Each host has a name (what you set in the admin panel) and an ipAddresses array with its IPv4 and IPv6 overlay addresses. In the script we’ll turn each into A and AAAA records respectively.
Then confirm the Netlify token works and that the domain you want to use is one of its zones:
curl -s -H "Authorization: Bearer $NETLIFY_TOKEN" \ https://api.netlify.com/api/v1/dns_zones | jq -r '.[].name'example.comThe default of dn for subdomain should work for most folks. If you stick with that, your hosts will get urls like db-01.dn.example.com and web-frontend.dn.example.com.
The script owns every A and AAAA record under this subdomain: it creates records for hosts that exist and deletes any record that doesnʼt match a current host, including records that were there before the first run. Choose a subdomain with no existing records so the script canʼt touch anything else.
This script fetches all your hosts from the Defined Networking API, converts the host names to valid DNS labels3. It then compares them against the current DNS records in Netlify, and creates or deletes records to keep them in sync, after which it tags every host that gets a record dns:synced. Hosts that haven’t been seen by the script or whose names can’t be converted to DNS labels won’t get the tag.
Itʼs a single Node.js file with no dependencies:
#!/usr/bin/env node// Sync Defined Networking host names into a Netlify DNS zone as A/AAAA records, then tag each// host that has one with dns:synced. Requires Node.js 20 or newer; no dependencies.import { domainToASCII, domainToUnicode } from "node:url";
const DN_API_KEY = required("DN_API_KEY", "your Defined Networking API key");const NETLIFY_TOKEN = required("NETLIFY_TOKEN", "a Netlify personal access token");const DOMAIN = required("DOMAIN", "the domain of your Netlify DNS zone");const SUBDOMAIN = process.env.SUBDOMAIN || "dn";
function required(name, what) { if (!process.env[name]) { console.error(`Set ${name} to ${what}`); process.exit(1); } return process.env[name];}
async function api(url, token, init = {}) { const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }); if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}: ${await res.text()}`); const body = await res.text(); return body ? JSON.parse(body) : null;}const dn = (path, init) => api(`https://api.defined.net${path}`, DN_API_KEY, init);const netlify = (path, init) => api(`https://api.netlify.com/api/v1${path}`, NETLIFY_TOKEN, init);
// Turn a host name into a DNS label. Letters, digits, dashes and emoji from any script// survive, apostrophes vanish ("Caleb's" -> "calebs"), and every other run of characters// becomes one dash, except at the edges or next to a dash the name already has, so// "c-----aleb" keeps its dashes and "a - b" is "a-b". Anything left that is not ASCII goes// through the same IDNA (UTS-46) encoding a browser applies to an address, so "東京" becomes// "xn--1lqs71d"; a name that is already punycode ("xn--...") is used as-is. Returns "" when// the result is not a legal label (1 to 63 characters, alphanumeric at both ends).const APOSTROPHES = /['‘’ʼ]/g;const JUNK = /[^\p{L}\p{N}\p{Extended_Pictographic}-]+/gu;const LEGAL_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;function toLabel(name) { let kept = name.normalize("NFKC").toLowerCase(); if (!kept.startsWith("xn--")) { kept = kept.replace(APOSTROPHES, "").replace(JUNK, (run, at, s) => { const edge = at === 0 || at + run.length === s.length; return edge || s[at - 1] === "-" || s[at + run.length] === "-" ? "" : "-"; }); } // The URL parser reads all-digit names like "2024" as IPv4 addresses, so only hand it non-ASCII const label = /^[a-z0-9-]*$/.test(kept) ? kept : domainToASCII(kept); return LEGAL_LABEL.test(label) ? label : "";}
const warn = (message) => console.warn(`::warning::${message}`);
// A punycode hostname is logged with its Unicode spelling alongside, e.g. "xn--fr8h.dn.example.com (💀.dn.example.com)"const shown = (hostname) => { const unicode = domainToUnicode(hostname); return unicode && unicode !== hostname ? `${hostname} (${unicode})` : hostname;};
// Look up the Netlify DNS zone ID from the domain nameconst zone = (await netlify("/dns_zones")).find((z) => z.name === DOMAIN);if (!zone) { console.error(`Error: no Netlify DNS zone found for ${DOMAIN}`); process.exit(1);}console.log(`Found zone ${zone.id} for ${DOMAIN}`);
// Fetch all hosts from the Defined Networking API, following paginationconst hosts = [];let cursor = "";do { const page = await dn(`/v2/hosts${cursor && `?cursor=${encodeURIComponent(cursor)}`}`); hosts.push(...page.data); cursor = page.metadata.hasNextPage ? page.metadata.nextCursor : "";} while (cursor);
// Sanitize each host name into a DNS label, keeping the rest of the host recordconst labeled = hosts.map((host) => ({ ...host, label: toLabel(host.name) }));
// Warn about names with no usable label (they get no record) and about collisionsfor (const { name } of labeled.filter((h) => !h.label)) warn(`"${name}" does not sanitize to a legal DNS label, skipping`);const byLabel = new Map();for (const h of labeled.filter((h) => h.label)) byLabel.set(h.label, [...(byLabel.get(h.label) ?? []), h.name]);for (const [label, names] of byLabel) { if (names.length > 1) { warn( `${names.map((n) => `"${n}"`).join(", ")} ${names.length === 2 ? "both" : "all"} sanitize to "${label}"` ); }}
// Build the desired record set: one A or AAAA record per addressconst desired = labeled .filter((h) => h.label) .flatMap((h) => h.ipAddresses.map((value) => ({ hostname: `${h.label}.${SUBDOMAIN}.${DOMAIN}`, type: value.includes(":") ? "AAAA" : "A", value, })) );
// Fetch the A/AAAA records already under our subdomainconst suffix = `.${SUBDOMAIN}.${DOMAIN}`;const existing = (await netlify(`/dns_zones/${zone.id}/dns_records`)).filter( (r) => r.hostname.endsWith(suffix) && (r.type === "A" || r.type === "AAAA"));
const key = (r) => `${r.type} ${r.hostname} ${r.value}`;const desiredKeys = new Set(desired.map(key));const existingKeys = new Set(existing.map(key));
// Delete stale records (in Netlify but not in the desired set)for (const r of existing.filter((r) => !desiredKeys.has(key(r)))) { console.log(`Deleting stale record: ${r.type} ${shown(r.hostname)} -> ${r.value}`); await netlify(`/dns_zones/${zone.id}/dns_records/${r.id}`, { method: "DELETE", });}
// Create missing records (in the desired set but not in Netlify)for (const r of desired.filter((r) => !existingKeys.has(key(r)))) { console.log(`Creating record: ${r.type} ${shown(r.hostname)} -> ${r.value}`); await netlify(`/dns_zones/${zone.id}/dns_records`, { method: "POST", body: JSON.stringify({ ...r, ttl: 3600 }), });}
// Tag every host that has a record and untag the rest, so the admin panel shows which names// resolve. Editing a host resets every field the request leaves out, so each PUT sends the host// back whole and changes only its tags.const SYNCED = "dns:synced";for (const h of labeled) { if (h.tags.includes(SYNCED) === Boolean(h.label)) continue; const tags = h.label ? [...h.tags, SYNCED] : h.tags.filter((t) => t !== SYNCED); console.log(`${h.label ? "Tagging" : "Untagging"} "${h.name}" ${SYNCED}`); const { name, roleID, staticAddresses, listenPort, configOverrides } = h; await dn(`/v3/hosts/${h.id}`, { method: "PUT", body: JSON.stringify({ name, roleID, staticAddresses, listenPort, configOverrides, tags }), });}
console.log("Sync complete.");Save it as sync-nebula-dns.mjs and run it:
export DOMAIN="example.com"export SUBDOMAIN="dn"node sync-nebula-dns.mjsFound zone 60a1b2c3d4e5f6a7b8c9d0e1 for example.comCreating record: A lighthouse-sfo.dn.example.com -> 100.100.0.1Creating record: AAAA lighthouse-sfo.dn.example.com -> fdef:c0:c0:2966:d3bf:55c:e10d:3356Creating record: A db-01.dn.example.com -> 100.100.1.43Creating record: AAAA db-01.dn.example.com -> fdef:c0:c0:2966:d3bf:1a96:fa72:db82Creating record: A web-frontend.dn.example.com -> 100.100.1.20Creating record: AAAA web-frontend.dn.example.com -> fdef:c0:c0:2966:d3bf:7115:615e:5361Creating record: A calebs-laptop.dn.example.com -> 100.100.2.12Creating record: AAAA calebs-laptop.dn.example.com -> fdef:c0:c0:2966:d3bf:d23b:37f5:e38cTagging "lighthouse-sfo" dns:syncedTagging "db-01" dns:syncedTagging "web-frontend" dns:syncedTagging "Calebʼs Laptop" dns:syncedSync complete.After the first run, you should see your hosts as DNS records:
host db-01.dn.example.com# ordig db-01.dn.example.com A db-01.dn.example.com AAAA +shortdb-01.dn.example.com has address 100.100.1.43db-01.dn.example.com has IPv6 address fdef:c0:c0:2966:d3bf:1a96:fa72:db82# or100.100.1.43fdef:c0:c0:2966:d3bf:1a96:fa72:db82Host names in Defined Networking are free-form: they can contain spaces, apostrophes, emoji, and characters from any script, most of which arenʼt valid in a DNS label. The sync script lowercases each name, drops apostrophes, keeps letters, digits, dashes, and emoji, and turns every other run of characters into a single dash. Calebʼs Laptop becomes calebs-laptop.dn.example.com. Dashes you typed yourself are left alone, so c-----aleb is a legal label and stays one, and a name thatʼs already punycode (xn--…) is used as-is.
Whatever is left that isnʼt ASCII goes through the same IDNA encoding a browser applies when you type a Unicode address, so 東京 becomes xn--1lqs71d, привет becomes xn--b1agh1afp, and a host named 💀 gets the record xn--fr8h.dn.example.com. Node.js exposes that encoding as url.domainToASCII, which is what the script calls.
The scriptʼs log shows both spellings, so you can tell which host a punycode record belongs to:
Creating record: A xn--fr8h.dn.example.com (💀.dn.example.com) -> 100.100.3.7Whether you can use those names depends on the client. Browsers and curl run the same encoding on their end, so http://東京.dn.example.com:3000 and http://💀.dn.example.com open your internal services exactly as typed. ssh doesnʼt: OpenSSH passes the raw UTF-8 bytes to the resolver, which canʼt find a record for them, and youʼd have to spell out ssh xn--1lqs71d.dn.example.com yourself. If your hosts are mostly SSH targets, plain ASCII names are the easier life; if theyʼre mostly web services, name them however you like.
Opening the name and seeing it in the address bar are two different questions, though. Each browser decides for itself whether to show a label as Unicode or leave it as xn--…, and they disagree in both directions. Chrome and Firefox only render a label as Unicode when every character is one the identifier specs allow (UTS 39 for Chrome, IDNA2008 for Firefox), and emoji are on neither list, so http://💀.dn.example.com loads but the address bar reads xn--fr8h.dn.example.com. Safari keeps its own allow-list of scripts, symbols included, so it shows the 💀. CJK and most other single-script names show as typed in all three. Cyrillic goes the other way: Chrome and Firefox show сервер.dn.example.com as typed (Chrome adds a lookalike check that punycodes names spelled entirely from Latin-lookalike letters), while Safari leaves Cyrillic as punycode unless the domain ends in a Cyrillic-script TLD like .рф or .укр, and Greek stays punycode everywhere. The rules are public if you want to check a name: Chromeʼs, Firefoxʼs (plus the open bug explaining why emoji stay punycode), and Safariʼs, which lives in WebKitʼs URLHelpers.cpp.
A name that doesnʼt come out as a legal label (nothing but punctuation, a leading or trailing dash, or more than 63 characters once encoded) is skipped with a warning rather than sent to Netlify, which refuses it:
::warning::"???" does not sanitize to a legal DNS label, skippingIf two hosts sanitize to the same label, the script warns you and keeps going, so both hosts end up with records under the shared name until you rename one of them in the admin panel:
::warning::"Calebʼs Laptop", "calebʼs laptop" both sanitize to "calebs-laptop"Fine. Your hosts have DNS names. Now what? They’ll get out of sync as soon as the next host is created.
The sync script needs to run periodically to pick up new/renamed hosts and clean up removed ones. A GitHub Actions scheduled workflow is a lightweight way to do this that doesnʼt need a server.
We have a template repo set up with the script and the workflow below.
DN_API_KEY and NETLIFY_TOKEN as repository secrets.DOMAIN in .github/workflows/sync-dns.yml, and SUBDOMAIN if you chose something other than dn.schedule trigger in the workflow; the template ships with it commented out so the sync doesnʼt run (and fail) before your secrets are configured.name: Sync Nebula DNSon: # Uncomment after adding your repository secrets: # schedule: # - cron: "*/15 * * * *" workflow_dispatch: # manual trigger
jobs: sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Sync DNS records env: DN_API_KEY: ${{ secrets.DN_API_KEY }} NETLIFY_TOKEN: ${{ secrets.NETLIFY_TOKEN }} DOMAIN: example.com SUBDOMAIN: dn run: node sync-nebula-dns.mjsThe scriptʼs warnings use the GitHub Actions workflow command format, so they also appear as annotations on each runʼs summary.
If you donʼt want to use GitHub Actions, a cron job on an always-on server works too.1
No DNS answer? If host db-01.dn.example.com comes back empty even though the record exists in Netlify, your DNS resolver is probably filtering it. Some routers and resolvers strip private IP addresses (like 10.x.x.x or 192.168.x.x) from DNS responses to defend against DNS rebinding, and depending on which IPv4 CIDR your overlay network uses, your Nebula addresses can fall inside that filter. The answers pass through whichever resolver your client uses (your router, your ISP, or a public resolver), so the filter can sit anywhere along that path.
To fix it, exempt your Nebula subdomain from rebinding protection on the resolver you control, or point your client at a resolver that doesnʼt filter, such as 8.8.8.8 or 1.1.1.1.
Services not loading? Be sure to confirm you’ve allowed the connection between the hosts in the admin panel - you can now go to the host that’s trying to access the service and view the “Effective Access” panel to see if it has access. When first dogfooding, I forgot to add the right tag to preview this blog post from my laptop!
Mac canʼt reach its own overlay IPv6? DNClient ≤0.9.7 / Nebula ≤1.11.0 on macOS can’t reach its own overlay IPv6 (ping works, TCP/UDP doesn’t) — fixed in DNClient 0.9.8 / #1862.
Service aliases. The sync script gives you a DNS name per host, but you might also want names for the services running on those hosts. Add CNAME records in your DNS provider pointing service names at host names, either under a separate subdomain like svc or directly on your root domain. As long as theyʼre outside the synced dn subdomain, the script wonʼt touch them:
grafana.svc.example.com. IN CNAME db-01.dn.example.com.grafana.example.com. IN CNAME db-01.dn.example.com.Docker sidecar. Using the Docker DNClient and Caddy as a proxy, you can enable a service on an automatically synced domain name. Create a host in the admin panel just for that service, setup the Caddyfile to proxy the service’s port to :80, and setup firewall rules for access.
CoreDNS private DNS. If youʼd rather not publish your hostsʼ names on the public internet, serve them from a private CoreDNS instead. CoreDNS has no record API, but its hosts plugin serves A and AAAA records from an /etc/hosts-style file and re-reads it every few seconds, so swap the Netlify calls in the script for writing that file: one line per address, like 100.100.1.43 db-01.dn.example.com. Run CoreDNS on a Nebula host, and point your clients at its overlay IP for just that subdomain (an /etc/resolver/dn.example.com file on macOS, a ~dn.example.com routing domain in systemd-resolved), and the names never leave the overlay.
Please write if you’ve tried this tutorial out, or if you’re adapting it to another setup, I’m keen to hear what your setup looks like! Reach out via our contact form, Bluesky or post to our new subreddit r/DefinedNet!
Fly.io scheduled Machines are another lightweight option if you donʼt have a dedicated server. ↩ ↩2
Some examples include 1Password secret references and Hashicorp Vault OSS ↩
https://www.rfc-editor.org/info/rfc1035/#section-2.3.1 and https://www.rfc-editor.org/info/rfc4343/ are relevant if you’re a nerd who wants to read the RFCs ↩
A free, hosted managed lighthouse is now part of every new Managed Nebula network — sign up, enroll two machines, and they'll be instantly connected.
Mobile Nebula v0.10.0 brings Always On support, custom DNS resolvers, full config imports, and inbound firewall rules to Android and iOS.
Nebula v1.10.0 is here with IPv6 overlay support, multiple Nebula IPs per host, a new certificate format, and more.
Connectivity that just works. Connect up to 100 devices free, no credit card required.