> ## Documentation Index
> Fetch the complete documentation index at: https://docs.basaltic.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# DNS

> Authoritative hosted zones signed with DNSSEC, with health-checked failover, geo-routed answers, and private zones scoped to a VPC.

DNS hosts authoritative zones for your domains. Every zone is signed with
DNSSEC from the moment it exists, records can fail over on a health check, and
a zone can be made private so it answers only inside your VPCs.

The service is **global** — one endpoint, `dns.basaltic.sh`, no region segment.
The public namespace is not regional.

<CardGroup cols={2}>
  <Card title="Create and delegate" icon="globe" href="#creating-a-zone">
    Proving you own the domain, and the two ways to do it.
  </Card>

  <Card title="Records" icon="list" href="#records">
    RRsets, the values array, and the one rule that breaks zones silently.
  </Card>

  <Card title="Health checks" icon="activity" href="#health-checked-failover">
    Priority tiers, what gets answered, and what `served` tells you.
  </Card>

  <Card title="Geo routing" icon="map-pin" href="#geo-routing">
    Answer variants scoped to client prefixes, and how they stay
    DNSSEC-valid.
  </Card>

  <Card title="DNSSEC" icon="shield-check" href="#dnssec">
    Always on. What to paste at your registrar to close the chain.
  </Card>
</CardGroup>

## Creating a zone

