Cloudflare Tunnels enabled external access from behind Carrier Grade NAT or Dual-Stack Lite. Moving DNS from Route53 to Cloudflare was the logical next step.

The Context

I procure all my domains on Netcup (can only recommend checking them out) and delegate nameservers to wherever I want them managed. For years, that was AWS Route53 for mittbachweg.de and benmatheja.de.

Route53 worked well:

  • Reliable DNS resolution
  • LetsEncrypt DNS-01 challenges via Traefik (automated cert issuance)
  • Terraform-managed records

But it wasn’t free. ~€12/year for basic DNS hosting.

The real trigger: Cloudflare Tunnels. My ISP uses Carrier Grade NAT (DS-Lite), so port forwarding was never an option. Cloudflare Tunnels enabled external access for the first time. Once I was using Cloudflare for tunnels, managing DNS in two places didn’t make sense.


The Solution

Cloudflare’s free plan includes:

  • Unlimited DNS queries
  • Unlimited DNS records
  • DDoS protection
  • Universal SSL for all subdomains
  • Cloudflare Tunnels support
  • Terraform provider

Annual cost: $0


Migration

Export Route53 Records

aws route53 list-resource-record-sets \
  --hosted-zone-id Z1234EXAMPLE \
  --output json > mittbachweg-zone.json

Discovered: 47 DNS records across both domains (A, AAAA, CNAME, MX, TXT)

Terraform Cloudflare Provider

# terraform/3-security/cloudflare.tf
terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.43.0"
    }
  }
}

provider "cloudflare" {
  api_token = var.cloudflare_api_token
}

resource "cloudflare_zone" "mittbachweg" {
  zone = "mittbachweg.de"
  plan = "free"
  jump_start = false
}

resource "cloudflare_record" "gitlab" {
  zone_id = cloudflare_zone.mittbachweg.id
  name    = "git"
  type    = "A"
  value   = "192.168.1.10"
  ttl     = 1
}

resource "cloudflare_record" "wildcard_platform_one" {
  zone_id = cloudflare_zone.mittbachweg.id
  name    = "*.one"
  type    = "CNAME"
  value   = "ruby.mittbachweg.de"
  ttl     = 300
}

Ansible Integration

Instead of managing DNS via Terraform for every service, I integrated Cloudflare DNS into Ansible roles:

# roles/platform_one/tasks/dns.yml
- name: Register Cloudflare DNS record for application
  community.general.cloudflare_dns:
    zone: mittbachweg.de
    record: ".one"
    type: A
    value: ""
    proxied: yes
    account_email: ""
    account_api_token: ""
    state: present
  when: dns_record | default(false)

Benefits:

  • DNS records created automatically when deploying services
  • No manual Terraform updates for each app
  • DNS lifecycle tied to application deployment

Phase 4: Nameserver Cutover

The critical migration moment:

# 1. Verify Cloudflare DNS is fully populated
dig @eva.ns.cloudflare.com git.mittbachweg.de

# Verify Cloudflare DNS populated
dig @eva.ns.cloudflare.com git.mittbachweg.de

# Lower Route53 TTL to 60 seconds
aws route53 change-resource-record-sets --change-batch '...'

# Update nameservers at Netcup
# Old: ns-1234.awsdns-12.org
# New: eva.ns.cloudflare.com, walt.ns.cloudflare.com

# Monitor propagation
watch -n 5 'dig NS mittbachweg.de +short'

Result: Zero downtime. Full propagation in ~45 minutes.

Cloudflare Tunnel: The Real Motivation

Port forwarding was never an option for me. My ISP uses Carrier Grade NAT (DS-Lite), which means I don’t have a public IPv4 address. Traditional port forwarding simply doesn’t work in this setup.

Even if I could forward ports, the security implications always turned me off. Exposing services directly to the internet invites constant scanning, brute force attempts, and potential DDoS attacks.

Cloudflare Tunnels changed everything. For the first time, I could reliably expose services from home without:

  • ❌ Public IP address
  • ❌ Port forwarding rules
  • ❌ Firewall holes
  • ❌ Direct exposure to internet threats

The architecture:

Internet → Cloudflare Edge → Encrypted Tunnel → Traefik → Services

Cloudflare establishes an outbound connection from my homelab to their edge network. All inbound traffic is proxied through Cloudflare’s DDoS protection and WAF. My actual infrastructure remains completely hidden.

Terraform Configuration

Tunnel setup:

resource "cloudflare_tunnel" "app_platform" {
  account_id = var.cloudflare_account_id
  name       = "app-platform-tunnel"
  secret     = base64encode(random_password.tunnel_secret.result)
}

resource "cloudflare_tunnel_config" "app_platform" {
  tunnel_id  = cloudflare_tunnel.app_platform.id
  account_id = var.cloudflare_account_id
  
  config {
    ingress_rule {
      hostname = "git.mittbachweg.de"
      service  = "https://ruby.internal:443"
    }
    
    ingress_rule {
      hostname = "*.one.mittbachweg.de"
      service  = "https://ruby.internal:443"
    }
    
    ingress_rule {
      service = "http_status:404"
    }
  }
}

resource "cloudflare_record" "tunnel_gitlab" {
  zone_id = cloudflare_zone.mittbachweg.id
  name    = "git"
  type    = "CNAME"
  value   = "${cloudflare_tunnel.app_platform.id}.cfargotunnel.com"
  proxied = true
}

Deployment

# roles/cloudflare_tunnel/tasks/main.yml
- name: Install cloudflared
  apt:
    name: cloudflared

- name: Configure tunnel credentials
  copy:
    content: \"{{ tunnel_credentials | to_json }}\"
    dest: /etc/cloudflared/credentials.json
    mode: '0600'

- name: Start tunnel service
  systemd:
    name: cloudflared-tunnel@app-platform
    enabled: yes

Why This Matters

Cloudflare Tunnels solved a problem I couldn’t fix any other way:

  • Carrier Grade NAT / DualStack Lite: No public IPv4, port forwarding impossible
  • Security: Zero exposed ports, all traffic proxied through Cloudflare, adding policies like GeoIP-based blocking or another SSO is possible
  • Reliability: Persistent outbound connection, no inbound firewall rules needed
  • DDoS protection: Cloudflare’s network absorbs attacks before they reach my connection

This wasn’t a migration from port forwarding. It was the first time I could expose homelab services to the internet reliably and securely.

What I Learned

Ansible vs. Terraform for DNS Management

I use Ansible for DNS record creation during service deployments and vm setups. This caused problems.

The issue: Ansible doesn’t track state. When you remove a DNS record from your playbook, Ansible doesn’t know to delete it from Cloudflare. The record stays there until you manually remove it.

This led to:

  • Duplicate records: Deploying the same service twice created multiple DNS entries
  • Stale records: Removing services left orphaned DNS records in Cloudflare
  • Manual cleanup: Had to manually edit DNS records in Cloudflare’s UI to fix duplicates

The fix: Move DNS records to Terraform where possible.

Terraform tracks state. When you remove a record from your Terraform code:

# Delete this resource
resource "cloudflare_record" "old_service" {
  # ...
}

Run terraform apply, and Terraform automatically removes it from Cloudflare.

Current approach:

  • Terraform: Static DNS records (zones, subdomains, tunnel CNAMEs) - the majority
  • Ansible: Dynamic records only when deployment requires it (rare)

Terraform’s state management eliminated the duplicate/stale record problem entirely. Everything configured via Terraform. LetsEncrypt DNS-01 challenges still work via Traefik.

The migration to Cloudflare made sense for consistency with Tunnel and eliminated AWS entirely.