Nik-Blog, terraform

09: Infrastructure as Code — bringing the real S3/IAM setup under Terraform

09: Infrastructure as Code — bringing the real S3/IAM setup under Terraform

Note 08 covered the S3 bucket and IAM user for image uploads — all built by hand in the AWS console. This note covers bringing that same, already-running infrastructure under Terraform, without destroying or recreating anything, using terraform import.

Why import, not just writing the resources fresh

nikblog-images is a live bucket serving real images on the public site right now. If I'd written the Terraform code and run apply, it would have tried to create a bucket that already exists and failed — or worse, if I'd deleted the real one first to "start clean," every image URL already stored in the database would have broken. terraform import does the opposite: it reads the real, already-existing resource and records it into Terraform's state, without creating, changing, or deleting anything. The code only starts controlling the resource going forward, from this exact point.

Getting the real configuration first, not guessing it

Before writing any .tf code, pulled the actual live settings with the AWS CLI, under a new dedicated IAM user (nikblog-terraform) scoped to read-only:

aws s3api get-bucket-policy --bucket nikblog-images
aws s3api get-public-access-block --bucket nikblog-images

Got back the exact bucket policy JSON and the exact public-access-block flags (BlockPublicAcls: true, IgnorePublicAcls: true, BlockPublicPolicy: false, RestrictPublicBuckets: false) — the real "public read, no public write" shape from note 08, now confirmed byte-for-byte instead of remembered from when I set it up.

A dedicated, read-only IAM user for this — not reusing anything else

Created nikblog-terraform, separate from both nikblog-backend (the app's own upload credentials) and terraform-practice (my unrelated learning exercises). Its policy started scoped to individual Get* actions on just nikblog-images, but Terraform's own import process needed more read calls than I'd anticipated (HeadBucket for s3:ListBucket, then GetBucketCors, each surfacing as a real AccessDenied one at a time) — switched to s3:Get* + s3:List* wildcarded to just this one bucket ARN. Still fully read-only (no Put/Delete/Set actions anywhere in the policy), just not enumerated action-by-action, which is also the realistic way a "read-only" policy gets written on a real team.

The six resources, matched to the real settings exactly

resource "aws_s3_bucket" "images" {
  bucket = "nikblog-images"
}

resource "aws_s3_bucket_public_access_block" "images" {
  bucket = aws_s3_bucket.images.id

  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy     = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_policy" "images" {
  bucket = aws_s3_bucket.images.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "PublicReadGetObject"
        Effect    = "Allow"
        Principal = "*"
        Action    = "s3:GetObject"
        Resource  = "${aws_s3_bucket.images.arn}/*"
      }
    ]
  })
}

resource "aws_iam_user" "nikblog_backend" {
  name = "nikblog-backend"
}

resource "aws_iam_policy" "nikblog_backend_s3_write" {
  name        = "NikblogBackendS3Write"
  description = "Allows the nik-blog backend to upload and delete objects only in the nikblog-images S3 bucket."

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid      = "AllowObjectWriteOnNikblogImages"
        Effect   = "Allow"
        Action   = ["s3:PutObject", "s3:DeleteObject"]
        Resource = "${aws_s3_bucket.images.arn}/*"
      }
    ]
  })
}

resource "aws_iam_user_policy_attachment" "nikblog_backend_s3_write" {
  user       = aws_iam_user.nikblog_backend.name
  policy_arn = aws_iam_policy.nikblog_backend_s3_write.arn
}

Code lives in infra/terraform/main.tf. The provider block pins a specific profile = "nikblog-terraform" so this project always runs under its own dedicated, scoped credentials, never whatever happens to be the default AWS CLI profile at the time.

Importing, one resource at a time

terraform import aws_s3_bucket.images nikblog-images
terraform import aws_s3_bucket_public_access_block.images nikblog-images
terraform import aws_s3_bucket_policy.images nikblog-images
terraform import aws_iam_user.nikblog_backend nikblog-backend
terraform import aws_iam_policy.nikblog_backend_s3_write arn:aws:iam::654654323932:policy/NikblogBackendS3Write
terraform import aws_iam_user_policy_attachment.nikblog_backend_s3_write "nikblog-backend/arn:aws:iam::654654323932:policy/NikblogBackendS3Write"

Each import ID has its own shape depending on the resource type — a bucket name, a user name, a full policy ARN, or (for the attachment) a user/policy-arn pair.

The real wall, hit twice

First terraform import aws_s3_bucket.images failed with Cannot import non-existent remote object — a confusing error, since the bucket obviously exists. Root cause: Terraform's S3 provider calls HeadBucket internally to confirm the bucket is really there before importing, and HeadBucket needs s3:ListBucket, which the read-only policy didn't have yet (it only had Get* bucket-config actions, not ListBucket). Verified directly with aws s3api head-bucket — a real 403 Forbidden, confirming the cause before touching the policy.

After adding s3:ListBucket, the next import attempt failed differently: reading S3 Bucket (nikblog-images) CORS configuration ... AccessDenied: ... s3:GetBucketCORS. Rather than keep adding one specific action at a time as each new one surfaced, switched the policy to s3:Get* + s3:List* on the bucket — covers every read call the provider makes internally, still zero write access.

Verified clean before trusting any of it

$ terraform plan

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

This is the actual proof the import worked correctly — if the .tf code had been even slightly wrong (a missing Sid, a different action list, a wrong public-access-block flag), plan would have shown a diff, and I'd have stopped there rather than ever running apply. Zero differences means the code is now a completely accurate, version-controlled description of what's actually running — and from here on, any real change to this infrastructure goes through code and apply, not console clicks.