Skip to content

Domains & DNS

Your domain is infrastructure too. Alchemy manages Cloudflare zones, the DNS records inside them, DNSSEC, and per-zone settings as ordinary resources — declared next to the Workers they route to, diffed and converged on every deploy.

Most of the time you won’t create a zone from scratch: the domain already exists in your Cloudflare account, hand-configured over the years. Adoption is designed for exactly that — Alchemy takes over an existing zone or record only when you explicitly opt in, so a deploy can never clobber DNS it doesn’t own.

A zone is one resource declaration:

import * as Cloudflare from "@alchemy.run/cloudflare";
const zone = yield* Cloudflare.Zone.Zone("MyZone", {
name: "example.com",
});
// zone.zoneId, zone.nameServers, zone.status, ...

Cloudflare assigns name servers to the new zone (zone.nameServers); point your registrar at them and the zone moves from pending to active once Cloudflare detects proof of ownership.

Zones default to retain: destroying the stack does not delete the zone in Cloudflare. Opt in to actual deletion with destroy():

import { destroy } from "alchemy/RemovalPolicy";
yield* Cloudflare.Zone.Zone("MyZone", {
name: "example.com",
}).pipe(destroy());

A Cloudflare zone carries no ownership markers, so Alchemy cannot prove it created a zone that already exists. Deploying a Zone whose name matches a pre-existing zone fails with an OwnedBySomeoneElse error — unless you opt in with adopt(true):

import { adopt } from "alchemy/AdoptPolicy";
const zone = yield* Cloudflare.Zone.Zone("MyZone", {
name: "example.com",
}).pipe(adopt(true));
// zone.zoneId, zone.nameServers, zone.accountId, ...

The engine takes over the existing zone instead of creating a new one, and from then on manages it like any other resource. The same gate can be lifted for an entire deploy with alchemy deploy --adopt. How ownership gating works under the hood is covered in Concepts › Provider.

Cloudflare.DNS.Record manages a single record in a zone. A plain A record:

yield* Cloudflare.DNS.Record("ApiA", {
zoneId: zone.zoneId,
name: "api.example.com",
type: "A",
content: "203.0.113.42",
ttl: 300,
});

Set proxied: true to send traffic through Cloudflare’s proxy (orange-clouded in the dashboard). Proxied records use Cloudflare’s automatic TTL — the default when ttl is omitted — and only A, AAAA, and CNAME records are proxiable:

yield* Cloudflare.DNS.Record("AppCname", {
zoneId: zone.zoneId,
name: "app.example.com",
type: "CNAME",
content: `${tunnel.tunnelId}.cfargotunnel.com`,
proxied: true,
comment: "app behind a Cloudflare Tunnel",
});

A record is tracked by its Cloudflare record id, so changing its name, type, content, ttl, proxied, or comment updates it in place. Only moving it to another zone replaces it. Unless you set comment, Alchemy labels each record Managed by Alchemy (<FQN>) in the dashboard.

Records get the same adoption safety as zones. If a record with the same name, type and content (and priority, for MX and URI) already exists in the zone — a hand-edited apex A record, an email DKIM/SPF entry — the deploy refuses to take it over unless you pipe adopt(true). A record with different content is left alone and a new one is created beside it, so adding a second TXT or MX record never overwrites the first. For an A or AAAA record that means traffic is split between the old and new address until you remove the old record or adopt it. To adopt a record and then change it, declare its current content first, then change it in a later deploy. A CNAME is the exception: a name holds only one, so adopt(true) takes over an existing CNAME whatever it points at.

This is the layer under a Worker’s domain prop. When you write:

export default Cloudflare.Worker(
"Worker",
{
main: import.meta.url,
domain: "app.example.com",
},
// ...
);

Cloudflare infers the zone from the hostname — the zone must already exist in the account. Declaring the Zone resource in the same stack (or adopting the existing one, as above) is what satisfies that requirement end-to-end. The custom domains guide walks through the full setup: zone, DNS, and Worker wired together.

Cloudflare.Zone.Setting pins a single zone setting to a desired value. Settings are singletons — they always exist with a Cloudflare default — so the resource patches the value on deploy and restores the original on destroy:

yield* Cloudflare.Zone.Setting("AlwaysUseHttps", {
zoneId: zone.zoneId,
settingId: "always_use_https",
value: "on",
});

Cloudflare.DNS.Dnssec signs the zone. Activation completes at your registrar: paste the ds attribute (the DS record) there, and Cloudflare reports pending until it’s in place:

const dnssec = yield* Cloudflare.DNS.Dnssec("ZoneDnssec", {
zoneId: zone.zoneId,
});
// Paste `dnssec.ds` at your registrar to complete activation.

Like zone settings, DNSSEC is a per-zone singleton: destroying the resource restores whatever state the zone had before Alchemy first managed it.

Guides:

  • Custom domains — serve a Worker from your own hostname, end to end.

Related:

  • Workers — the compute your DNS points at.
  • Email — route mail on the zone and send from Workers.
  • DNS Adapters — publish another platform’s custom-domain records (AWS, Fly, Railway, …) in a Cloudflare zone.

Reference: