Skip to main content

Overview

fjall domain manages custom domains for your applications. It scaffolds domains, deploys their AWS Route 53 hosted zones, classifies every live record against your declarations, verifies nameserver delegation, and imports or exports DNS records. A domain is declared in fjall/domains/<zone>/infrastructure.ts with the typed Domain construct. See Domain for the construct API.
Adopting a live zone is not just a source import. A first deploy can delete consented live DNS records before CloudFormation recreates them, leaving an outage if creation fails. Back up the live zone and prepare recovery before consenting to takeover. Follow Adopt a live Route 53 zone before the first adopted-zone deploy.

Usage

Every subcommand supports agent mode: --agent (structured TOON output) with --budget, --fields, and --full.

Subcommands

Create a domain

Scaffold a new apex domain in the current project:
Adopt an existing Route 53 hosted zone by reference instead of creating a new one:
Scaffold a delegated child under an already-registered parent:
Fan out one delegated child per workload account after the apex scaffold:
--parent defaults to the longest-suffix registered parent found in fjall-config.json. --account and --region default to the parent entry’s pins for a delegated child, otherwise the first successful deploy records them. --per-account is idempotent, and a solo-account organisation fans out nothing. fjall create domain --name example.com is the create-family spelling of the same scaffold. It takes -n, --name <name> instead of the positional argument and carries the same option set. Both create and import register the domain in fjall-config.json automatically, so the organisation cascade can deploy it. A domain nested under an already-registered domain is registered as delegated with that domain as its parent. Everything else is apex.

Deploy a domain

Deploy the domain’s hosted zone and DNS infrastructure to AWS:
--plan exits 2 when changes are pending approval. Spell each --remediate value exactly as the ticket prints it, for example --remediate 'www.example.com/A'=recreate. A weighted, latency, or failover variant carries its SetIdentifier in a three-part key: --remediate 'www.example.com/A@blue'=recreate. The deploy echoes the resolved identity before any synth or AWS read, on every route including --plan: Deploying 'example.com' with AWS account 123456789012 (target 'production'). Resolution fails closed. An unresolvable --target stops before anything is read or mutated, with a message listing the available targets. An unresolvable AWS identity names the cure (fjall connect) rather than falling back to an unnamed account. --region without --target applies the region override to the ambient credential chain. When you deploy an adopted zone (one that already had live records) for the first time, any record that both the live zone and your infrastructure.ts claim must be released through the record-takeover ceremony. The deploy stops with a ticket naming each colliding record, and you consent per row with --remediate '<name>/<TYPE>'=recreate. Consent withheld means exit code 4, with nothing released and nothing deployed. See Destructive Changes. Releasing a record deletes it from the live zone. Fjall deletes the consented records one at a time, before the CloudFormation deploy starts, and the domain stack then creates each one again from your declaration. Until it does, the record is absent from DNS, and a resolver that asks for it caches the absence for up to the zone’s negative TTL: the smaller of the SOA record’s TTL and its minimum field (RFC 2308), which is 900 seconds with Route 53’s default SOA. The ticket calls this a short DNS gap bounded by the deploy. If the stack’s creation fails, CloudFormation rolls it back. The records Fjall deleted are not recreated, and Fjall keeps no copy of them. An application stack that already exports the zone’s <zone>-hosted-zone-id name makes that failure certain, so work through Adopt a live Route 53 zone before you consent. The takeover ticket is not built from the record classification. It offers every live record your declaration claims that the domain stack’s deployed template did not, even one another stack owns, and a claimed NS or SOA record stops the deploy instead. --destruction-consent-digest binds the consents to the rows alone: each record’s zone, name, type and set identifier, never its value or the template. A changed record value, live or declared, leaves the digest as it was, so the --plan hint that a changed plan then withholds is true on this ticket only when the set of rows changes. --approval-token re-verifies the synthesised template, and nothing compares a live record’s value at apply time with the value the plan showed. The plan’s summary counts only CloudFormation’s change set, never the takeover rows listed beneath it. That covers the Deploy plan: line and, under --agent, destructiveResourceCount and dataLossResourceCount. In --plan --agent output, destructiveChangeCount counts the takeover rows. Each row’s finding is data-loss-delete, but under --agent the rows leave out the cause text that says the live record is deleted.
--allow-replace does not exist on domain surfaces. It is rejected rather than silently dropped. Consent to takeover ticket rows with --remediate '<name>/<TYPE>'=recreate, appending @<setIdentifier> exactly when the live record set has one.
fjall domain deploy is the only domain-deploy spelling. fjall deploy takes application names only and rejects fjall deploy domain <name> with a teaching error before anything is validated or deployed. Delegated child domains deploy in two phases automatically. The zone and its NS delegation go first. The certificates follow once the delegation has propagated and the CAA preflight passes. This prevents ACM DNS validation hanging against an undelegated zone. An external-records domain with certificates needs you to act before its deploy can finish. CloudFormation creates each certificate and then waits until its DNS validation record resolves publicly, and only you can create that record at the external DNS provider. ACM chooses the record when the certificate is created, so the deploy reads it from ACM while CloudFormation waits and announces each record as a warning:
If the deploy fails while ACM is still waiting, for example when the deploy times out, its error repeats the outstanding records. CloudFormation keeps waiting after the command exits. Create the records, then re-run fjall domain deploy once the domain stack has settled. The read needs cloudformation:DescribeStacks, cloudformation:ListStackResources, cloudformation:DescribeStackEvents and acm:DescribeCertificate. A denied read is announced once, naming those four actions, and the deploy then stops looking: no record is announced for the rest of that run, while CloudFormation keeps waiting. Grant the four actions and re-run, or read each certificate’s validation record from ACM yourself (see Domain). Every successful deploy, apex and delegated alike, self-records the entry’s account and region keys in fjall-config.json: the 12-digit AWS account id and the region the domain stack deployed into. Record classification uses a delegated child’s account key to gather the child zone’s cross-account evidence, and the parent-side delegation resolution reads the apex entry’s.

