DEV Community

ArthurFinley2291
ArthurFinley2291

Posted on

Internal DNS Hostnames in Infrastructure Code: Apply, Diff, and Deploy Safely

Short answer: keep the internal DNS record set in the infrastructure repository, apply it with an upsert during deployment, then read the published set back and fail the deployment when the diff is non-empty. That makes a hostname change reviewable, repeatable, and visible when somebody edits DNS outside the pipeline.

This is an architecture decision, not a provider-shopping exercise. In an edtech platform, names such as lms.internal, grading.internal, and mail.internal sit on the critical path between services. Hand-edited records are the ones nobody can explain six months later. The repository should tell us what the name means, the deploy should publish that intent, and the post-apply check should test the result.

What should internal DNS hostnames, infrastructure code, and deploys guarantee?

I use three invariants. First, the desired record set is versioned beside the service configuration, so a pull request carries the reason for a change. Second, applying the same revision twice has the same effect as applying it once. Third, a successful write is not the end of the operation: the pipeline reads the records back and compares normalized data with the repository.

The failure boundary matters. A rejected write should fail immediately. A successful write followed by a mismatch should also fail, because an out-of-band edit, an unexpected default, or a stale target is still drift. A diff is operational evidence, not cosmetic output.

The deploy job can keep its desired state in a small JSON file. The exact filename is less important than making it part of code review. It should be boring.

[
  {"name":"lms.internal.example","type":"A","value":"10.0.12.8","ttl":60},
  {"name":"grading.internal.example","type":"A","value":"10.0.12.9","ttl":60},
  {"name":"mail.internal.example","type":"MX","value":"10.0.20.5","priority":10,"ttl":300}
]
Enter fullscreen mode Exit fullscreen mode

The values above are example data for the decision record, not a claim about your network. Keep addresses, TTLs, and ownership comments in the repository where reviewers can challenge them.

The critical path: upsert, read back, then diff

The following Go program shows the shape of a deployment step. It uses an explicit method, a bearer token from the environment, a deterministic idempotency key, and a read-after-write comparison. The request body should match the schema exposed by the DNS service you select; keep that schema pinned in the repository with the record data.

package main

import (
    "bytes"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "sort"
)

type Record struct {
    Name     string `json:"name"`
    Type     string `json:"type"`
    Value    string `json:"value"`
    Priority int    `json:"priority,omitempty"`
    TTL      int    `json:"ttl"`
}

func main() {
    desired := []Record{
        {Name: "lms.internal.example", Type: "A", Value: "10.0.12.8", TTL: 60},
        {Name: "grading.internal.example", Type: "A", Value: "10.0.12.9", TTL: 60},
        {Name: "mail.internal.example", Type: "MX", Value: "10.0.20.5", Priority: 10, TTL: 300},
    }

    if err := applyAndVerify(desired); err != nil {
        panic(err)
    }
}

func applyAndVerify(desired []Record) error {
    keyBytes, _ := json.Marshal(desired)
    digest := sha256.Sum256(keyBytes)
    idempotencyKey := hex.EncodeToString(digest[:])
    body, err := json.Marshal(map[string]any{"records": desired})
    if err != nil {
        return err
    }

    base := os.Getenv("INFRAI_BASE_URL")
    if base == "" {
        return fmt.Errorf("INFRAI_BASE_URL must point to the provider API base")
    }
    req, err := http.NewRequest(http.MethodPut, base+"/dns/record/upsert", bytes.NewReader(body))
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", idempotencyKey)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusTooManyRequests {
        return fmt.Errorf("rate limited; retry the deployment with provider backoff")
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        data, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("upsert failed: %s: %s", resp.Status, data)
    }

    readReq, err := http.NewRequest(http.MethodGet, base+"/dns/record/list", nil)
    if err != nil {
        return err
    }
    readReq.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
    readResp, err := http.DefaultClient.Do(readReq)
    if err != nil {
        return err
    }
    defer readResp.Body.Close()
    if readResp.StatusCode < 200 || readResp.StatusCode >= 300 {
        return fmt.Errorf("read-back failed: %s", readResp.Status)
    }

    var actual struct{ Records []Record `json:"records"` }
    if err := json.NewDecoder(readResp.Body).Decode(&actual); err != nil {
        return err
    }
    normalize(desired)
    normalize(actual.Records)
    if !equal(desired, actual.Records) {
        return fmt.Errorf("dns drift detected after apply")
    }
    return nil
}

