I migrated ten domains from DNSimple to Cloudflare. Experimenting with Cloudflare Tunnels was my primary motivation, but also to learn. None of my DNS configuration was previously managed and I wanted to bring it into Terraform. As a cheeky bonus I also saved $A8.50/month, since my usage fits within the Cloudflare free tier.
This post is a sketch of the process, with links out to relevant code and configuration. It assumes working knowledge of Terraform, Ruby and basic DNS concepts.
As of January 2025, differences I noted between the two services:
- DNSimple supports a custom
ALIASrecord type to enable redirection of queries to the apex (i.e.xaviershay.com). Cloudflare achieves this by transparently convertingCNAMErecords pointing at the apex, which they call “CNAME flattening.” DNSimple also creates aTXTrecord describing theALIASfor debugging purposes only, so I excluded them from migration. - DNSimple supports a custom
SPFrecord type for email security, which creates appropriateTXTrecords for you. Those createdTXTrecords were also returned by the API, so theSPFrecords could be safely excluded. - Any record with a blank name in DNSimple needs to be
@for Cloudflare. - The DNSimple API returns
NSandSOArecords, which Cloudflare needs to manage, so these too were excluded.
My goal was to generate Terraform configuration from my existing DNSimple configuration, something eventually looking like:
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
module "zone_xaviershay_com" {
source = "cloudposse/zone/cloudflare"
version = "1.0.1"
zone = "xaviershay.com"
account_id = var.cloudflare_account_id
records = [
{
name = "@"
type = "CNAME"
ttl = 3600
value = "d35moas0x4pv9r.cloudfront.net"
proxied = false
}
# ... etc
]
}
Claude.ai helped me generate a series of Ruby scripts.
-
Scrape the DNSimple API and generate a JSON description of my configuration:
{ "provider": "dnsimple", "exported_at": "2025-01-06T16:58:48+11:00", "zones": [ { "name": "xaviershay.com", "records": [ { "name": "", "type": "ALIAS", "ttl": 3600, "content": "d35moas0x4pv9r.cloudfront.net", "priority": null, "regions": [ "global" ], "metadata": { "created_at": "2014-01-10T23:23:10Z", "updated_at": "2016-12-28T04:47:33Z" } } ] } # ... etc ] } -
Transform DNSimple specific records (e.g.
ALIAS) to equivalent Cloudflare records, and to filter out unwanted records:{ "name": "@", "type": "CNAME", "ttl": 3600, "content": "d35moas0x4pv9r.cloudfront.net", "priority": null, "regions": [ "global" ], "metadata": { "created_at": "2014-01-10T23:23:10Z", "updated_at": "2016-12-28T04:47:33Z" } } -
Generate terraform config from the transformed JSON, using
cloudposse/zone/cloudflaremodule (per above, full generated HCL here).
Multiple scripts made it easier to debug and work with AI.
From there, Terraform was able to create the necessary configuration on Cloudflare. At this point, both DNSimple and Cloudflare are able to serve DNS for my domains, but DNSimple is still primary.
Before switching, I generated another Ruby script to resolve all records using regular DNS (i.e not via the DNSimple API) and create some spec files that could be pointed at a configurable name server.
require 'resolv'
RSpec.describe 'DNS Configuration for xaviershay.com' do
let(:dns) do
config = {}
config[:nameserver] = ENV.fetch('NAMESERVER')
Resolv::DNS.new(config)
end
describe 'MX records' do
let(:records) { dns.getresources('xaviershay.com', Resolv::DNS::Resource::IN::MX) }
it 'has the correct number of records' do
expect(records.length).to eq(2)
end
it 'includes MX record with preference 10 and exchange in1-smtp.messagingengine.com' do
matching_record = records.find do |r|
r.preference == 10 && r.exchange.to_s == 'in1-smtp.messagingengine.com'
end
expect(matching_record).not_to be_nil
end
# ...
end
This was able to verify most of the
configuration at Cloudflare. I had to exclude the apex domains from the script
since they resolve non-deterministically due to how ALIAS/CNAME flattening is implemented. I
could have written a further spec to check the content at each domain, which
should be unchanged, but I checked this manually instead.
I then manually went through all six of my registrars(!) to update nameservers, leaving the most important domains until the earlier ones had baked.
All up it took about half a day.