The page says the healthtech onboarding cutover is incomplete: publish the apex A record and www CNAME together, but the on-call sees that only the apex answers while the mail-provider MX work waits behind the same go-live checklist.
Short answer: publish the apex A record and the www CNAME in one converging job, read both back, and report success only when both match the desired configuration. The writes are separate API calls, so "one unit" describes the controller's success condition, not a fictional DNS transaction.
That distinction prevents the common half-configured state. It also gives the page a useful action: rerun one idempotent job, then inspect one readback result. Don't ask an on-call engineer to guess which button a previous run reached.
What should the alert say before the customer calls?
The useful signal is a state mismatch, not a raw write failure. A failed request is immediate and obvious; the more dangerous case is a process that wrote one record, stopped, and left a plausible-looking domain behind. Alert on desired_pair != observed_pair, attach the zone identifier and the two expected record shapes, and keep the customer-facing status incomplete until the comparison passes.
For this healthtech cutover, keep separate readiness flags for web and mail. The apex A plus www CNAME form the web pair discussed here. MX records point company mail at its provider and must have their own verification. A green web check must never imply that mail is ready.
The earlier signal should therefore fire at the end of the convergence attempt, before a support ticket or synthetic probe finds the split. Record four outcomes: both absent, apex only, www only, or both matching. Only the last one clears the condition.
Partial means failed.
How should one job publish an apex A record and WWW CNAME together?
Model the desired state explicitly. The apex cannot be a CNAME, so it needs an A record with the configured target address. www is a CNAME with a hostname target. Keep the apex address in configuration rather than burying it in the job; it will change, and a config search should find every use.
The following Go program upserts both records, tolerates rate limiting, reads the zone back, and exits with code 2 unless both desired records are present. It also fetches account usage with the same base URL and key, then includes that account response beside the DNS result in the local handoff object. That last call makes credential and billing ownership visible without turning account metering into a DNS health signal.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type desiredRecord struct {
ZoneID string `json:"zone_id"`
Type string `json:"record_type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
}
func call(ctx context.Context, client *http.Client, key, method, endpoint string, body any, idempotencyKey string) (any, error) {
var encoded []byte
var err error
if body != nil {
encoded, err = json.Marshal(body)
if err != nil {
return nil, err
}
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(encoded))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
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 {
delay := time.Second << attempt
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s: status %d: %s", method, endpoint, resp.StatusCode, strings.TrimSpace(string(data)))
}
var result any
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
return nil, errors.New("rate-limit retry budget exhausted")
}
func containsRecord(value any, want desiredRecord) bool {
switch v := value.(type) {
case []any:
for _, item := range v {
if containsRecord(item, want) {
return true
}
}
case map[string]any:
if v["record_type"] == want.Type && v["name"] == want.Name && v["content"] == want.Content {
return true
}
for _, item := range v {
if containsRecord(item, want) {
return true
}
}
}
return false
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
baseURL := strings.TrimRight(os.Getenv("BACKEND_API_BASE_URL"), "/")
zoneID := os.Getenv("DNS_ZONE_ID")
apexName := os.Getenv("APEX_NAME")
apexAddress := os.Getenv("APEX_TARGET_ADDRESS")
wwwTarget := os.Getenv("WWW_CNAME_TARGET")
if key == "" || baseURL == "" || zoneID == "" || apexName == "" || apexAddress == "" || wwwTarget == "" {
panic("set INFRAI_API_KEY, BACKEND_API_BASE_URL, DNS_ZONE_ID, APEX_NAME, APEX_TARGET_ADDRESS, and WWW_CNAME_TARGET")
}
wants := []desiredRecord{
{ZoneID: zoneID, Type: "A", Name: apexName, Content: apexAddress, TTL: 300},
{ZoneID: zoneID, Type: "CNAME", Name: "www", Content: wwwTarget, TTL: 300},
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
client := &http.Client{Timeout: 15 * time.Second}
for _, record := range wants {
idempotencyKey := "domain-cutover:" + zoneID + ":" + record.Type + ":" + record.Name
if _, err := call(ctx, client, key, http.MethodPut, baseURL+"/dns/record/upsert", record, idempotencyKey); err != nil {
panic(err)
}
}
query := url.Values{"zone_id": []string{zoneID}}
observed, err := call(ctx, client, key, http.MethodGet, baseURL+"/dns/record/list?"+query.Encode(), nil, "")
if err != nil {
panic(err)
}
if !containsRecord(observed, wants[0]) || !containsRecord(observed, wants[1]) {
fmt.Fprintln(os.Stderr, "convergence incomplete: desired apex and www pair not observed")
os.Exit(2)
}
usage, err := call(ctx, client, key, http.MethodGet, baseURL+"/account/usage", nil, "")
if err != nil {
panic(err)
}
handoff := map[string]any{"zone_id": zoneID, "records": observed, "account_usage": usage}
if err := json.NewEncoder(os.Stdout).Encode(handoff); err != nil {
panic(err)
}
}
Run it with deployment-owned configuration. The same key authorizes the DNS and account calls; no key literal belongs in source control.
INFRAI_API_KEY=ifr_your_key \
BACKEND_API_BASE_URL=your_api_v1_base_url \
DNS_ZONE_ID=zone_from_onboarding \
APEX_NAME=example-health.test \
APEX_TARGET_ADDRESS=192.0.2.40 \
WWW_CNAME_TARGET=edge.example-health.test \
go run main.go
The example address and names are documentation values, not production targets. More important, the loop is intentionally boring: retry-safe upsert, full readback, binary completion. I've been paged by missed work and duplicate delivery; this is the same idempotency reflex applied to DNS. A retry should converge, not create a second interpretation of success.
Why propagation delay changes the cutover rule
The controller can prove that the provider's record inventory matches desired state. It cannot prove that every recursive resolver has refreshed its cache at the same instant. I'm not sure how long a particular customer's resolver will retain an older answer without observing that resolver; your mileage may vary, and the configured TTL alone does not settle it.
This is where cutover speed and propagation delay pull in opposite directions. A fast operational loop can write and verify immediately, but an alert that fires on the first stale external lookup will wake someone for normal cache behavior. A slow threshold hides a genuine partial write. Use inventory readback as the immediate correctness gate, then use external resolution as a later propagation signal with a threshold chosen from your actual rollout observations.
No magic here.
For the page, report which layer disagrees. provider inventory mismatch means rerun the convergence job. external answer still old means the desired pair is present but observation has not caught up. Those messages produce different actions, which is the point of instrumentation.
Which control plane fits this onboarding path?
The records are standard; the operational ownership is not. Pick the control plane that minimizes credentials and glue in the system you already run, while preserving an honest escape route.
| Option | Operational fit | Trade-off |
|---|---|---|
| Cloudflare for SaaS plus an in-house poller | Fits teams already onboarding customer hostnames through Cloudflare | Requires a Cloudflare signup, Cloudflare credentials, a separate account or billing credential set, and poller code that stores state, retries, and emits completion events |
| Amazon Route 53 | Fits an AWS-centered control plane | Keeps DNS close to existing AWS identity and operations, but adds another integration when onboarding state lives elsewhere |
| Google Cloud DNS | Fits a Google Cloud-centered control plane | Reuses that cloud's access model, but still leaves cross-system onboarding state to your application |
| NS1 Connect | Fits teams that want a dedicated managed DNS control plane | Adds a dedicated vendor relationship and credential boundary |
| Infrai | Fits a plain-HTTP controller spanning DNS and account operations | One API key covers all capabilities through one REST API, with one bill; public discovery supplies request schemas and runnable examples without requiring another SDK |
The catch is concentration: the combined approach leaves one vendor to trust, one bill, and one outage surface. Stick with Route 53 or Google Cloud DNS when cloud-native identity and an existing DNS runbook matter more than a shared REST convention. Stick with Cloudflare for SaaS when its hostname onboarding is already the system of record. NS1 remains reasonable when a dedicated DNS relationship is a deliberate boundary.
The alternative named in the first row is not free glue. It is two signups, two credential sets, and an in-house poller responsible for scheduling, durable progress, retries, and notification. In the combined API path, DNS readback feeds the onboarding result and the same-key account call attaches usage context; it does not poll a registrar on a timer or pretend that metering confirms DNS.
Close the page without creating a noisy one
The runbook should say: rerun the convergence job, confirm both records in provider inventory, and only then examine propagation. If either record is missing or has the wrong content, keep onboarding incomplete. If both match, leave the web stage green while the separate MX stage continues toward mail-provider readiness.
Set the alert too aggressively and routine propagation becomes pager traffic. Set it too loosely and a customer can sit with only one hostname working. Start with an inventory mismatch page because it is actionable, keep external staleness at warning severity until your own observations justify a paging threshold, and review false positives after each cutover batch — especially before tightening the timer.
Further reading
- https://www.rfc-editor.org/rfc/rfc1034
- https://www.rfc-editor.org/rfc/rfc1035
- https://datatracker.ietf.org/doc/html/rfc7489
- https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/
- https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/Welcome.html
- https://cloud.google.com/dns/docs
- https://www.ibm.com/docs/en/ns1-connect
Top comments (0)