The DNS Configuration Tax

Every cloud architect has been here: you’re launching a new site, and you hit the DNS configuration phase.

The checklist:

  1. Request SSL certificate in AWS ACM
  2. Copy the validation CNAME (name: _abc123..., value: _xyz789...)
  3. Log into Cloudflare (or Route 53, or whoever)
  4. Add the CNAME record
  5. Wait 5-10 minutes
  6. Refresh AWS console repeatedly
  7. Certificate finally validates
  8. Create CloudFront distribution
  9. Copy CloudFront domain name (d1234abcd.cloudfront.net)
  10. Log back into Cloudflare
  11. Add CNAME for root domain
  12. Add CNAME for www subdomain
  13. Test DNS: dig graycloudarch.com
  14. Oh crap, forgot to set it to “DNS only” (gray cloud)
  15. Fix it, test again

Time: 20-30 minutes if everything goes right Typo risk: High (those validation records are 60+ characters) Fun factor: Zero

And this is just one domain. I’m running two brands (graycloudarch.com + cloudpatterns.io), so multiply everything by 2.

This is “DNS hell”—and I wanted out.


The Terraform Epiphany

I manage AWS infrastructure for a living. At my day job, I built an Enterprise Cloud Platform with 6 AWS accounts, all managed by Terraform.

The rule: If you’re doing it twice, automate it.

So why was I clicking through Cloudflare’s UI like it’s 2015?

The answer: I didn’t know Cloudflare had a Terraform provider. Once I found out, everything changed.


The Solution: Terraform Orchestrates AWS + Cloudflare

Key insight: Terraform can manage resources across multiple providers in the same configuration.

terraform apply
    │
    ├─→ AWS Provider
    │   ├─ Creates ACM certificate
    │   ├─ Exposes validation records as outputs
    │   └─ Waits for validation
    │
    └─→ Cloudflare Provider
        ├─ Reads ACM validation records
        ├─ Creates DNS records automatically
        └─ Returns control to AWS provider

Terraform orchestrates the entire dance. You just run terraform apply.


The Code: 100 Lines That Changed Everything

Step 1: Request ACM Certificate

resource "aws_acm_certificate" "graycloudarch" {
  provider          = aws.us-east-1  # CloudFront requires us-east-1
  domain_name       = "graycloudarch.com"
  validation_method = "DNS"

  subject_alternative_names = ["www.graycloudarch.com"]

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name = "graycloudarch.com"
    Site = "graycloudarch"
  }
}

Nothing magical here. Standard ACM certificate request with DNS validation.

The magic is in the next step.

Step 2: Automatic DNS Validation with for_each

This is where most people get stuck. ACM gives you validation records, but you have to manually add them to DNS.

Not anymore.

resource "cloudflare_record" "graycloudarch_cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.graycloudarch.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = data.cloudflare_zone.graycloudarch.id
  name    = each.value.name
  value   = each.value.record
  type    = each.value.type
  ttl     = 60
  proxied = false  # Must be false for ACM validation
}

Let’s break this down.

The for_each Loop

for_each = {
  for dvo in aws_acm_certificate.graycloudarch.domain_validation_options : dvo.domain_name => { ... }
}

What this does:

  1. aws_acm_certificate.graycloudarch.domain_validation_options is a list of validation records ACM generates
  2. For each record (dvo), create a map entry keyed by dvo.domain_name
  3. The value is an object with name, record, and type

Why this works:

  • ACM generates 2 validation records (one for apex domain, one for www)
  • Terraform iterates over both
  • Each iteration creates a Cloudflare DNS record

The result: Two DNS records created automatically, no copy-paste.

The cloudflare_record Resource

zone_id = data.cloudflare_zone.graycloudarch.id
name    = each.value.name
value   = each.value.record
type    = each.value.type
ttl     = 60
proxied = false

Key details:

  • zone_id: Fetched via data source (more on this later)
  • name: The long _abc123... CNAME name from ACM
  • value: The equally long _xyz789... validation value
  • proxied = false: Critical—ACM validation requires DNS-only mode

Without proxied = false, ACM validation fails. Cloudflare’s proxy rewrites DNS responses, breaking ACME DNS-01 validation.

Step 3: Wait for Validation

resource "aws_acm_certificate_validation" "graycloudarch" {
  provider                = aws.us-east-1
  certificate_arn         = aws_acm_certificate.graycloudarch.arn
  validation_record_fqdns = [for record in cloudflare_record.graycloudarch_cert_validation : record.hostname]
}

This is the glue.

What it does:

  1. Tells Terraform to wait for ACM validation to complete
  2. Depends on Cloudflare DNS records being created first
  3. Blocks until validation succeeds

Without this resource:

  • Terraform would request the cert and immediately move on
  • You’d have to run terraform apply again after validation
  • Not truly automated

With this resource:

  • Terraform waits (usually 2-5 minutes)
  • Validation completes
  • Terraform continues to next step

