Bringing existing AWS infrastructure under Terraform with terraform import
I built my server by hand in the AWS console, then moved it into Terraform without downtime. Here is the import workflow, the traps, and the rules I follow so plan never surprises me.
Most Terraform tutorials start with an empty AWS account. Real life usually starts the other way: a server already exists, built by hand in the console, and it is serving real traffic. You want it in code, but you cannot afford to rebuild it.
That is exactly what I did for the instance behind this portfolio. This article is the workflow, and the mistakes it protects you from.
The goal
Describe the existing infrastructure in Terraform so that:
terraform planreports "No changes".- Nothing was recreated and nothing went down.
- From now on, changes happen through code review, not console clicks.
Terraform manages three things here: the EC2 instance, its Elastic IP and the security group.
Step 1: write the resources to match reality
Start by writing resource blocks that describe what already exists. You are documenting, not designing. Open the console (or run aws ec2 describe-instances) and copy the real values:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_instance" "app" {
ami = "ami-0123456789abcdef0"
instance_type = "t3.micro"
subnet_id = "subnet-0123456789abcdef0"
vpc_security_group_ids = [aws_security_group.app.id]
root_block_device {
volume_type = "gp3"
volume_size = 20
}
metadata_options {
http_tokens = "required"
}
tags = {
Name = "app"
}
}
The IDs above are placeholders. Use your own, and keep real identifiers out of anything public.
Step 2: import each resource
terraform import connects a resource block to the real object in AWS and records it in state:
terraform import aws_security_group.app sg-0123456789abcdef0
terraform import aws_instance.app i-0123456789abcdef0
terraform import aws_eip.app eipalloc-0123456789abcdef0
Import the security group first, because the instance references it.
On Terraform 1.5 and newer you can also declare imports in code with an import block, and let terraform plan -generate-config-out=generated.tf draft the resource for you. Treat generated code as a starting point and read every line.
Step 3: run plan until it is boring
This is the step that matters:
terraform plan
You want to see No changes. If you do not, read each difference carefully. The dangerous marker is:
-/+ destroy and then create replacement
On an instance, that means Terraform believes it must delete your server and build a new one. Typical causes:
- A hard-coded
amithat differs from what the instance was launched with. - A
subnet_idorkey_namethat does not match. - An attribute you set in code that cannot be changed in place.
Do not apply a plan that replaces a live server. Fix the code until it matches reality, or tell Terraform to ignore a drifting attribute:
lifecycle {
ignore_changes = [ami]
}
Rules I follow after the import
terraform planbefore everyapply, always. Planning is read-only and safe to run at any time. It is also a cheap drift check if someone changed something in the console.- Never run
terraform destroyon a live environment. On a single server with a database on its own disk, that is not a command, it is an incident. - Keep the scope small. I manage only the instance, the Elastic IP and the security group. DNS and CDN settings stay out of Terraform because they already work and are not worth the risk of migrating.
Encode the boring-but-critical details
Writing infrastructure as code pays off when it captures facts that used to live in someone's head. My security group allows web traffic only from Cloudflare's IP ranges, and SSH only from one address:
locals {
cloudflare_ipv4_ranges = [
"173.245.48.0/20",
"103.21.244.0/22",
# ...the rest of https://www.cloudflare.com/ips-v4
]
}
resource "aws_security_group" "app" {
name = "app-sg"
description = "Web server"
vpc_id = "vpc-0123456789abcdef0"
ingress {
description = "SSH from my IP"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.my_ip]
}
ingress {
description = "HTTPS from Cloudflare"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = local.cloudflare_ipv4_ranges
}
}
Now the reason for each rule is written next to the rule, and refreshing the Cloudflare list is a one-line change followed by plan and apply. The architecture behind it is explained in hosting a Laravel app on one EC2 instance.
State: the file you must not lose
By default Terraform stores state in a local terraform.tfstate. Two consequences:
- Never commit it (or
*.tfvars) — it can contain sensitive values. - Back it up. Lose the state and Terraform forgets what it manages; you would have to import everything again.
For a solo project, a local state file with a private backup is acceptable. The moment a second person joins, move to a remote backend such as an S3 bucket with versioning and state locking.
Use a least-privilege IAM user
The credentials Terraform runs with should be able to manage only what the project needs — EC2, Elastic IPs and security groups — not the whole account. If a laptop is ever compromised, that limits the damage.
Checklist
- Resource blocks match the real values.
- Every resource imported;
planshows No changes. - Nothing is marked
-/+before you apply. - State and
*.tfvarsare out of Git, and state is backed up. - Credentials are scoped to this project.
- You never run
destroyagainst production.
Once the infrastructure is in code, the natural next step is to automate how changes reach it. That is the CI/CD pipeline article.
More articles
Hosting a Laravel app on one AWS EC2 instance with Docker, Caddy and Cloudflare
The exact setup behind this portfolio: Docker Compose on a single EC2 instance, Caddy for HTTPS, Cloudflare in front, and a security group that only lets Cloudflare in.
· 4 min read
Running a Laravel queue worker in Docker Compose without migration races
Put your queue worker in its own container, make it wait for a healthy database, stop it from re-running migrations, and restart it cleanly on every deploy.
· 4 min read
Technical SEO for a Laravel and Livewire portfolio: what I actually implemented
A practical checklist from building this site: per-page meta, canonical URLs, Open Graph, JSON-LD, a dynamic sitemap and Search Console — with the code and the things I deliberately skipped.
· 4 min read