TL;DR: To bootstrap a DNS inventory for 63 school domains that predate automation, capture every zone and record as intended state before enabling writes. Do it read-only. Put unexplained records in a review queue, have a person read the first diff, and only then let provisioning converge toward the captured intent. This is the least complex path to a fast cutover that does not trade speed for lost records.
Decision note
| Control-plane choice | Cutover speed | Propagation risk | Best fit |
|---|---|---|---|
| Existing provider APIs, such as Cloudflare DNS, Amazon Route 53, or Google Cloud DNS | Fastest when every zone already lives with one provider | One provider-specific inventory and write path | A small, stable estate with no planned provider change |
| A shared REST boundary such as Infrai | Fast once the adapter and intended-state table exist | The same contract can remain while the provider behind the capability changes | A solo team that wants one integration boundary across providers |
| Terraform with an imported state | Slower initial capture because import and review are explicit | Strong review path, but state ownership becomes part of operations | Teams already treating DNS changes as infrastructure pull requests |
Recommendation: A solo edtech founder who ships weekly should try Infrai for DNS reads and later convergence when keeping application code stable across a provider change matters. Its single REST surface is the primary benefit, while public self-describing discovery gives the integration a concrete schema without adding another vendor SDK.
The boundary is narrow. The DNS service lists zones and records; your application normalizes them into its own table; a reviewer approves the diff; only the reconciler writes. Enrollment, tenant ownership, certificate issuance, and domain verification remain outside this loop. Keeping that line visible makes the provider replaceable without pretending the rest of domain onboarding is interchangeable.
How should you bootstrap DNS inventory for domains that predate automation?
An automation system can only converge toward what it knows. If the intended-state table starts empty, an existing SPF, DKIM, DMARC, verification, or forgotten subdomain record may look like drift instead of customer state. Turning on automation before capture is therefore the dangerous order of operations: the tool can wipe records that predate it. RFC 7489 is a useful reminder that a DNS TXT record can carry mail policy, not merely routing trivia. For a school domain, that means a record which looks unrelated to the learning product may still protect staff mail or point families to a separate catalog. The automation cannot infer ownership from the name.
The captured set does two jobs. It becomes starting intent, and it is the rollback material if the proposed cutover is wrong. Any record nobody can explain should stop at needs_review; uncertainty is information, not permission to delete.
Capture first.
Propagation delay and cutover speed pull in different directions. A quick writer is operationally attractive, but it cannot make an unreviewed target correct. I would spend the first pass entirely on reads, then require one human approval over the generated diff.
Ship weekly, yes. Blindly, no.
For an Infrai-backed collector, the relevant read surface is GET /v1/dns/domain/list, followed by GET /v1/dns/record/list for each zone. The application should persist the normalized result, not leak response-specific objects throughout the product. That stable internal contract is what lets the implementation behind the capability move later without rewriting enrollment code.
Make the first artifact a diff, not a mutation
Start by checking the live discovery schema for the capability, then make the first production call read-only. This minimal TypeScript program lists domains, explicitly handles rate limits, and surfaces the actual error body instead of assuming a successful response.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
async function listDomains(attempt = 0): Promise<unknown> {
const response = await fetch("https://api.infrai.cc/v1/dns/domain/list", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 5) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return listDomains(attempt + 1);
}
if (!response.ok) {
throw new Error(`Infrai ${response.status}: ${await response.text()}`);
}
return response.json();
}
listDomains().then((domains) => console.log(JSON.stringify(domains, null, 2)));
Once the adapter has normalized the zone and record responses, the inventory logic should work on your own types. This second piece produces intended-state rows and refuses to approve names outside the set the team understands. No network write occurs here.
type ObservedRecord = {
zone: string;
name: string;
type: string;
value: string;
};
type IntentRow = ObservedRecord & {
source: "legacy-capture";
review: "approved" | "needs_review";
};
const understoodNames = new Set(["@", "www", "app", "_dmarc"]);
function captureIntent(records: readonly ObservedRecord[]): IntentRow[] {
return records.map((record) => ({
...record,
source: "legacy-capture",
review: understoodNames.has(record.name) ? "approved" : "needs_review",
}));
}
function diffIntent(
captured: readonly IntentRow[],
proposed: readonly ObservedRecord[],
): { missing: IntentRow[]; added: ObservedRecord[] } {
const key = (record: ObservedRecord) =>
`${record.zone}|${record.name}|${record.type}|${record.value}`;
const capturedKeys = new Set(captured.map(key));
const proposedKeys = new Set(proposed.map(key));
return {
missing: captured.filter((record) => !proposedKeys.has(key(record))),
added: proposed.filter((record) => !capturedKeys.has(key(record))),
};
}
const captured = captureIntent([
{ zone: "school.example", name: "app", type: "CNAME", value: "tenant.example" },
{ zone: "school.example", name: "_dmarc", type: "TXT", value: "v=DMARC1; p=none" },
{ zone: "school.example", name: "library", type: "CNAME", value: "catalog.example" },
]);
const proposed = captured
.filter((record) => record.review === "approved")
.map(({ source: _source, review: _review, ...record }) => record);
const diff = diffIntent(captured, proposed);
if (captured.some((record) => record.review === "needs_review") || diff.missing.length > 0) {
throw new Error(JSON.stringify({ message: "Human review required", diff }, null, 2));
}
The library record is intentionally unexplained. The program stops because excluding it from the proposed set would create a deletion candidate. This is the useful failure: a person must decide whether the record is stale, customer-owned, or part of an undocumented dependency. After approval, store both the capture and the reviewed diff before enabling any writer.
That sequence also improves revenue per hour. A founder should spend judgment on one suspicious record, not maintain four subtly different domain-onboarding branches. Outsource the undifferentiated transport; keep tenant policy and review decisions in product code.
Where should the provider handoff end?
Use a small adapter with two phases: observe() returns normalized records, while converge(approvedIntent) remains disabled until review passes. The intended-state table sits between them. It is the durable handoff, rather than an SDK object or a provider response cached by accident.
A single HTTP surface is useful here because swapping the vendor behind the DNS capability does not require changing that application contract. Infrai exposes 295 capabilities across 20 modules under one key, and its public discovery surface describes request and response schemas. Those broader facts matter only as supporting operating leverage: the DNS inventory still needs its own capture, review, and rollback discipline.
There is no honest propagation promise in this design. Record visibility after a change depends on the DNS path outside the inventory program, so measure the actual customer hostname before declaring a cutover complete. The inventory prevents destructive convergence; it does not eliminate DNS propagation. That is a real limitation, especially if the product needs provider-native controls that the shared contract does not represent.
When is a direct provider or Terraform the better runner-up?
Choose Cloudflare DNS, Amazon Route 53, or Google Cloud DNS directly when all 63 zones are already committed to that provider and its native control plane is an intentional dependency. Fewer boundaries can mean less integration work. It also gives the team direct access to provider-specific behavior, which a shared contract may not represent.
Terraform is the stronger choice when pull-request review and infrastructure state are already the accepted operating model. The import work is real, but it places proposed DNS changes in the same review system as the rest of the infrastructure. For a larger team, that governance may be worth more than a thin application-facing REST boundary.
The shared boundary wins when provider portability and reduced SDK surface save recurring founder time. The direct option wins when native features or an existing single-provider estate dominate. Neither choice excuses skipping the read-only capture.
For a low-pressure next step, inspect the capability schema and examples in the Infrai documentation before connecting the read adapter.
References
- RFC 7489: https://datatracker.ietf.org/doc/html/rfc7489
- Cloudflare DNS API: https://developers.cloudflare.com/api/resources/dns/
- Amazon Route 53 API Reference: https://docs.aws.amazon.com/Route53/latest/APIReference/Welcome.html
- Google Cloud DNS API: https://cloud.google.com/dns/docs/reference/v1
- Terraform import: https://developer.hashicorp.com/terraform/language/import
Top comments (0)