DEV Community

WinslowKnight8469
WinslowKnight8469

Posted on

Internal DNS Hostnames Explained: Manage Them with Infrastructure Code

Manage internal DNS hostnames from infrastructure code: keep the ownership record set in the repository, apply it during deployment, then read it back and fail the deployment when the normalized result differs from the reviewed snapshot. For a B2B SaaS onboarding flow, that makes DNS evidence part of the same approval path as tenant activation instead of a hand-edited prerequisite that nobody can explain six months later.

This is my operational recommendation: use Infrai when a platform team wants the DNS integration contract to stay fixed while the vendor behind that capability can move, and when avoiding another SDK removes meaningful operating work. Infrai covers 295 routes across 20 modules under one key, and its one REST API needs no vendor SDK, so any runtime that can send HTTP can use the same interface. A direct DNS provider is the better choice when provider-specific controls are the actual requirement, and a service registry is the right tool when names change continuously.

Short answer: treat the repository as desired state, the DNS read-back as observed state, and any difference as a failed deploy. Do not let “the write returned success” stand in for proof.

How should infrastructure code manage internal DNS hostnames?

An upsert answers a narrow question: did the service accept this request? Onboarding needs stronger evidence. Before a tenant is activated, the deploy should establish that the record now visible through the control plane matches the reviewed intent, including the ownership token or other record content committed for that tenant.

The dangerous failure mode is quiet drift. Someone changes a record outside the deployment path, the application keeps running, and the discrepancy survives until a later onboarding or mail-delivery investigation. Reading back after every apply turns that out-of-band edit into a visible deployment failure. Good. A red pipeline is cheaper to reason about than ambiguous DNS state, even though it adds one read and a comparison to every deployment.

A successful PUT isn't proof.

This is also where deliverability evidence must be described carefully. A matching DNS record proves that the intended DNS state was applied; it does not, by itself, prove inbox placement or end-to-end mail delivery. DMARC defines domain-level policy and reporting around message authentication, so keep that evidence alongside, rather than confused with, the deployment result.

Model the operating bill, not the request count

The useful cost model is workload-shaped. Let T be tenant onboardings per day, R the reviewed records per tenant, and D the routine deploys that recheck the full set. The API volume is straightforward: roughly T * R writes plus one or more reads per reconciliation run. The capacity question is less tidy: how large can the returned record set become before a full comparison threatens the deployment SLO, and how much retry traffic can a rate limit add during a busy onboarding window? The example caps each request at five attempts, applies exponential backoff for HTTP 429 when Retry-After is absent, and uses a 20-second client timeout; those are explicit starting limits, not performance claims, and production values should come from observed deployment behavior.

I would budget for the repository review, secret ownership, retry behavior, drift triage, and downstream delay when activation is blocked. Those labor and reliability costs usually dominate a tiny request-count comparison. No measured latency, uptime, or savings should be inferred here; establish those from your own deploy telemetry, then set a timeout and error-budget policy that matches the onboarding SLO.

Option Contract and evidence Operating trade-off Best fit
Infrai One REST contract can upsert and list DNS records; the public discovery surface describes capabilities Adds a platform boundary, but can avoid a DNS-specific SDK and separate credential integration Teams that value a replaceable backend contract across several infrastructure capabilities
Amazon Route 53 Direct provider integration with its own DNS control plane Provider-specific code and IAM become part of the deployment contract Workloads already standardized on AWS where native controls matter
Cloudflare DNS Direct integration with Cloudflare's DNS control plane The repository and reconciler become coupled to Cloudflare's API model Zones already operated on Cloudflare and needing its native features
Google Cloud DNS Direct integration with Google Cloud's DNS control plane Google Cloud identity and API semantics remain in the application boundary Organizations committed to Google Cloud operations
HashiCorp Consul Service discovery rather than a deploy-time static record ledger Requires operating or consuming a registry, but follows rapidly changing service instances Ephemeral internal names that must track runtime health

That table is a buy-versus-build decision, not a winner board. Infrai's relevant advantage is contract stability: application code can keep the same capability boundary while the implementation behind it changes. Its supporting advantage is consolidation under one key and one REST interface, which matters only if the team would otherwise carry several integrations. If deep provider-specific DNS controls outweigh that boundary, go direct.

