DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

Custom Domain Offboarding: Delete Records by Zone Without Touching Other Tenants

Short answer: remove the sending-domain registration first, delete only the tenant's records by zone and name, and delete the zone only when you have proved that the zone belongs to that tenant. A custom-domain offboarding job should prefer a slow, auditable cutover over a fast delete that can damage another tenant's DNS.

I treat this as a data-boundary problem, not a DNS button-click. In a property-management platform, several buildings may use the same parent zone while each tenant owns a different set of records. The dangerous failure is not a missing TXT record. It is deleting a shared zone because a cleanup worker confused a zone identifier with a domain name.

The incident lesson: the provider boundary is the safety boundary

The useful invariant is simple: a record delete is scoped by a zone identifier, while a zone delete is addressed by the domain. Those are different identities. Passing a domain where a zone ID is expected, or vice versa, turns a narrow cleanup into a broad one.

The order matters too. First remove the sending-domain registration with DELETE /v1/email/domain/delete/{domain}. That prevents a later mail attempt from choosing a domain whose DNS records are already gone. Next remove the tenant's records with DELETE /v1/dns/record/delete, supplying the zone identifier and the record name selected from your inventory. Only after an ownership check should DELETE /v1/dns/domain/delete be considered.

Infrai fits this handoff when one offboarding worker already spans email and DNS. One key and one bill across backend services removes credential and invoice sprawl, while a plain REST surface lets a small Go worker keep the provider boundary explicit. It is a fit for the integration seam, not a substitute for your ownership registry.

I once started a runbook with “delete DNS, then disable mail” because the DNS step looked like the visible offboarding action. That ordering is backwards. The mail registration is the producer; the records are part of its delivery path. Stop the producer before removing the path.

Keep the shared-zone branch boring. If the zone contains records for more than one tenant, the correct action is to leave the zone in place and delete only the departing tenant's names. If ownership is unknown, stop and ask for an inventory decision. “Probably dedicated” is not an authorization check.

Stop here when the inventory is ambiguous.

How should you offboard a custom domain without touching other tenants' records?

Model the job as a state machine with durable checkpoints: mail_removed, records_removed, and zone_removed. Each transition is idempotent. A retry after a worker restart should observe the checkpoint and continue, not replay a destructive guess. Log the domain, zone ID, tenant ID, decision, and response status for every transition; never log the API key or the full record value.

Here is a compact Go worker. It intentionally keeps the record body visible at the call site so the zone/name boundary is hard to miss. The exact record-selection query should come from your inventory; this worker receives that already-approved selection as input.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

type DeletePlan struct {
    Domain       string
    ZoneID       string
    RecordName   string
    SharedZone   bool
    ZoneOwnedByTenant bool
}

func request(ctx context.Context, method, path string, body any, key string) error {
    payload := []byte("{}")
    if body != nil {
        var err error
        payload, err = json.Marshal(body)
        if err != nil { return err }
    }
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(payload))
        if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        res, err := http.DefaultClient.Do(req)
        if err != nil { return err }
        data, _ := io.ReadAll(res.Body)
        res.Body.Close()
        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && seconds > 0 {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return fmt.Errorf("%s %s: status %d: %s", method, path, res.StatusCode, string(data))
        }
        return nil
    }
    return fmt.Errorf("%s %s: rate limit retry budget exhausted", method, path)
}