This is idempotent. If validation already completed (e.g., re-running terraform apply), this step is a no-op.

Step 4: CloudFront DNS Records (The Easy Part)

Once the certificate is validated, pointing your domain to CloudFront is straightforward:

resource "cloudflare_record" "graycloudarch_root" {
  zone_id = data.cloudflare_zone.graycloudarch.id
  name    = "@"
  value   = module.graycloudarch.cloudfront_domain_name
  type    = "CNAME"
  ttl     = 1  # Auto
  proxied = false
}

resource "cloudflare_record" "graycloudarch_www" {
  zone_id = data.cloudflare_zone.graycloudarch.id
  name    = "www"
  value   = module.graycloudarch.cloudfront_domain_name
  type    = "CNAME"
  ttl     = 1
  proxied = false
}

Two records:

Why proxied = false here too? CloudFront is already a CDN. Using Cloudflare’s proxy on top creates:

  • Double latency (request hits Cloudflare edge, then CloudFront edge)
  • SSL termination conflicts
  • More moving parts to debug

Use Cloudflare for DNS only. It’s excellent at that.


The Data Sources: Getting Zone IDs

Cloudflare needs a zone_id to create records. You could hardcode it:

zone_id = "abc123def456"  # Don't do this

But that’s brittle. If you transfer the domain or change Cloudflare accounts, the zone ID changes.

Better approach: Data source

data "cloudflare_zone" "graycloudarch" {
  name = "graycloudarch.com"
}

# Use it:
zone_id = data.cloudflare_zone.graycloudarch.id

What this does:

  1. Queries Cloudflare API: “What’s the zone ID for graycloudarch.com?”
  2. Returns the current zone ID
  3. Uses it in resources

Benefits:

  • Works across Cloudflare account changes
  • Self-documenting (you can see it’s looking up “graycloudarch.com”)
  • No hardcoded IDs

The Dependencies: Terraform Graph Magic

Terraform automatically figures out the order of operations based on resource dependencies:

aws_acm_certificate.graycloudarch
    ↓ (domain_validation_options)
cloudflare_record.graycloudarch_cert_validation
    ↓ (hostname)
aws_acm_certificate_validation.graycloudarch
    ↓ (certificate_arn)
module.graycloudarch (CloudFront)
    ↓ (cloudfront_domain_name)
cloudflare_record.graycloudarch_root

You don’t have to tell Terraform the order. It infers dependencies from references:

  • cloudflare_record references aws_acm_certificate.domain_validation_options → Terraform knows to create the cert first
  • aws_acm_certificate_validation references cloudflare_record.hostname → Terraform knows to create DNS records first
  • cloudflare_record.graycloudarch_root references module.graycloudarch.cloudfront_domain_name → Terraform knows to create CloudFront first

This is why Terraform is better than bash scripts. Scripts fail if you run steps out of order. Terraform always runs them in the right order.


Scaling to Multiple Domains

Here’s the beautiful part: adding a second domain is 10 lines of code.

# ACM certificate
resource "aws_acm_certificate" "cloudpatterns" {
  provider          = aws.us-east-1
  domain_name       = "cloudpatterns.io"
  validation_method = "DNS"
  subject_alternative_names = ["www.cloudpatterns.io"]
}

# Validation records (same for_each pattern)
resource "cloudflare_record" "cloudpatterns_cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.cloudpatterns.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }
  zone_id = data.cloudflare_zone.cloudpatterns.id
  name    = each.value.name
  value   = each.value.record
  type    = each.value.type
  ttl     = 60
  proxied = false
}

# Validation wait
resource "aws_acm_certificate_validation" "cloudpatterns" {
  provider                = aws.us-east-1
  certificate_arn         = aws_acm_certificate.cloudpatterns.arn
  validation_record_fqdns = [for record in cloudflare_record.cloudpatterns_cert_validation : record.hostname]
}

# CloudFront DNS records
resource "cloudflare_record" "cloudpatterns_root" { /* ... */ }
resource "cloudflare_record" "cloudpatterns_www" { /* ... */ }

Copy-paste the pattern, change the names. That’s it.

Manual approach: Do all 15 steps again for the second domain. Terraform approach: Copy 10 lines, run terraform apply.


The Before & After

Before: Manual DNS Configuration

For two domains (graycloudarch.com + cloudpatterns.io):

Time:

  • 30 minutes per domain = 1 hour total
  • Plus fixing typos: +15 minutes

Steps:

  • 30+ manual actions (clicks, copy-pastes, waits)

Errors:

  • Typo in validation CNAME: 20% chance
  • Forgot to set DNS-only mode: 30% chance
  • Added wrong CloudFront domain: 10% chance

Documentation:

  • “I think I did this last time…” (unreliable memory)

Scaling:

  • Third domain = another 30 minutes
  • No reusability

After: Terraform Automation

For two domains:

Time:

  • Write Terraform once: 30 minutes
  • Deploy both domains: terraform apply (35 minutes, mostly waiting)
  • Total: 35 minutes (and future domains are free)