Verify delegation

Check whether the domain’s nameservers point to Route 53:
--target defaults to the domain entry’s home pin, falling back to the active target. The command runs five progress steps (Checking DNS propagation, Checking delegation role, Checking zone drift, Checking certificate validation, Completing), then prints the verdict:
Delegation role: and Hosted zone: print only when those audits return a verdict. After the DNS check, verify audits the parent-side delegation role. A managed zone whose fjall-config.json declares delegated children needs its stack to publish a delegation-role export, otherwise no child NS delegation can be written in-stack. The audit reports published, missing (redeploy the parent, which publishes the export), or unavailable (credentials could not be resolved, so run fjall connect). It is diagnostic only and never gates the DNS verdict. An external-records domain skips the nameserver lookup, because its DNS stays at the external provider. The verdict prints a DNS line in place of the delegation lines, and the result carries registrar: "external-records" and delegated: false with no nameservers:
When the domain declares certificates and its stack holds them, the certificate validation step reads them from ACM. Certificate validation: reports the worst verdict across the certificates: Immediately after a deploy requests a certificate, ACM may not have published its record yet. The verdict is still pending, and verify warns ACM has not published the validation record for example.com yet; re-run 'fjall domain verify example.com' in a minute. rather than naming a record. An unusable verdict warns with ACM’s status verbatim, and its reason when ACM gives one: ACM reports the certificate for example.com as VALIDATION_TIMED_OUT; it will not become valid without a new certificate request. A validation record that was never created is the usual cause of VALIDATION_TIMED_OUT. ACM derives a validation record from the account and the name, so a wildcard and its base name share one record, and so do two certificates covering the same name. Each distinct record is reported once. Its domainNames (the (validates example.com) suffix in the warning) lists every name it validates, so you create that CNAME once. The records still to create are also returned as validationRecords (name, type, value, domainNames) for --agent and --fields projections. The reads need cloudformation:DescribeStacks, cloudformation:ListStackResources, cloudformation:DescribeStackEvents and acm:DescribeCertificate. Print registrar delegation instructions instead of the verdict block:
--show-delegation refuses an external-records domain, because Fjall creates no hosted zone for it and there are no nameservers to delegate to.

List live records