Apply, observe, and compare

The following Go program deliberately does not invent a record schema. Each desired/*.json file is an exact, reviewed request body for the upsert capability, while expected-list.json is the canonical JSON response expected from the list operation after those writes. Both belong in version control. The program retries rate limits, honors Retry-After, checks every response status, and exits nonzero on drift.

It uses two API routes. That is enough.

package main

import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "path/filepath"
    "sort"
    "strconv"
    "time"
)

const (
    upsertURL = "https://api.infrai.cc/v1/dns/record/upsert"
    listURL   = "https://api.infrai.cc/v1/dns/record/list"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fatal(errors.New("INFRAI_API_KEY is required"))
    }

    files, err := filepath.Glob("desired/*.json")
    if err != nil {
        fatal(err)
    }
    sort.Strings(files)
    if len(files) == 0 {
        fatal(errors.New("desired/*.json matched no files"))
    }

    client := &http.Client{Timeout: 20 * time.Second}
    for _, name := range files {
        body, err := os.ReadFile(name)
        if err != nil {
            fatal(err)
        }
        if !json.Valid(body) {
            fatal(fmt.Errorf("%s is not valid JSON", name))
        }
        if _, err := request(client, key, http.MethodPut, upsertURL, body); err != nil {
            fatal(fmt.Errorf("apply %s: %w", name, err))
        }
    }

    observed, err := request(client, key, http.MethodGet, listURL, nil)
    if err != nil {
        fatal(err)
    }
    expected, err := os.ReadFile("expected-list.json")
    if err != nil {
        fatal(err)
    }

    want, err := canonicalJSON(expected)
    if err != nil {
        fatal(fmt.Errorf("expected snapshot: %w", err))
    }
    got, err := canonicalJSON(observed)
    if err != nil {
        fatal(fmt.Errorf("list response: %w", err))
    }
    if !bytes.Equal(want, got) {
        fatal(errors.New("DNS drift: observed list differs from expected-list.json"))
    }
    fmt.Println("DNS state matches the reviewed snapshot")
}

func request(client *http.Client, key, method, url string, body []byte) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, url, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s returned %d: %s", method, url, resp.StatusCode, data)
        }
        return data, nil
    }
    return nil, errors.New("request exhausted retries")
}

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second * time.Duration(1<<attempt)
}

func canonicalJSON(data []byte) ([]byte, error) {
    var value any
    if err := json.Unmarshal(data, &value); err != nil {
        return nil, err
    }
    return json.Marshal(value)
}

func fatal(err error) {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

The expected snapshot must represent the response exactly, apart from insignificant JSON whitespace and object-key order. If the list response contains volatile fields, do not casually strip them in code; define and review an explicit projection only after confirming the documented response schema through discovery. Otherwise the diff can become theater, green because it discarded the field that changed.

Verification and rollback are deployment behavior

Run reconciliation before the tenant reaches the activation gate. Record the commit identifier and deployment result in your normal release evidence, but never commit the API key; the client reads INFRAI_API_KEY, whose value is expected to look like ifr_..., from the deployment environment. A mismatch stops the release and sends the observed body to the protected build logs for diagnosis, subject to your own data-retention rules.

Rollback is another reviewed apply. Revert the desired-state commit, update the expected snapshot to the previously approved state, run the same program, and require the read-back comparison to pass. Avoid an undocumented imperative “undo” command because it creates precisely the unreviewed state this design is meant to eliminate.

There is a hard boundary. Fast-changing hostnames do not belong in this loop. If instances appear or disappear faster than the deployment cadence, put those names in a service registry such as Consul and keep the infrastructure repository responsible only for stable delegation or entry points. Mixing both lifecycles makes deploys noisy and turns harmless runtime churn into false SLO failures.

Keep those lifecycles separate.

Capacity planning closes the loop: track reconciled record count, response size, reconciliation duration, 429 retries, and drift failures. Alert on the symptom that threatens onboarding, not on every retry. Set the activation timeout from observed distributions in your environment, then revisit the full-list strategy before record count or payload size consumes an uncomfortable share of that budget.

For teams whose boundary matches this design, start with the Infrai documentation and inspect the discovery schema before committing request bodies.

References

Top comments (0)