| 1 | --- |
| 2 | title: DNS validation |
| 3 | description: Configure Cloudflare DNS-01 challenges, wildcard certificates, propagation, and custom solvers. |
| 4 | --- |
| 5 | |
| 6 | DNS-01 proves control of a name by publishing a CA-derived TXT value under |
| 7 | `_acme-challenge`. The CA reads that value before issuing the certificate. |
| 8 | Alchemy implements DNS-01 only; your application does not need a public HTTP |
| 9 | challenge endpoint. |
| 10 | |
| 11 | ## Reference a Cloudflare zone |
| 12 | |
| 13 | Inside the stack from [Getting started](/acme/getting-started): |
| 14 | |
| 15 | ```typescript |
| 16 | const certificate = yield* ACME.Certificate("Site", { |
| 17 | account, |
| 18 | identifiers: ["example.com"], |
| 19 | solver: Cloudflare.DNS.AcmeSolver({ |
| 20 | zoneId: Config.String("CLOUDFLARE_ZONE_ID"), |
| 21 | }), |
| 22 | }); |
| 23 | ``` |
| 24 | |
| 25 | `AcmeSolver` returns serializable data containing the solver type and zone ID. |
| 26 | Include both `ACME.providers()` and `Cloudflare.providers()` in the stack. The |
| 27 | Cloudflare credentials must allow listing, creating, and deleting TXT records in |
| 28 | that zone. The domain must already be delegated to that zone's nameservers. |
| 29 | |
| 30 | ## Use a managed zone instead |
| 31 | |
| 32 | If your stack already owns a Cloudflare `Zone` resource, pass the resolved zone: |
| 33 | |
| 34 | ```diff lang="typescript" |
| 35 | +const zone = yield* Zone; |
| 36 | const certificate = yield* ACME.Certificate("Site", { |
| 37 | account, |
| 38 | identifiers: ["example.com"], |
| 39 | - solver: Cloudflare.DNS.AcmeSolver({ |
| 40 | - zoneId: Config.String("CLOUDFLARE_ZONE_ID"), |
| 41 | - }), |
| 42 | + solver: Cloudflare.DNS.AcmeSolver(zone), |
| 43 | }); |
| 44 | ``` |
| 45 | |
| 46 | The zone's output supplies the ID and establishes the resource dependency. Use |
| 47 | the ID form when you only want to manage challenge records, not the zone's |
| 48 | lifecycle or settings. |
| 49 | |
| 50 | ## Add a wildcard |
| 51 | |
| 52 | ```diff lang="typescript" |
| 53 | - identifiers: ["example.com"], |
| 54 | + identifiers: ["example.com", "*.example.com"], |
| 55 | ``` |
| 56 | |
| 57 | `*.example.com` covers one label such as `www.example.com`, but not the apex |
| 58 | `example.com` or `a.b.example.com`. Include the apex separately when needed. Both |
| 59 | names validate at `_acme-challenge.example.com`; Alchemy processes the |
| 60 | authorizations sequentially and removes each challenge value afterward. |
| 61 | |
| 62 | ## Understand record cleanup |
| 63 | |
| 64 | The deploy-time Cloudflare solver creates TXT records with a 60-second TTL. It |
| 65 | matches both record name and value during cleanup, preserving unrelated values |
| 66 | at the same name. Once `present` succeeds, cleanup runs when validation succeeds, |
| 67 | fails, or is interrupted through Effect's normal finalization. |
| 68 | |
| 69 | A process kill or a failure after the DNS API accepted a write can prevent |
| 70 | cleanup. Inspect any leftover value before removing it: another order may be |
| 71 | using the same record name. Avoid concurrent issuers for the same names where |
| 72 | possible. |
| 73 | |
| 74 | ## Adjust the propagation budget |
| 75 | |
| 76 | ```diff lang="typescript" |
| 77 | const certificate = yield* ACME.Certificate("Site", { |
| 78 | account, |
| 79 | identifiers: ["example.com", "*.example.com"], |
| 80 | solver: Cloudflare.DNS.AcmeSolver(zone), |
| 81 | + propagation: { |
| 82 | + delay: "60 seconds", |
| 83 | + timeout: "90 seconds", |
| 84 | + interval: "3 seconds", |
| 85 | + }, |
| 86 | }); |
| 87 | ``` |
| 88 | |
| 89 | These are the deploy-time Cloudflare defaults. The timeout covers the lookup and |
| 90 | post-visibility delay together. Lookup attempts are bounded, so increasing the |
| 91 | timeout alone does not create unlimited polling. Keep enough budget for the |
| 92 | post-visibility delay; cached previous TXT values can cause a secondary CA |
| 93 | validator to reject a record even after one resolver sees it. |
| 94 | |
| 95 | ## Choose fallback resolvers |
| 96 | |
| 97 | ```diff lang="typescript" |
| 98 | propagation: { |
| 99 | delay: "60 seconds", |
| 100 | timeout: "90 seconds", |
| 101 | interval: "3 seconds", |
| 102 | + resolvers: [ |
| 103 | + "https://cloudflare-dns.com/dns-query", |
| 104 | + "https://dns.google/resolve", |
| 105 | + ], |
| 106 | }, |
| 107 | ``` |
| 108 | |
| 109 | Where Node DNS APIs are available, the deploy-time check prefers authoritative |
| 110 | nameservers. Otherwise it polls DNS-over-HTTPS JSON endpoints and requires every |
| 111 | configured resolver to return the expected TXT value. These endpoints are the |
| 112 | default fallback resolvers; private DNS needs a solver-specific visibility check. |
| 113 | |
| 114 | ## Select the runtime solver |
| 115 | |
| 116 | Inside a Worker's initialization Effect: |
| 117 | |
| 118 | ```typescript |
| 119 | const dns = yield* Cloudflare.DNS.WriteDns(Zone); |
| 120 | const solver = Cloudflare.DNS.acmeDnsSolver(dns); |
| 121 | ``` |
| 122 | |
| 123 | The runtime helper is **`acmeDnsSolver`**, while the deploy-time helper is |
| 124 | **`AcmeSolver`**. Provide `Cloudflare.DNS.WriteDnsHttp` on the host Effect. Its |
| 125 | client uses a zone-scoped token created at deployment, rather than exposing the |
| 126 | deployer's entire credential set to the application. |
| 127 | |
| 128 | ## Account for runtime propagation |
| 129 | |
| 130 | ```typescript |
| 131 | const issued = yield* issuer.issue({ |
| 132 | identifiers: ["api.example.com"], |
| 133 | solver, |
| 134 | propagation: { delay: "60 seconds" }, |
| 135 | }); |
| 136 | ``` |
| 137 | |
| 138 | The Cloudflare runtime solver uses a fixed delay (60 seconds by default), rather |
| 139 | than authoritative or public resolver polling. For this solver, `resolvers`, |
| 140 | `interval`, and `timeout` do not add a DNS visibility check. An issuance request |
| 141 | can take longer than a minute; [Runtime issuance](/acme/runtime) explains the |
| 142 | hosting and scheduling implications. |
| 143 | |
| 144 | ## Implement a custom runtime solver |
| 145 | |
| 146 | A custom solver adapts an existing DNS client's idempotent operations: |
| 147 | |
| 148 | ```typescript |
| 149 | import type * as ACME from "alchemy/ACME"; |
| 150 | import type * as Effect from "effect/Effect"; |
| 151 | |
| 152 | export const makeSolver = <R>(dns: { |
| 153 | ensureTxt: ( |
| 154 | record: ACME.DnsChallengeRecord, |
| 155 | ) => Effect.Effect<void, ACME.DnsSolverError, R>; |
| 156 | removeTxt: ( |
| 157 | record: ACME.DnsChallengeRecord, |
| 158 | ) => Effect.Effect<void, ACME.DnsSolverError, R>; |
| 159 | }): ACME.DnsSolver<R> => ({ |
| 160 | present: (record) => dns.ensureTxt(record), |
| 161 | cleanup: (record) => dns.removeTxt(record), |
| 162 | }); |
| 163 | ``` |
| 164 | |
| 165 | Implement `ensureTxt` and `removeTxt` with your provider's DNS API, mapping its |
| 166 | failures to `ACME.DnsSolverError`. Ensure must tolerate the exact value already |
| 167 | existing; removal must tolerate it already being absent. Match the requested |
| 168 | value, not every TXT record at that name. The default propagation check applies |
| 169 | unless you supply the solver's optional `propagated` method. |
| 170 | |
| 171 | ## Register a deploy-time solver |
| 172 | |
| 173 | Deploy-time props need a serializable descriptor, not a client with closures. |
| 174 | For an adapter whose client has no remaining Effect requirements, register a |
| 175 | factory with this shape: |
| 176 | |
| 177 | ```typescript |
| 178 | import * as ACME from "alchemy/ACME"; |
| 179 | import type * as Effect from "effect/Effect"; |
| 180 | |
| 181 | export const registerExampleDns = ( |
| 182 | make: ( |
| 183 | descriptor: ACME.DnsSolverDescriptor, |
| 184 | ) => Effect.Effect<ACME.DnsSolver, ACME.DnsSolverError>, |
| 185 | ) => ACME.dnsSolverLayer("Example.DNS", make); |
| 186 | ``` |
| 187 | |
| 188 | Validate the descriptor's fields inside `make`, construct the client, and merge |
| 189 | the returned layer into your stack's providers. Certificate props then use |
| 190 | `solver: { type: "Example.DNS", ...providerData }`. A service-backed factory can |
| 191 | instead provide its required credential and HTTP layers to `dnsSolverLayer`; |
| 192 | the registration captures that context for the solver. Cloudflare's implementation |
| 193 | in `Cloudflare/DNS/AcmeDnsSolver.ts` is the built-in reference. |
| 194 | |
| 195 | ## Delegated challenges |
| 196 | |
| 197 | The Cloudflare helper writes directly to the specified zone at the calculated |
| 198 | challenge name. It does not automatically follow CNAME or NS delegations to |
| 199 | another challenge zone. For delegated validation, implement a solver that |
| 200 | publishes to the delegated target and verifies its visibility. Simply changing |
| 201 | `zoneId` is not a delegation resolver. |
| 202 | |
| 203 | ## Next steps |
| 204 | |
| 205 | - [Troubleshooting](/acme/troubleshooting) — diagnose propagation and authorization failures. |
| 206 | - [Runtime issuance](/acme/runtime) — use a solver in a complete Worker. |