List every live record in the zone with its ownership classification:
fjall domain records example.com behaves identically, because list is the default action. Each record is classified as one of: A satellite record covers the apex alias an application stack manages, live ACM-validation records, and a child’s NS row written through Custom::CrossAccountZoneDelegation. A residue record covers validation records for certificates that no longer exist, and aliases to AWS targets proven gone. Classification is read-only and fail-closed. An evidence gap makes a record unknown, never residue, and fjall domain sync --fix never offers an unknown record for remediation. Evidence is drawn from two sources: the templates of live CloudFormation stacks that carry a fjall: tag or a name starting Fjall, in the region the command reads, including Custom::CrossAccountZoneDelegation delegation resources; and ACM certificate ownership, read in that region and in us-east-1. A validation record counts as satellite while its certificate is live and one of those stacks owns the certificate. A record any other stack owns, such as the validation record of a certificate a us-east-1 stack holds for CloudFront, is never satellite. Evidence crosses accounts. A delegated child zone living in another AWS account classifies its parent-side NS row as satellite when the child’s domain stack fully proves ownership. The stack must be live, its template must claim the NS record via a literal DelegatedZoneName, and its nameservers stack output must match the live NS values (order, case, and trailing-dot insensitive). Any missing piece fails closed to unknown with a warning naming the child domain and the cure, never a guess. The classifier finds the child through the delegated domain’s account key in fjall-config.json, set automatically by a successful delegated fjall domain deploy. You can also add it by hand. An unreachable child account affects only that child’s row. The rest of the zone’s evidence, residue detection included, is unaffected.

Sync a zone

Report the classification of every live record (read-only):
Delete consented residue records:
Spell each --remediate value exactly as the ticket prints it, for example --remediate '_abc.example.com/CNAME'=recreate. --fix runs the same destruction ceremony as deploy. A ticket names each residue record, you consent per row, and withheld consent exits 4 with nothing deleted. Only residue-classified records ever appear on the ticket, so unknown records are never remediable.

Delegate a child domain

Write (or report) the NS delegation for a delegated child domain:
When Fjall manages the parent zone, the child’s NS records are written into it through the parent’s delegation role and the command waits for propagation. When the parent is externally owned, the command prints the exact NS records to create at your registrar instead.

Destroy a domain

Destroy the domain’s stack through the destruction ceremony:
Consent to an apex zone by name (--remediate 'example.com'=recreate). Consent to a delegated child’s NS row with its zone-qualified key (--remediate 'staging.example.com/NS'=recreate, or =forget). Destroying an apex zone requires consenting to the zone by name. When the domain stack created the zone, the destroy deletes it, and a recreated zone gets a different nameserver set, so DNS stops resolving until the registrar is repointed. An adopted zone, one whose infrastructure.ts names a literal hostedZoneId, survives: the destroy deletes every record the domain stack owns, which after a takeover includes every record you released, and leaves the hosted zone, its name servers and its delegation in place. Its ticket still describes a deleted zone. Destroying a delegated child deletes its NS record from the parent zone first. When the parent cannot delete it, because no delegation role is published, the ticket offers =forget instead.

Export DNS records

Export the domain’s DNS records to a BIND zone file:

Import a domain

Import from an existing Route 53 hosted zone (the default when a domain is given):
Import from a BIND zone file:
Import writes an infrastructure.ts from the live zone. Only the guided import classifies who owns each record. A Route 53 import with a domain argument, with or without --agent or --non-interactive, declares the records another live CloudFormation stack owns along with the rest: an application’s alias record, for example, or the DNS validation CNAME of a certificate CloudFormation manages. No import writes a header naming the records it left out. The first fjall domain deploy of the adopted zone then passes through the record-takeover ceremony described above, so work through Adopt a live Route 53 zone first.

Guided import (no domain argument)

Run fjall domain import with no domain argument in a terminal to open the guided flow:
  1. How do you want to import a domain? picks between a discovered Route 53 zone and a BIND zone file. Choosing BIND prompts for Path to the BIND zone file:.
  2. Fjall discovers zones, then Select a hosted zone to import: lists them. Already-configured zones are hidden.
  3. A Reading records ... and classifying ownership... step runs the classifier.
  4. Curate records for <zone>: N candidate(s) is a multi-select. Each row carries a [declared], [satellite], [residue], or [unknown] chip. Satellite and residue rows start unticked, and declared and unknown rows start ticked.
  5. Import review: <zone> reports Keeping X of N record(s); Y excluded., then asks Registrar for the imported domain: (route53 or external-records).
Selecting a registrar is the commit. The scaffold write starts immediately, shows Importing domain: <zone>, and finishes with Imported domain '<zone>'.. The classifier reads only stacks that carry a fjall: tag or a name starting Fjall, in the active target’s region, so a record another stack owns can arrive unknown and ticked. The validation record of a us-east-1 certificate for CloudFront is one such record. Untick each record a stack owns before you choose the registrar.

Prompt-driven import (domain argument plus --interactive)

