The operational reality of adopting Infrastructure as Code for AWS environments is that Route53 hosted zones and their record sets are almost always created before Terraform is introduced. Records are added through the AWS Console, through manual CLI calls, or through earlier automation, and then a team attempts to bring that existing DNS configuration under Terraform management without recreating records, causing downtime or conflicting changes. The terraform import command is the mechanism Terraform provides to associate a live AWS resource with a configuration block and a state entry. For awsroute53record resources the import identifier is a composite of the hosted zone identifier, the record name, and the record type, in the form {hostedZoneId}{recordName}{Type}. This identifier is the key to mapping an existing record set into Terraform state.
Once the mapping exists, Terraform will manage the record going forward and will not attempt to create a duplicate. The challenge with Route53 is scale. A single hosted zone can contain dozens or hundreds of ResourceRecordSets, each with a different Type, TTL, and record value set. Performing terraform import manually for each record requires a pre-existing resource block for every record, and the import command must be executed once per record. That workflow is operationally impractical at scale and is the reason scripted import workflows and the import block with generate-config-out are discussed in practice.
Manual Import Workflow for a Single Route53 Record
The manual workflow establishes the baseline that automated methods attempt to replicate.
A resource definition must exist in Terraform configuration before terraform import can succeed. The documentation requirement is explicit: because of this, prior to running terraform import it is necessary to write manually a resource configuration block for the resource, to which the imported object will be mapped.
An example definition for an MX record is:
resource "aws_route53_record" "cogvio_com_MX" {
zone_id = aws_route53_zone.cogvio_com.zone_id
name = "cogvio.com."
type = "MX"
ttl = 300
records = [
"1 aspmx.l.google.com",
"5 alt1.aspmx.l.google.com",
"5 alt2.aspmx.l.google.com",
"10 aspmx2.googlemail.com",
"10 aspmx3.googlemail.com",
]
}
After the definition exists, the import command binds the live resource to the configuration:
terraform import aws_route53_record.cogvio_com_MX Z4KAPRWWNC7JR_cogvio_com_MX
The identifier Z4KAPRWWNC7JRcogviocom_MX follows the pattern hosted zone id, record name, type. Once imported, Terraform loads the state and treats the record as managed. This manual approach is viable for a handful of records. The real-world consequence is that each additional record requires a new resource block and a new import invocation, which creates a linear growth in manual effort and a high risk of naming inconsistencies or missing attributes.
Bulk Discovery of Existing Records With AWS CLI
Before any import can be scripted, the existing inventory must be extracted from AWS.
The AWS CLI command used to list all record sets in a hosted zone is:
aws route53 list-resource-record-sets --hosted-zone-id XXX > data/company-tld.json
This writes the full JSON representation of the zone's ResourceRecordSets to a file. The JSON structure contains Name, Type, TTL, ResourceRecords, and other attributes for each record set. Loading this file into a script provides the data needed to generate Terraform configuration and import identifiers programmatically.
A Python helper to load the records is:
def load_records(zone_file=ZONE_FILE):
with open(zone_file) as record_file:
data = json.load(record_file)
return data
The impact of this step is that the source of truth shifts from human knowledge of which records exist to a machine-readable export. That export becomes the input for automation and reduces the chance of missing records that were created manually in the console.
Programmatic Creation of Dummy Configuration Blocks
Terraform requires a resource declaration to exist prior to import. When importing many records, writing each block by hand defeats the purpose of automation.
A dummy Terraform template is written programmatically for each record:
def template_dummy_file(resource_name):
add_dummy_record = Template(
"""
resource "aws_route53_record" "$resource_name" {
# (resource arguments)
}
"""
)
dummy_file_path = path.join(TERRAFORM_DIR, "dummy.tf")
with open(dummy_file_path, "a") as f:
f.write(add_dummy_record.substitute(resource_name=resource_name))
The script appends a minimal awsroute53record block with a placeholder for resource arguments. The block satisfies Terraform's requirement that a configuration exist before import. The real-world consequence is that the import command can now succeed without manual authoring, and the dummy file serves as a temporary scaffold that can later be replaced with a proper module configuration.
Executing terraform import For Each Record
With a dummy configuration in place, the import command is executed for each record discovered from the JSON export.
The Python function that shells out the import command is:
def terraform_import(resource_name, resource_type):
import_command = f"terraform import aws_route53_record.{resource_name} {ZONE_ID}_{resource_name}_{resource_type}"
run(import_command, shell=True, check=True)
The identifier construction mirrors the AWS import ID pattern: ZONE_ID, resource name, resource type. Running this in a loop over records.get("ResourceRecordSets") imports each record into state under the dummy resource name.
The loop structure from the reference implementation is:
records = load_records()
for i in records.get("ResourceRecordSets"):
resource_name = i.get("Name")
resource_type = i.get("Type")
template_dummy_file(resource_name)
terraform_import(resource_name, resource_type)
terraform_move(resource_name, resource_type)
The impact for users is that hundreds of records can be imported in minutes rather than hours, with consistent naming derived from the live data.
Optional Move From Resource To Module Configuration
Many teams manage Route53 records through a reusable module rather than individual resource blocks. After import into dummy resources, the state entries are moved to the module's resource.
The state move command is:
def terraform_move(resource_name, resource_type):
mv_command = f"terraform state mv aws_route53_record.{resource_name} 'module.{MODULE_NAME}.aws_route53_record.route53_record[\"{resource_name}-{resource_type}\"]'"
run(mv_command, shell=True, check=True)
The move operation preserves the imported state while changing its address in the state file to match the module's resource address. This allows the Terraform configuration to be refactored after import without losing the mapping to the live AWS record. The contextual layer is that import and move are separate concerns: import establishes reality in state, move aligns state with the desired module structure.
Import Block And Generate Config Output
Terraform 1.5 introduced the import block, which allows declarative import of resources without first writing a full configuration.
An import block for a Route53 record can be defined as:
import {
to = aws_route53_record.example
id = "{hostedZoneId}_{recordName}_{Type}"
}
Running terraform plan with generate-config-out produces a configuration file from the imported resource:
terraform plan -generate-config-out=generated_resources.tf
The command outputs a Terraform configuration file containing the attributes discovered from the live resource. The real-world consequence is a reduction in manual configuration writing. The generated file can be inspected, edited, and committed, and it serves as a starting point for a more complete module configuration.
The reference notes that the manual way starts from scratch to illustrate the difference in comfort between methods. The import block plus generate-config-out workflow is positioned as an alternative to writing definitions by hand and then running import repeatedly.
Script Output And Naming Conventions
The reference implementation notes that the script expects the JSON with routes to be in the route53cogviocom.json file, and it generates the output into route53cogviocom_records.tf.
The script also tries to generate semi-usable names for the resources. You should be able to modify it further if you have a convention in mind. Or simply run it and fix the names manually - it's still less work than writing it manually from scratch.
The script skips NS and SOA records because I want them to be a bit more dynamic. Skipping these record types avoids importing records that are typically managed by Route53 zone creation and can cause conflicts if Terraform attempts to manage them.
The workflow for a zone import is:
terraform import aws_route53_zone.cogvio_com Z4KAPRWWNC7JR
And the zone resource definition:
resource "aws_route53_zone" "cogvio_com" {
name = "cogvio.com"
}
The zone import is performed first because record imports reference zone_id, and a single zone import is a one-time operation per domain.
Module Based Management Of Route53 Records
Once records are imported, teams often consolidate management into a module.
The Terraform module creates a Route53 record in AWS. It provides a flexible way to configure various types of routing policies for your DNS records.
Capabilities include:
- Creates a Route53 record with the specified zone ID, name, type, TTL, and records.
- Supports optional attributes
- Supports different routing policies including geolocation, failover, latency, weighted, and CIDR-based routing.
- Provides the option for alias records that can point to AWS resources using their AWS resource name.
- Allows only one routing policy block to be supplied.
- Adheres to security best practices by leveraging automated scanning with Checkov.
A minimum example usage is:
module "minimum_example" {
source = "boldlink/route53-records/aws"
version = "insert_latest_version"
zone_id = local.zone_id
name = var.name
type = var.type
ttl = var.ttl
records = var.records
}
Provider requirements are:
| Name | Version |
|---|---|
| terraform | >= 0.14.11 |
| aws | >= 4.65.0 |
Provider usage:
| Name | Version |
|---|---|
| aws | 5.23.1 |
Resources:
| Name | Type |
|---|---|
| awsroute53record.main | resource |
Inputs:
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| alias | (Optional) An alias block. Conflicts with ttl & records. | any | {} | no |
| allow_overwrite | Allow creation of this record in Terraform to overwrite an existing record, if any |
The module approach centralizes record configuration and allows the imported state to be managed through a single interface rather than scattered resource blocks.
Summary Of The Three Part Import Process
The task consists of 3 parts:
- Import all existing records in a hosted zone using AWS CLI
- Import the record in Terraform state
- Optional Move Resources in a Module
The first part extracts data. The second part creates a minimal configuration and runs terraform import. The third part aligns state with module addresses. Together these steps provide a repeatable path from console-created DNS to Terraform-managed DNS with minimal manual effort and reduced risk of drift.