Steps:

  • 1 command (terraform apply)

Errors:

  • 0% (Terraform validates before applying)

Documentation:

  • The code IS the documentation
  • Git history shows what changed and why

Scaling:

  • Third domain = copy 10 lines, run terraform apply (5 minutes)

The Real Win: Mental Overhead

The time savings are great. But the bigger win is mental overhead.

Manual DNS configuration requires:

  • Context switching (AWS console → Cloudflare → AWS → Cloudflare)
  • Remembering state (“Did I already add the www record?”)
  • Focus (“Don’t typo this 60-character validation string”)
  • Testing (“Let me check if it worked…”)

Terraform requires:

  • Run terraform apply
  • Go make coffee

I get my focus back. I can write this blog post while Terraform deploys infrastructure.


Lessons Learned

1. Cloudflare’s Terraform Provider is Excellent

It covers 95% of Cloudflare features:

  • DNS records
  • Page rules
  • Firewall rules
  • Workers (if you’re into that)

Documentation: https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs

Well-maintained: Updates within days of new Cloudflare features

2. for_each Loops are the Secret Weapon

Most Terraform tutorials show count:

resource "aws_instance" "example" {
  count = 3
  # ...
}

for_each is better for dynamic resources:

resource "cloudflare_record" "validation" {
  for_each = toset(var.validation_records)
  # ...
}

Why?

  • count uses index (0, 1, 2…). If you remove item 1, Terraform re-indexes everything (destroys and recreates resources)
  • for_each uses keys (domain names, IDs). Removing one item doesn’t affect others

For ACM validation, for_each is essential because ACM generates validation records dynamically.

3. aws_acm_certificate_validation is Non-Obvious

This resource doesn’t create anything in AWS. It’s a waiter.

Without it:

$ terraform apply
# Creates cert, creates DNS records, exits
# Cert is still "Pending Validation"
# CloudFront creation fails (needs validated cert)

$ terraform apply  # Run again later
# Now cert is validated, CloudFront succeeds

With it:

$ terraform apply
# Creates cert, creates DNS records
# Waits for validation (2-5 minutes)
# Creates CloudFront with validated cert
# Everything works in one apply

Always use aws_acm_certificate_validation for automated workflows.

4. proxied = false is Critical

Spent 30 minutes debugging why ACM validation failed. The problem:

proxied = true  # Wrong!

Cloudflare’s proxy rewrites DNS responses. ACM’s validation servers see Cloudflare’s IP, not your validation record.

Solution:

proxied = false  # Correct

ACM validation requires DNS-only mode. Remember this or suffer.


Cost Analysis

Terraform: Free (open source) Cloudflare API: Free (included in free tier) GitHub (storing Terraform code): Free (public repo) or $4/month (private repos)

Total cost of automation: $0 (or $4/month if you want private repos)

Time saved per deployment: 30 minutes Value of time (as a consultant): $150/hour ROI per deployment: $75

Over 12 months (12 deployments): $900 saved

One-time investment: 30 minutes to write Terraform Payback period: After 1 deployment


What’s Next

Enhancements I’m Adding

1. Modules for Reusability Instead of copy-pasting, extract to a module:

module "domain" {
  source = "./modules/domain"

  domain_name  = "graycloudarch.com"
  enable_www   = true
}

2. Terraform Cloud for State Currently using local state. Upgrading to remote state in S3 + DynamoDB for:

  • State locking (prevent concurrent applies)
  • State history (rollback if needed)
  • Team collaboration (if I hire help)

3. Preview Environments Use Terraform workspaces for staging:

terraform workspace new staging
terraform apply
# Deploys to staging-graycloudarch.com

4. Automated Testing Use terraform plan in CI/CD:

on: [pull_request]
jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - run: terraform plan
      # Fail PR if plan shows destructive changes

Why This Matters for Side Hustles

I’m building this side hustle to $3K/month by March 31, 2026. That’s 9 weeks away.

Every hour I save on infrastructure is an hour I can spend on:

  • Writing blog posts (like this one)
  • LinkedIn outreach to potential clients
  • Building my Udemy course
  • Actually talking to customers

Manual DNS configuration doesn’t make money. Published content makes money.

By automating with Terraform, I’m optimizing for the constraint that matters: my time.


Conclusion

Going from manual DNS configuration to fully automated Terraform took:

  • 30 minutes to write the initial code
  • 100 lines of Terraform
  • Zero regrets

What I got:

  • ✅ ACM certificates with auto-validation
  • ✅ CloudFront DNS records automatically managed
  • ✅ No more typos, no more forgotten steps
  • ✅ Reusable for future domains
  • ✅ Version-controlled in Git

If you’re managing multiple domains:

  • Stop clicking through consoles
  • Use Terraform + Cloudflare provider
  • Automate ACM validation with for_each
  • Never look back

Your time is worth more than $0/hour. Automate the boring stuff.


Want help automating your infrastructure? Book a consultation or check out my course on cloud automation.