| 1 | --- |
| 2 | title: DNS |
| 3 | description: Hetzner Cloud DNS with Alchemy — Zones and RecordSets as resources, plus ReadDns/WriteDns bindings for querying and mutating records from Effects. |
| 4 | --- |
| 5 | |
| 6 | Hetzner Cloud DNS is part of the same project and the same |
| 7 | `HCLOUD_TOKEN` as your servers — no separate account, console, or |
| 8 | token. Alchemy models it two ways: **resources** (`Zone`, |
| 9 | `RecordSet`) for records that belong to your stack, and **bindings** |
| 10 | (`ReadDns`/`WriteDns`) for code that queries or mutates records |
| 11 | dynamically. |
| 12 | |
| 13 | ## Zones |
| 14 | |
| 15 | A [`Zone`](/providers/hetzner/zone) is an apex domain you own — |
| 16 | `name` must be a real registrable domain (no subdomains, no |
| 17 | trailing dot): |
| 18 | |
| 19 | ```typescript |
| 20 | import * as Hetzner from "alchemy/Hetzner"; |
| 21 | |
| 22 | export const Dns = Hetzner.Zone("dns", { |
| 23 | name: "example.com", |
| 24 | ttl: 300, |
| 25 | }); |
| 26 | ``` |
| 27 | |
| 28 | Changing `name` replaces the Zone. The resolved resource exposes |
| 29 | `assignedNameservers` — set those at your registrar to delegate the |
| 30 | domain — plus `delegationStatus` to check whether that's done. |
| 31 | |
| 32 | ## RecordSets |
| 33 | |
| 34 | A [`RecordSet`](/providers/hetzner/recordset) is one `(name, type)` |
| 35 | pair and **all** of its records. Values accept resource outputs, so |
| 36 | pointing your domain at a Load Balancer is a reference, not a |
| 37 | copy-pasted IP: |
| 38 | |
| 39 | ```typescript |
| 40 | const www = yield* Hetzner.RecordSet("www", { |
| 41 | zone: Dns, |
| 42 | name: "www", |
| 43 | type: "A", |
| 44 | ttl: 300, |
| 45 | records: [{ value: lb.ipv4.as<string>() }], |
| 46 | }); |
| 47 | ``` |
| 48 | |
| 49 | `name` is relative to the zone (`@` for the apex). Changing `zone`, |
| 50 | `name`, or `type` replaces the RecordSet; `records` and `ttl` |
| 51 | update in place. Because a RecordSet owns the whole `(name, type)` |
| 52 | pair, keep each pair in exactly one resource. |
| 53 | |
| 54 | ## Query & mutate from Effects |
| 55 | |
| 56 | For DNS that isn't part of the declared stack — health-dependent |
| 57 | records, dynamic entries, lookups — bind a typed client to a zone. |
| 58 | Three bindings, each with a matching layer: |
| 59 | |
| 60 | | Binding | Layer | Client | |
| 61 | | --- | --- | --- | |
| 62 | | `Hetzner.ReadDns(zone)` | `Hetzner.ReadDnsHttp` | `getRecordSet`, `listRecordSets` | |
| 63 | | `Hetzner.WriteDns(zone)` | `Hetzner.WriteDnsHttp` | `createRecordSet`, `setRecordSetRecords`, `addRecordSetRecords`, `removeRecordSetRecords`, `updateRecordSet`, `deleteRecordSet`, `changeRecordSetTtl`, … | |
| 64 | | `Hetzner.ReadWriteDns(zone)` | `Hetzner.ReadWriteDnsHttp` | both | |
| 65 | |
| 66 | The zone is fixed when you bind — pass the Zone declaration |
| 67 | directly, and calls never pass a zone id: |
| 68 | |
| 69 | ```typescript |
| 70 | import * as Alchemy from "alchemy"; |
| 71 | import * as Hetzner from "alchemy/Hetzner"; |
| 72 | import * as Effect from "effect/Effect"; |
| 73 | import { Dns } from "./dns.ts"; |
| 74 | |
| 75 | const SyncRecords = Alchemy.Action( |
| 76 | "SyncRecords", |
| 77 | Effect.gen(function* () { |
| 78 | const dns = yield* Hetzner.ReadWriteDns(Dns); |
| 79 | |
| 80 | return Effect.fn(function* () { |
| 81 | const listed = yield* dns.listRecordSets({ type: ["A"] }); |
| 82 | |
| 83 | const { action } = yield* dns.setRecordSetRecords("api", "A", { |
| 84 | records: [{ value: "203.0.113.20" }], |
| 85 | }); |
| 86 | yield* Hetzner.waitForZoneAction(action); |
| 87 | }); |
| 88 | }).pipe(Effect.provide(Hetzner.ReadWriteDnsHttp)), |
| 89 | ); |
| 90 | ``` |
| 91 | |
| 92 | :::caution[DNS mutations are asynchronous] |
| 93 | Every mutating RRSet call returns an `action` that Hetzner applies |
| 94 | in the background — a read right after may not see the change. |
| 95 | Always `yield* Hetzner.waitForZoneAction(action)` before depending |
| 96 | on the result. (Zone actions have their own API namespace — use |
| 97 | `waitForZoneAction`, not the compute `waitForAction`.) |
| 98 | ::: |
| 99 | |
| 100 | Writes also require your `HCLOUD_TOKEN` to be a **Read & Write** |
| 101 | token — a read-only token fails mutations with a typed `Forbidden`. |
| 102 | |
| 103 | ## Put it together: a domain on your Load Balancer |
| 104 | |
| 105 | ```typescript |
| 106 | const cert = yield* Hetzner.Certificate("cert", { |
| 107 | type: "managed", |
| 108 | domainNames: ["example.com", "www.example.com"], |
| 109 | }); |
| 110 | |
| 111 | yield* Hetzner.RecordSet("apex", { |
| 112 | zone: Dns, |
| 113 | name: "@", |
| 114 | type: "A", |
| 115 | records: [{ value: lb.ipv4.as<string>() }], |
| 116 | }); |
| 117 | ``` |
| 118 | |
| 119 | The managed [Certificate](/hetzner/networking#tls-termination) is |
| 120 | issued via this same DNS, so the Zone must exist (and be delegated) |
| 121 | first — Alchemy orders that for you through the references. |
| 122 | |
| 123 | ## Where next |
| 124 | |
| 125 | - [Networking](/hetzner/networking) — the Load Balancer these |
| 126 | records usually point at. |
| 127 | - [`Zone`](/providers/hetzner/zone) and |
| 128 | [`RecordSet`](/providers/hetzner/recordset) references. |