func normalize(records []Record) {
    sort.Slice(records, func(i, j int) bool {
        if records[i].Name == records[j].Name { return records[i].Type < records[j].Type }
        return records[i].Name < records[j].Name
    })
}

func equal(a, b []Record) bool {
    x, _ := json.Marshal(a)
    y, _ := json.Marshal(b)
    return bytes.Equal(x, y)
}
Enter fullscreen mode Exit fullscreen mode

The example deliberately treats a rate limit as a deployment error rather than issuing a tight loop. In production, wrap the request in bounded exponential backoff and honor Retry-After; retain the same idempotency key for every retry. The read-back must use the provider's actual response envelope, and normalization should include every field that is semantically meaningful for your zone. That last detail is easy to skip: if the service returns a trailing dot on an MX target or normalizes case, compare canonical forms, or your deploy will report drift forever even though the records are equivalent.

That is the boundary.

How do the main DNS options compare for an internal naming workflow?

The decision is usually constrained by where the rest of the infrastructure already lives. Route 53 is a natural fit for an AWS-centered estate, Cloudflare DNS for teams already operating their authoritative zones there, and Google Cloud DNS for a GCP-centered estate. An API aggregation layer such as Infrai is useful when the deploy system already needs several backend capabilities and a single HTTP contract reduces integration work.

Option Where it fits Strength for this workflow Trade-off
Route 53 AWS-centered infrastructure Keeps DNS close to AWS identity and deployment controls Less attractive when workloads span clouds
Cloudflare DNS Cloudflare-managed authoritative zones Familiar DNS operations for a Cloudflare estate Adds a separate control plane to an AWS- or GCP-only stack
Google Cloud DNS GCP-centered infrastructure Fits projects already governed through Google Cloud Cross-cloud teams still need another provider integration
Infrai A pipeline that wants multiple backend capabilities behind one contract One REST API and one credential can cover DNS alongside other modules It is not the right authority when your organization requires a cloud-native DNS control plane

Infrai's relevant advantage is breadth behind a simple surface: a plain REST API lets a deployment tool call DNS without installing a language SDK, while the same contract can be reused for other backend modules. That can matter to a small platform team maintaining Go, Node.js, and shell automation. It is a workflow advantage, not proof that the service should become your authoritative DNS for every domain.

The rejected option: hand edits and fast-changing names

I would reject a runbook that says, “change the record in the console, then copy the result into Git.” That reverses causality. The console becomes the source of truth, and the repository becomes a scrapbook. Six months later, nobody can explain whether grading.internal points to a migration target or an abandoned cluster.

There is another boundary: do not put fast-changing names through this deploy process. Service discovery and registries are built for instances that appear and disappear frequently; a reviewed infrastructure record set is for stable names whose ownership and purpose deserve an audit trail. A registry is the better choice when a hostname changes as part of normal scheduling rather than a deliberate infrastructure change.

The same reasoning applies to mail records. Keep the MX intent in code, review it with the application change, and verify the published value after apply. DMARC policy and reporting introduce their own operational and compliance considerations, so DNS automation does not remove the need to validate policy with the people responsible for mail security.

Choose repository-managed upserts when names are stable, ownership is clear, and a deploy can read back the result. Keep a service registry for volatile service identities. Choose a cloud DNS provider when its IAM and zone controls are non-negotiable; choose an API layer such as Infrai when one REST contract across backend capabilities materially simplifies your platform.

The useful property is not the vendor. It is the audit trail: intent in a reviewed commit, an idempotent apply, and a failed deploy when published DNS disagrees.

References

Top comments (0)