Skip to content

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.

Gohar Nawaz 4 min read
Terraform and AWS icons with the title Bringing existing AWS infrastructure under Terraform

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:

  1. terraform plan reports "No changes".
  2. Nothing was recreated and nothing went down.
  3. 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 ami that differs from what the instance was launched with.
  • A subnet_id or key_name that 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 plan before every apply, 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 destroy on 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; plan shows No changes.
  • Nothing is marked -/+ before you apply.
  • State and *.tfvars are out of Git, and state is backed up.
  • Credentials are scoped to this project.
  • You never run destroy against 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.