<Tabs>
  <Tab title="Console">
    Go to **DNS** and choose **Create Zone**. Under **Zone details**, enter the
    fully-qualified **Name** — `example.com`, not a hostname inside it.

    There is no DNSSEC switch to find. The create page describes itself as
    *signed with DNSSEC by default* and its summary row reads **DNSSEC**:
    **Enabled**. Once the zone exists, its **Settings** tab
    carries the **Nameservers** card with the names to copy into your
    registrar.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST https://dns.basaltic.sh/v1/zones
    { "name": "example.com" }
    ```
  </Tab>
</Tabs>

The SOA and apex NS records are generated for you against the platform
nameserver list, and the zone is queryable on success. The response carries a
`nameservers` array — that is what you copy into your registrar to delegate the
domain.

<Warning>
  A new zone with no zone above it on the platform **does not resolve** until
  you prove you own the domain. Creating it is not claiming it.
</Warning>

### Proving ownership

The proof exists so nobody can create a zone for a subdomain of a domain they
do not own and sit inside your subtree. Which method you use depends on where
the domain points **right now**.

<Tabs>
  <Tab title="Domain still at your old provider">
    The zone's `ownership` block names a TXT record. Publish it at your current
    provider — before you delegate anything — then call verify:

    ```dns theme={null}
    _basaltic-challenge.example.com.  TXT  "<ownership.record_value>"
    ```

    ```bash theme={null}
    POST /v1/zones/{zone_id}/verify-ownership
    ```

    The call is idempotent, so you can poll it.
  </Tab>

  <Tab title="Domain already delegated to us">
    There is nowhere to publish that TXT — we are the domain's DNS, and an
    unverified zone does not answer, so the record could never resolve. Use
    `ownership.claim_nameservers` instead: set those as the domain's
    nameservers at your registrar, then call verify.

    Those names reach the same servers as the zone's `nameservers`, but they
    are **unique to this zone**, and that is what makes them proof. Anyone can
    point a domain at our published nameservers, so a delegation naming those
    shows only that somebody did so at some point — possibly a previous owner
    who never updated their registrar. A delegation naming these can only have
    been set for this zone, by whoever holds the domain now.

    The delegation may stay on these names permanently.
  </Tab>

  <Tab title="Subdomain of a zone you own">
    Nothing to do. A zone created underneath a zone you already own inherits
    its parent's proof and is verified on creation. Calling verify on one
    succeeds with nothing to do.
  </Tab>
</Tabs>

<Note>
  `verify-ownership` is **API only**. The console creates zones and shows their
  nameservers, but has no button that runs the check — so a zone created there
  still needs this call before it answers.
</Note>

<Info>
  **The proof is a lease, not a deed.** It is re-confirmed periodically for as
  long as the zone is served, so a domain that lapses or changes hands stops
  being served from here.
</Info>

### When re-confirmation starts failing

A zone whose periodic re-proof is failing grows an
`ownership.recheck_deadline` — the instant it stops answering unless the check
passes again. Absent means healthy.

Reaching that date takes sustained failure, not one bad afternoon: every pass
re-checks a failing zone, so a transient resolver problem clears itself. Your
organization's owner is emailed when the deadline is set, and again if the zone
does stop resolving — a zone is never taken off the air before that message has
gone out.

To clear it deliberately, republish the challenge (or point the delegation back
at our nameservers) and call `verify-ownership` again. That is the one case
where an already-verified zone can answer `400`, because there the call runs
the check for real.

## Records

A record is an **RRset**: one name, one type, and a `values` array. Updating a
record replaces the value list wholesale.

<Tabs>
  <Tab title="Console">
    Open the zone from **DNS** and choose **Add Record**. Give it a **Name** —
    a subdomain like `www`, or `@` for the apex — pick a **Type**, and put one
    entry per line in **Values**. That list is the RRset. **TTL** is optional.
  </Tab>

  <Tab title="API">
    ```bash theme={null}
    POST /v1/zones/{zone_id}/records
    {
      "name": "www.example.com",
      "type": "A",
      "ttl": 300,
      "values": [
        { "content": "203.0.113.10" },
        { "content": "203.0.113.11" }
      ]
    }
    ```
  </Tab>
</Tabs>

A value can be `disabled: true` — retained but not served, which is useful for
staging a change before it goes live.

<Warning>
  **A CNAME cannot share a name with any other record, and cannot sit at the
  zone apex.** The API refuses both, because the breakage is silent otherwise.

  The signer stamps each name's NSEC with a bitmap of the types that name
  holds. A CNAME beside an A yields a bitmap reading "CNAME and A", and a
  validating resolver treats that owner as bogus and answers SERVFAIL. The zone
  loads, the records look correct in the API, and the name simply stops
  resolving for anyone who validates. This is RFC 1034 §3.6.2 and RFC 2181
  §10.1.

  At the apex the conflict is unavoidable — the SOA and NS sets are always
  there — so point the apex at addresses with `A`/`AAAA` records.
</Warning>

### Platform-managed records

Records with `managed: true` are read-only: the SOA, the apex NS set, and the
whole DNSSEC family. They appear in reads so you can see the zone as it really
is, but they cannot be created, updated or deleted through the API.

## Importing a zone file

`POST /v1/zones/{zone_id}/import` takes an RFC 1035 master file — the BIND
format every provider exports.

<Tabs>
  <Tab title="Console">
    Open the zone from **DNS** and choose **Import zone file**. Paste the file
    into the box, or load one from disk with **Choose a file**, then choose
    **Import**.

    The dialog then reports how many record sets it added and how many it
    replaced, and lists anything it skipped with the reason.
  </Tab>

  <Tab title="API">
    Send it as JSON in `zone_file`, or as the raw body under any other content
    type, so this works directly:

    ```bash theme={null}
    curl --data-binary @db.example.com \
      -H 'Content-Type: text/plain' \
      https://dns.basaltic.sh/v1/zones/{zone_id}/import
    ```
  </Tab>
</Tabs>

<AccordionGroup>
  <Accordion title="It merges, it never replaces" icon="git-merge">
    An RRset in the file replaces the RRset at the same name and type
    wholesale; an RRset the file does not mention is left exactly as it is.

    So an import **cannot delete a record**, and importing the same file twice
    is a no-op rather than an error — which is what makes it safe to retry.
  </Accordion>

  <Accordion title="It is all or nothing" icon="shield">
    The whole file is validated before a single record is written: every record
    faces the same type, name, TTL and rdata checks that creating one does, the
    same CNAME rule, and the same record quota. One bad record refuses the file
    and the zone is untouched.
  </Accordion>

  <Accordion title="Some records are skipped by design" icon="filter">
    The SOA and apex NS set are the platform's — the zone is served by our
    nameservers, and the apex NS is what the parent delegates to. The DNSSEC
    records (`RRSIG`, `DNSKEY`, `DS`, `NSEC`, `NSEC3`, `NSEC3PARAM`, `CDS`,
    `CDNSKEY`) are generated by our signer from keys your file cannot know.
    Records placed outside the zone are skipped too.

    Every one comes back in `skipped` with a reason. A file exported from a
    signed zone carries all of them, so refusing the file over them would make
    export/import unusable.
  </Accordion>
</AccordionGroup>

The file is capped at **1 MiB**. On success the SOA serial advances once for
the whole import.

## Health-checked failover

Attach a check to an `A` or `AAAA` record and the nameservers stop answering
with targets that are down.

<Note>
  Health checks are **API only**. The console's zone page creates, edits and
  deletes records; it does not attach checks to them.
</Note>

```bash theme={null}
PUT /v1/zones/{zone_id}/records/{record_id}/health-check
{
  "protocol": "https",
  "port": 443,
  "path": "/healthz",
  "matcher": "200-299",
  "targets": [
    { "content": "203.0.113.10", "priority": 0 },
    { "content": "203.0.113.20", "priority": 1 }
  ]
}
```

**Lower priority wins.** Values sharing a priority form one tier and are
answered together, so `0` and `1` is a failover pair while `0` and `0` is
active/active. The nameservers answer with the healthy targets at the
most-preferred tier, fall through to the next tier when those go unhealthy,
and — if nothing is healthy anywhere — answer with every configured value
rather than nothing.

<Note>
  `GET` on the health check returns a `served` array: the values the
  nameservers are answering with *right now*. The record's own `values` stay
  whatever you configured. The difference between the two **is** the failover,
  so it is reported rather than left for you to infer.
</Note>

<AccordionGroup>
  <Accordion title="Why there is no UDP probe" icon="circle-slash">
    A UDP probe cannot tell a healthy target from a silent one. Only `http`,
    `https` and `tcp` are offered.
  </Accordion>

  <Accordion title="https does not verify the certificate" icon="lock">
    The probe dials an address, not a name, so there is no name to validate
    against. It measures whether the target is serving, not who it is.
  </Accordion>

  <Accordion title="Private addresses are refused" icon="ban">
    The prober runs in the control plane and could never reach an address
    inside a tenant VPC, so a check on an RFC 1918 or unique-local address is
    rejected rather than accepted and left permanently unhealthy.
  </Accordion>

  <Accordion title="A new target is answered before its first probe" icon="play">
    A target no probe has reached a verdict on is `initial`, and `initial` is
    **answered**. A freshly created record resolves immediately instead of
    being dark until the first probe lands.
  </Accordion>

  <Accordion title="Thresholds are damping, not decoration" icon="waves">
    Every health transition re-signs the whole zone. `healthy_threshold` and
    `unhealthy_threshold` (both default `3`) decide how many consecutive
    results it takes to move, and `timeout_sec` must stay below
    `interval_sec` or a slow target's probes overlap each other.
  </Accordion>
</AccordionGroup>

## Geo routing

A record can carry named **answer variants**, each scoped to a set of client
prefixes, so a query is answered with the values closest to whoever asked.

<Note>
  Geo routing is **API only**. The console shows a record's default values, not
  its variants.
</Note>

```bash theme={null}
PUT /v1/zones/{zone_id}/records/{record_id}/geo-routing
{
  "answers": [
    { "label": "eu", "values": ["198.51.100.10"], "prefixes": ["185.0.0.0/8"] },
    { "label": "apac", "values": ["203.0.113.10"], "prefixes": ["1.0.0.0/8", "14.0.0.0/8"] }
  ]
}
```

The record's own `values` stay the **default** — what a query matching no
prefix is answered with, which is most of the internet. So `answers` describes
overrides, never the fallback; the fallback cannot be deleted, and `default` is
a reserved label. `PUT` replaces the whole configuration, and `GET` reports the
default alongside the variants rather than leaving it implicit.

<Note>
  Only `A`, `AAAA` and `CNAME` records can carry variants — those are what
  "send this client somewhere closer" means. A variant only changes rdata, so
  nothing structural stops `MX` or `SRV`, but each brings its own question and
  none is what this is for.
</Note>

### How a client is matched

Selection is **longest-prefix match**, so overlapping prefixes of different
lengths are meaningful rather than a conflict — a `/8` and a `/16` inside it do
what you would expect. The same prefix on two variants is rejected, because
that would make the answer depend on row order and let two nameservers disagree
about one query.

Matching uses the **EDNS Client Subnet** prefix when the resolver sends one, and
the query's source address otherwise. A rule more specific than what the
resolver actually asserted is skipped, not matched: beyond the ECS source
netmask the address is zeros the resolver never claimed, and matching them would
route a whole `/8` to whichever `/24` happens to sit at its base.

Write prefixes as the **network address** — `185.0.0.0/8`, not `185.1.2.3/8`.

<Warning>
  Prefixes cannot be longer than **`/24`** for IPv4 or **`/56`** for IPv6. That
  is the ECS privacy convention, and it is also what keeps resolver caches
  correct: the scope length echoed back is the record's longest configured
  prefix, so an unbounded prefix would force per-host caching on every resolver
  that talks to us.
</Warning>

### Why the limits are what they are

<AccordionGroup>
  <Accordion title="8 variants per record" icon="pen-tool">
    Each variant is signed separately, so a record with N variants is N+1
    RRsets to mint on every re-sign of the zone — and a health flip or a record
    edit re-signs the whole zone.
  </Accordion>

  <Accordion title="64 prefixes per record" icon="gauge">
    The prefix list is scanned per query, on an unauthenticated UDP packet. It
    is deliberately a short linear scan over a list sorted longest-first at
    load, rather than anything that grows with what was configured.
  </Accordion>

  <Accordion title="A variant with no prefixes is refused" icon="circle-slash">
    Nothing routes to it, and it would cost a signature on every re-sign to
    never be answered. Refusing it is more useful than carrying it silently.
  </Accordion>

  <Accordion title="A CNAME variant takes exactly one value" icon="link">
    A variant is still a CNAME RRset, and RFC 1034 §3.6.2 allows a CNAME RRset
    exactly one value. The constraint is on the answer, not on the row that
    configured it.
  </Accordion>
</AccordionGroup>

<Info>
  **Geo answers stay DNSSEC-valid.** Each variant is signed at write time and a
  response carries exactly one variant's RRset together with *that variant's*
  signature. Filtering one signed RRset per query — the obvious implementation
  — would answer with data the shipped signature does not cover, which is
  SERVFAIL for every validating resolver. Because a variant changes only rdata
  and never which names or types exist, the NSEC denial chain is identical
  whichever variant is served.
</Info>

## Private zones

A zone is `public` or `private`, fixed at creation.

```bash theme={null}
POST /v1/zones
{
  "name": "internal.example.com",
  "visibility": "private",
  "vpc_ids": ["c3d4e5f6-a7b8-4901-c2d3-e4f5a6b7c8d9"]
}
```

A private zone answers only inside the VPCs associated with it, and needs at
least one at creation. Attach or detach more later through the
`vpc-associations` endpoints.

<Note>
  `visibility` is **API only**. The console's **Create Zone** form takes a name
  and nothing else, so every zone made there is public — and because visibility
  is fixed at creation, that is not something you can change afterwards.
</Note>

<Note>
  The two mismatches are rejected rather than quietly corrected: a `private`
  zone with no `vpc_ids`, and a `public` zone that carries them, both answer
  `400`.
</Note>

## DNSSEC

DNSSEC is **on for every zone created through this API** — there is no switch,
and no unsigned mode. The signer bootstraps the zone and the `dnssec` block
appears on it, carrying the key tags, the algorithm (`13`, ECDSA P-256
SHA-256), and the DS records.

To complete the chain of trust, paste the DS record at your **parent
registrar**:

<Steps>
  <Step title="Read the DS records off the zone">
    ```bash theme={null}
    GET /v1/zones/{zone_id}
    ```

    `dnssec.ds_records` holds them. Each also carries `rdata` — the zone-file
    form of the same record, for registrars that want a single string.
  </Step>

  <Step title="Publish it at the registrar">
    This is the one DNSSEC step that cannot be done from here: only the parent
    zone's operator can publish a DS record, and for a public suffix that is
    your registrar.
  </Step>

  <Step title="Validate">
    Once the DS is live, resolvers can validate the chain from the root down
    to your records.
  </Step>
</Steps>

<Warning>
  Until the DS record is published at the registrar, your zone is signed but
  **not validated** — resolvers have no way to reach your keys from the root.
  Signing without a DS buys nothing.
</Warning>

## Limits and behaviour

<ResponseField name="zone file import" type="1 MiB">
  Larger files are refused.
</ResponseField>

<ResponseField name="records per zone" type="quota-enforced">
  Checked on both single-record creation and import, against your
  organization's quota.
</ResponseField>

<ResponseField name="ttl" type="default 3600">
  Minimum `0`.
</ResponseField>

<ResponseField name="deleting a zone" type="cascades">
  Deletes every record it owns, in a single cascading delete.
</ResponseField>

## Troubleshooting

<AccordionGroup>
  <Accordion title="The zone does not resolve at all" icon="circle-x">
    Check `ownership.verified`. A zone with no zone above it on the platform
    does not answer until ownership is proved — creating it is not claiming
    it. See [proving ownership](#proving-ownership) for which of the two
    methods applies to you.
  </Accordion>

  <Accordion title="A name resolves for some people and SERVFAILs for others" icon="triangle-alert">
    That pattern — works for non-validating resolvers, fails for validating
    ones — is the signature of a CNAME sharing a name with another record. The
    API refuses new ones, but a zone imported or migrated in unusual ways is
    worth checking. List the records at that name and make sure the CNAME is
    alone.
  </Accordion>

  <Accordion title="DNSSEC validation fails everywhere" icon="shield-x">
    Almost always a missing or stale DS record at the registrar. Compare what
    the registrar publishes against `dnssec.ds_records` on the zone — a DS
    left over from a previous provider breaks validation exactly as
    thoroughly as none at all.
  </Accordion>

  <Accordion title="Failover is not switching" icon="activity">
    `GET` the health check and read `served` against the targets' `status`.
    A target still in `initial` has no verdict yet and is answered on purpose.
    Remember thresholds default to three consecutive results, so a flapping
    target takes three intervals to move in either direction.
  </Accordion>

  <Accordion title="Everyone gets the same geo answer" icon="globe">
    Most resolvers do not send EDNS Client Subnet, so selection falls back to
    the resolver's own source address — a client using a distant public
    resolver is matched where that resolver sits, not where the client is.
    Check `GET` on the geo routing: `default` is the answer the bulk of the
    internet receives, and it is the one to get right.

    Also confirm the prefix is written as a network address: `185.1.2.3/8` is
    not the same rule as `185.0.0.0/8`.
  </Accordion>

  <Accordion title="An import did not remove the records I deleted" icon="git-merge">
    Working as intended — import merges and never deletes. An RRset absent
    from the file is left alone. Delete records explicitly through the record
    endpoints.
  </Accordion>
</AccordionGroup>

## Next

<CardGroup cols={2}>
  <Card title="Certificates" icon="badge-check" href="/certificates">
    A zone on our DNS gets its ACME challenge CNAME created automatically.
  </Card>

  <Card title="API reference" icon="code" href="/api-reference/introduction">
    Every zone and record operation, with schemas.
  </Card>
</CardGroup>
