The multi-account DNS and certificate challenge
As teams scale across AWS accounts, DNS and certificate management become a coordination nightmare if each account owns its own infrastructure. You're managing delegated zones, copying certificates, manually syncing DNS records, and wondering why certificate renewals failed in one account but not another. Managing DNS and certificates across accounts explained through a shared services model eliminates this friction by centralizing DNS in one account while letting other accounts request and maintain their own certificates.
In this article, we'll walk through a production pattern that uses Terraform to set up a central DNS account with delegated zones, configure cross-account IAM roles so other accounts can manage their own resources, and share outputs through remote state. This approach keeps you DRY, auditable, and scalable as your organization grows.
Central DNS account with delegated zones
The foundation of this pattern is a dedicated AWS account that owns the root domain and all zone delegation. Other accounts don't manage DNS directly; instead, they own subdomains or specific records within zones hosted in the central account.
Let's say your organization owns acme.com. You have separate accounts for development, staging, and production. Rather than creating three separate hosted zones, you create one zone in a central DNS account and delegate subdomains: dev.acme.com, staging.acme.com, and prod.acme.com.
In your central DNS account, you'll define the root zone and NS records for each delegated subdomain:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
alias = "central"
}
# Root hosted zone in the central DNS account
resource "aws_route53_zone" "root" {
provider = aws.central
name = "acme.com"
tags = {
Environment = "shared"
Purpose = "root-zone"
}
}
# Delegated zone for dev account
resource "aws_route53_zone" "dev_delegated" {
provider = aws.central
name = "dev.acme.com"
tags = {
Environment = "dev"
Purpose = "delegated-zone"
}
}
# NS records in root zone pointing to dev zone
resource "aws_route53_record" "dev_ns" {
provider = aws.central
zone_id = aws_route53_zone.root.zone_id
name = "dev.acme.com"
type = "NS"
ttl = 300
records = aws_route53_zone.dev_delegated.name_servers
}
# Repeat for staging and prod
resource "aws_route53_zone" "staging_delegated" {
provider = aws.central
name = "staging.acme.com"
tags = {
Environment = "staging"
}
}
resource "aws_route53_record" "staging_ns" {
provider = aws.central
zone_id = aws_route53_zone.root.zone_id
name = "staging.acme.com"
type = "NS"
ttl = 300
records = aws_route53_zone.staging_delegated.name_servers
}Notice we're using provider aliases so we can explicitly target the central account. This pattern scales: you have one source of truth for the domain hierarchy, and each delegated zone is a natural boundary for that environment's infrastructure team.
Cross-account roles in providers
Now each account (dev, staging, prod) needs to manage its own delegated zone. Rather than sharing AWS credentials, we use cross-account IAM roles and assume them from the dev/staging/prod accounts.
In the central DNS account, create a role that other accounts can assume:
# In the central DNS account
data "aws_caller_identity" "current" {}
resource "aws_iam_role" "cross_account_dns" {
name = "cross-account-dns-admin"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${var.dev_account_id}:root"
}
Action = "sts:AssumeRole"
},
{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${var.staging_account_id}:root"
}
Action = "sts:AssumeRole"
},
{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${var.prod_account_id}:root"
}
Action = "sts:AssumeRole"
}
]
})
}
# Policy allowing Route53 operations on delegated zones
resource "aws_iam_role_policy" "dns_admin" {
name = "dns-admin-policy"
role = aws_iam_role.cross_account_dns.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets",
"route53:GetChange"
]
Resource = [
"arn:aws:route53:::hostedzone/${aws_route53_zone.dev_delegated.zone_id}",
"arn:aws:route53:::hostedzone/${aws_route53_zone.staging_delegated.zone_id}"
]
},
{
Effect = "Allow"
Action = [
"route53:ListHostedZones",
"route53:GetHostedZone"
]
Resource = "*"
}
]
})
}
# Output the role ARN for other accounts to assume
output "cross_account_dns_role_arn" {
value = aws_iam_role.cross_account_dns.arn
}In the dev account's Terraform configuration, assume that role:
# In the dev account
provider "aws" {
region = var.aws_region
alias = "assume_dns"
assume_role {
role_arn = var.central_dns_role_arn # Output from central account
}
}
# Now the dev account can manage its delegated zone
resource "aws_route53_record" "app_a_record" {
provider = aws.assume_dns
zone_id = var.dev_zone_id # Passed from central account output
name = "app.dev.acme.com"
type = "A"
ttl = 300
records = [aws_instance.app.private_ip]
}This pattern is secure: dev, staging, and prod accounts can only modify their own delegated zones. They cannot touch the root domain or other zones. No credentials are shared; instead, we rely on AWS STS (Security Token Service) for temporary credential exchange.
Certificates in each account
ACM (AWS Certificate Manager) certificates are account-local by design. You cannot share a certificate across accounts directly. Instead, each account requests its own certificate for its domain.
The dev account requests a certificate for *.dev.acme.com:
# In the dev account
resource "aws_acm_certificate" "app" {
domain_name = "*.dev.acme.com"
validation_method = "DNS"
tags = {
Name = "app-certificate"
}
lifecycle {
create_before_destroy = true
}
}
# Create the DNS validation record in the delegated zone
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.app.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = var.dev_zone_id # Points to central account's delegated zone
}
# Wait for validation
resource "aws_acm_certificate_validation" "app" {
certificate_arn = aws_acm_certificate.app.arn
timeouts {
create = "5m"
}
depends_on = [aws_route53_record.cert_validation]
}
output "certificate_arn" {
value = aws_acm_certificate.app.arn
}The key insight: the dev account needs permission to create DNS records in its delegated zone (which lives in the central account) to validate the certificate. This is why the cross-account role we defined earlier is essential. The dev account assumes that role and creates the validation record in the central zone.
Outputs shared through remote state
Terraform remote state becomes your API for sharing outputs between accounts. The central DNS account outputs zone IDs and role ARNs; other accounts reference those outputs via Terraform's HTTP remote state backend or by explicitly reading remote state.
In the central account's state, you output:
output "root_zone_id" {
value = aws_route53_zone.root.zone_id
description = "Root hosted zone ID for acme.com"
}
output "dev_zone_id" {
value = aws_route53_zone.dev_delegated.zone_id
description = "Delegated hosted zone ID for dev.acme.com"
}
output "staging_zone_id" {
value = aws_route53_zone.staging_delegated.zone_id
description = "Delegated hosted zone ID for staging.acme.com"
}
output "cross_account_dns_role_arn" {
value = aws_iam_role.cross_account_dns.arn
description = "Role ARN for cross-account DNS management"
}
output "dev_zone_nameservers" {
value = aws_route53_zone.dev_delegated.name_servers
description = "Nameservers for delegation verification"
}In the dev account, consume those outputs:
terraform {
required_providers {
terraform = {
source = "hashicorp/terraform"
version = "~> 1.0"
}
}
}
data "terraform_remote_state" "central_dns" {
backend = "s3"
config = {
bucket = var.central_state_bucket
key = "dns/central/terraform.tfstate"
region = var.aws_region"
dynamodb_table = "terraform-locks"
}
}
locals {
central_dns_role_arn = data.terraform_remote_state.central_dns.outputs.cross_account_dns_role_arn
dev_zone_id = data.terraform_remote_state.central_dns.outputs.dev_zone_id
}
variable "central_dns_role_arn" {
type = string
default = data.terraform_remote_state.central_dns.outputs.cross_account_dns_role_arn
description = "Role ARN from central DNS account"
}
variable "dev_zone_id" {
type = string
default = data.terraform_remote_state.central_dns.outputs.dev_zone_id
description = "Zone ID for dev.acme.com"
}
# Now use those values
provider "aws" {
region = var.aws_region
alias = "assume_dns"
assume_role {
role_arn = local.central_dns_role_arn
}
}
resource "aws_route53_record" "app" {
provider = aws.assume_dns
zone_id = local.dev_zone_id
name = "api.dev.acme.com"
type = "A"
ttl = 300
records = [aws_lb.app.private_ip]
}Remote state as an API enforces dependencies and versioning. If the central account changes zone IDs, dev and staging accounts will see those changes immediately on their next run. This beats copying outputs into variables.tf files.
Common mistakes
- Overly permissive cross-account roles: Avoid giving accounts blanket Route53 permissions. Specify the exact zone ARNs they can modify. Use resource-based conditions to limit which records they can change (e.g., only within their subdomain).
- Forgetting the zone ID in the delegated zone: If you don't pass the delegated zone's ID to the child account, they cannot create records. It's easy to forget when setting up the cross-account provider.
- Mixing local and remote state outputs: Be consistent. If you're pulling zone IDs from remote state in one place, do it everywhere. Hardcoding zone IDs in some modules and pulling from remote state in others creates silent failures.
- Certificate validation races: Ensure the DNS record for ACM validation is created before the certificate attempts validation. Use explicit depends_on or implicit dependencies to prevent timeout errors.
- Not versioning the central state backend: Enable S3 versioning on the bucket holding the central DNS account's state. If someone accidentally deletes a zone, you can recover it.
Key takeaways
- A central DNS account with delegated zones is the foundation: one source of truth for your domain hierarchy, with natural boundaries for each account.
- Cross-account IAM roles with assume_role in the provider configuration eliminate credential sharing and enforce least-privilege access per zone.
- Each account owns its own certificates, validated via DNS records it creates in its delegated zone using the cross-account role.
- Remote state outputs become the contract between accounts: zone IDs, role ARNs, and nameservers flow from central to child accounts through Terraform's data sources.
- This pattern scales to dozens of accounts and environments. New accounts plug in by reading the same remote state and assuming the same cross-account role.






Comments
Loading comments…