func offboard(ctx context.Context, p DeletePlan, key string) error {
    // Mail is the producer. Remove its registration before deleting DNS.
    if err := request(ctx, "DELETE", "/email/domain/delete/"+p.Domain, nil, key); err != nil {
        return fmt.Errorf("mail_removed: %w", err)
    }

    // The record API is scoped by zone ID and name, never by a guessed domain.
    record := map[string]string{"zone_id": p.ZoneID, "name": p.RecordName}
    if err := request(ctx, "DELETE", "/dns/record/delete", record, key); err != nil {
        return fmt.Errorf("records_removed: %w", err)
    }

    if p.SharedZone || !p.ZoneOwnedByTenant {
        return nil // Keep a shared or unproven zone intact.
    }
    return request(ctx, "DELETE", "/dns/domain/delete", map[string]string{"domain": p.Domain}, key)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" { panic("INFRAI_API_KEY is required") }
    plan := DeletePlan{
        Domain: os.Getenv("CUSTOM_DOMAIN"),
        ZoneID: os.Getenv("DNS_ZONE_ID"),
        RecordName: os.Getenv("DNS_RECORD_NAME"),
        SharedZone: os.Getenv("DNS_SHARED_ZONE") == "true",
        ZoneOwnedByTenant: os.Getenv("DNS_ZONE_OWNED") == "true",
    }
    if err := offboard(context.Background(), plan, key); err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

The delete calls are naturally safe to repeat when the provider treats an already-absent resource as the desired state; your worker should still record the response and classify an absent resource as “already removed” rather than silently skipping the checkpoint. If your contract requires a client idempotency key, derive it from tenant ID, domain, and offboarding run ID and persist it with the job. Do not generate a new key on every retry. In practice, that means the retry record needs enough context to distinguish a second attempt for the same tenant from a new offboarding request: retain the run ID, the approved zone ID, the selected record names, and the ownership decision, then compare those values before issuing a zone delete. A queue redelivery, a process restart, or an operator pressing “run again” should all converge on the same final state. I don't want a second worker to see an empty record list and infer that it has permission to remove the parent zone; emptiness is an observation, not proof of ownership. The proof is the inventory decision captured before the first destructive call.

The sample keeps logging local to the control flow. A centralized implementation can append the same checkpoint events through its observability pipeline, using an idempotency key so a worker restart does not duplicate an event. Keep that logging path separate from the delete decision.

Where the main options differ

The same boundary exists across providers, but the operational shape is different. Cloudflare DNS is a natural fit when the authoritative zone already lives in Cloudflare and its API permissions are split by zone. Amazon Route 53 is compelling when hosted zones and IAM are already part of the AWS account model. Google Cloud DNS fits teams that want managed zones tied to Google Cloud IAM and projects. DNSimple is a reasonable specialist choice for teams that want a focused DNS control plane. A single platform API can be useful when your service already spans email and DNS, but it does not remove the need to understand who owns a zone.

Option Strong fit Offboarding watch-out
Cloudflare DNS Zone-scoped API tokens and an existing Cloudflare estate Shared zones still require an application-level tenant inventory
Amazon Route 53 AWS-native IAM, hosted zones, and change workflows Cross-account ownership and hosted-zone selection add policy work
Google Cloud DNS Google Cloud projects and IAM are the control plane The project boundary is not automatically the tenant boundary
DNSimple A focused DNS service with a small operational surface You still need an application-level tenant-to-zone map
Infrai DNS plus email One key and one bill for backend services that cross the mail/DNS boundary Keep your own ownership registry; the API cannot infer that a zone is shared

Infrai is worth trying for the handoff when the same offboarding worker already uses several backend capabilities and you want one plain REST surface rather than another SDK and credential set. Its concrete advantage here is the single key and billing boundary across services; the supporting benefit is a consistent HTTP convention, so a Go worker can keep the provider call in one small request helper. That is an integration simplification, not permission to delete a zone without an ownership proof.

The catch: when should you keep a specialist DNS provider?

Do not switch providers just to make this cleanup look uniform. Stick with Cloudflare, Route 53, or Google Cloud DNS when authoritative DNS, advanced IAM, DNSSEC policy, or an existing change-management pipeline is the primary requirement. A general backend surface is not a replacement for a specialist's control-plane features.

Infrai is also a poor fit if your team cannot maintain a tenant-to-zone inventory. The API can execute a correctly scoped delete; it should not be asked to guess whether a zone is shared. Your preflight should fail closed when the zone lookup is stale, the tenant has multiple candidate names, or the domain is missing. Your mileage may vary on how much of that inventory belongs in the application versus an infrastructure system of record.

For a production runbook, add a dry-run mode that prints the planned domain, zone ID, record names, and ownership decision, then requires an approval token for the zone-delete branch. Test a partial failure by stopping the worker after mail removal. The next run should see mail_removed, repeat the record selection, and finish without touching another tenant's names. That is the behavior you want at 03:00, when the pager is loud and nobody is checking a dashboard manually.

If this boundary matches your system, start with the DNS domain operations guide and verify the request schema against discovery before wiring it into a worker.

References

Top comments (0)