fjall domain import example.com --interactive uses a plain prompt sequence with no zone discovery and no curation screen:
  1. Zone name.
  2. Registrar mode (route53, external-delegated, or external-records). Choosing external-delegated adds Delegated subdomain (e.g. 'app').
  3. Add a record? loops over Record type, Record name (use @ for apex), Record value, and TTL (seconds, blank for default).
  4. Add a certificate? collects Certificate primary domain and SANs (comma-separated, blank for none).
  5. Add a delegation? collects Subdomain to delegate, Target account ID, and Automate delegation?.
  6. A single confirm, Accept N records, M certificates, K delegations?, gates the write.

Adopt a live Route 53 zone

The first deploy of an adopted zone deletes each record you consent to before the domain stack creates it again, and nothing in Fjall restores a record when that deploy fails (see Deploy a domain). Work through these steps before you pass any --remediate consent.
  1. Back up the zone. Save every live record set, with its values, outside the project. --target names the account that hosts the zone:
  2. Check who owns each record. fjall domain records list reads the active target’s account, as the import does:
    Each line gives a record’s name, type and classification, and ends with the owning stack’s name in brackets when the classifier proved one. Remove every satellite record from infrastructure.ts. The classifier reads only stacks that carry a fjall: tag or a name starting Fjall, in the target’s region, so treat each unknown record as owned until you have checked it. Check two shapes first: an alias to a load balancer or a CloudFront distribution, and an _<hash> CNAME that validates a certificate, including a us-east-1 certificate for CloudFront.
  3. Compare every declared value with the live record. Check each record in infrastructure.ts against the backup: its value, its TTL and, for an alias, its target. A TXT record held as several strings, such as a 2048-bit DKIM key, imports wrongly. From Route 53 the value keeps the quote characters between the strings and reads "<part 1>\" \"<part 2>" in infrastructure.ts. From a BIND file the strings are joined with a space, and a string ends at an escaped quote. Declare such a value by hand as one string, its parts joined with nothing between them. The deploy splits it again into 255-character strings, which reproduces the live record when each string but the last is 255 characters long, and DKIM and SPF read the strings joined without a separator either way. Neither the import nor the Domain construct carries a routing policy (weighted, latency, failover or geolocation) or an alias’s EvaluateTargetHealth, so leave a record set that uses one out of infrastructure.ts.
  4. Check the zone’s export name. The domain stack exports <zone>-hosted-zone-id, the zone with its dots as dashes. Look for it in the account and region the domain deploys into:
    [] means the name is free. An application stack that binds the zone with HostedZoneFactory.import exports the same name, and CloudFormation then refuses the domain stack’s creation when it executes, after the takeover has deleted the records. Bind that application’s zone by value instead, with domainConfig: { domainName, managedDomain: { zoneName, hostedZoneId } } in place of hostedZone: HostedZoneFactory.import(...) (see Custom Domains). Deploy the application, run the check again, and plan the domain only once it prints [].
  5. Expect a gap for each record. A consented record is absent from DNS from the moment Fjall deletes it until the domain stack creates it again, and a resolver that asks in that time caches the absence for up to the zone’s negative TTL: 900 seconds with Route 53’s default SOA. Deploy at an hour when a missing record costs least.
  6. Prepare the restore. Build a Route 53 change batch from the backup, leaving out the apex NS and SOA records, and keep the command that applies it ready:
    Take the backup again and rebuild the batch just before you consent, so it holds the values you are about to release. If the domain stack’s creation fails, apply the restore once CloudFormation has finished rolling the stack back. CloudFormation leaves the failed stack in ROLLBACK_COMPLETE. Delete it before you plan again, because the next plan reads the failed stack’s template as owning every record it declared: it mints no ticket, and the deploy then fails because the records you restored already exist. The domain stack is named after the zone (ExamplecomDomain for example.com):
  7. Adopt a subset first. The takeover releases only records infrastructure.ts declares, so a record you leave out stays live and untouched. Declare the records whose absence costs least, deploy, then declare the rest in later deploys.
Then run fjall domain deploy example.com --plan, read every ticket row, and consent as Deploy a domain describes.

List domains

Show all domains configured in the current project:

Eject a domain

Remove a domain from Fjall management while keeping its AWS resources in place:
From fjall 38 an ejected external-records domain emits the same record outputs the Fjall-managed stack does: the declared records only. Neither side describes a certificate-validation record, because ACM chooses those at deploy time (see Domain).

Exit codes

Next steps

Domain construct

Declare zones, records, certificates, and delegation in infrastructure.ts.

Destructive Changes

How the destruction ceremony and named consents work.

Deploy

Deploy your application to AWS.

Connect AWS

Connect an AWS account to deploy into.