From 684d2180ef502f928fa7a7415845816c4533edbf Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:07:50 +0900 Subject: [PATCH 01/10] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=ED=98=84=ED=99=A9=EA=B3=BC=20Terraform=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/infrastructure/prod-inventory.md | 88 +++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/infrastructure/prod-inventory.md diff --git a/docs/infrastructure/prod-inventory.md b/docs/infrastructure/prod-inventory.md new file mode 100644 index 0000000..2550bd3 --- /dev/null +++ b/docs/infrastructure/prod-inventory.md @@ -0,0 +1,88 @@ +# Production Infrastructure Inventory + +## Purpose + +This document records the existing production infrastructure before it is +adopted by Terraform. It is an inventory, not an authorization to change AWS +resources. + +## Scope + +| Area | Existing resource | Terraform approach | +| --- | --- | --- | +| EC2 | `i-0b4fe94824ca7d3c7` (`moru-server`) | Import and manage | +| Elastic IP | `eipalloc-0423074c40c068102` | Import and manage | +| EC2 security group | `sg-0ff632924dad2adb6` (`moru-ec2-sg`) | Import and manage | +| RDS MySQL | `moru-db` | Import and manage | +| RDS security group | `sg-0056408c9dc50cef4` (`moru-rds-sg`) | Import and manage | +| S3 assets | `moru-prod-assets-488230509502` | Import and manage | +| S3 TTS previews | `moru-prod-preview-assets-488230509502` | Import and manage | +| EC2 IAM role/profile | `moru-server-s3-role` | Import and manage | +| CloudWatch Logs | `/moru/prod/app` | Import and manage | +| VPC | `vpc-087c2d76939191694` | Reference with data source | +| EC2 subnet | `subnet-0a94acc68b254e59f` | Reference with data source | +| RDS subnet group | `default-vpc-087c2d76939191694` | Reference with data source | + +The VPC and subnets are the account's default network and may be shared by +other workloads. They must not be imported or changed in this migration. + +## Observed Production Configuration + +### Compute and network + +- EC2: `t3.micro`, Amazon Linux AMI `ami-0e4ab31f1847c850c`, in + `ap-northeast-2c`. +- The instance uses the `moru-key` key pair, the `moru-server-s3-role` instance + profile, and an IMDSv2-required metadata configuration. +- Elastic IP `43.202.84.114` is associated with the instance. +- The application container publishes only to `127.0.0.1:8080`; Nginx remains + host-managed and proxies the public HTTP(S) traffic. + +### Database + +- RDS MySQL `moru-db`: MySQL `8.4.9`, `db.t4g.micro`, 20 GiB `gp2`, encrypted + storage, one-day backup retention, and no Multi-AZ deployment. +- The database name is `moru`. Credentials are intentionally excluded from this + repository and Terraform configuration. + +### Storage and logging + +- `moru-prod-assets-488230509502` stores private application assets. +- `moru-prod-preview-assets-488230509502` allows public reads only below + `tts/previews/*` and `tts/common/*` for preview and common TTS audio. +- Both production buckets use AES256 default encryption. Versioning is not + currently enabled. +- Docker uses the `awslogs` driver and writes to `/moru/prod/app`, which retains + logs for 14 days. No CloudWatch metric alarms are currently configured. +- The EC2 role has inline policies for the production and development asset + buckets. The production policy allows only the `tts/*` and `profiles/*` + prefixes and CloudWatch log writes. + +## Out of Scope for This Migration + +- Docker Compose services, the Redis container, and the GitHub Actions deploy + workflow remain unchanged. +- Nginx, Certbot, DuckDNS, and files on the EC2 host are documented only. They + are not changed or managed by Terraform in this phase. +- Database credentials, application secrets, and GCP credentials remain in the + existing secret-management flow. + +## Security Review Items + +These items were observed during the read-only audit. They are intentionally +not changed by the Terraform adoption work and need separate approval. + +1. The RDS instance is publicly accessible, and `moru-rds-sg` currently allows + all traffic from IPv4 and IPv6 addresses. +2. `moru-ec2-sg` allows SSH from every IPv4 address. +3. The production S3 buckets do not currently use versioning. This is distinct + from the planned Terraform state bucket, which must use versioning. + +## Adoption Safety Rules + +- Never run `terraform apply`, `terraform import`, or `terraform destroy` + without explicit approval. +- Do not accept a plan that replaces, creates, modifies, or destroys an + existing production resource during import. +- Keep Terraform state, plan files, credentials, and application secrets out of + Git. From 00ddb6b216c92d8bc6fc09d77d065dc0e715f0c1 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:16:15 +0900 Subject: [PATCH 02/10] =?UTF-8?q?chore:=20Terraform=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EC=86=8C=EC=99=80=20prod=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=B5=AC=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 ++ infra/README.md | 71 +++++++++++++++ infra/bootstrap/.terraform.lock.hcl | 26 ++++++ infra/bootstrap/main.tf | 131 ++++++++++++++++++++++++++++ infra/bootstrap/outputs.tf | 9 ++ infra/bootstrap/variables.tf | 29 ++++++ infra/bootstrap/versions.tf | 14 +++ infra/prod/.terraform.lock.hcl | 26 ++++++ infra/prod/backend.hcl.example | 5 ++ infra/prod/versions.tf | 16 ++++ 10 files changed, 336 insertions(+) create mode 100644 infra/README.md create mode 100644 infra/bootstrap/.terraform.lock.hcl create mode 100644 infra/bootstrap/main.tf create mode 100644 infra/bootstrap/outputs.tf create mode 100644 infra/bootstrap/variables.tf create mode 100644 infra/bootstrap/versions.tf create mode 100644 infra/prod/.terraform.lock.hcl create mode 100644 infra/prod/backend.hcl.example create mode 100644 infra/prod/versions.tf diff --git a/.gitignore b/.gitignore index 908bee1..c2451b0 100644 --- a/.gitignore +++ b/.gitignore @@ -215,6 +215,15 @@ fabric.properties hs_err_pid* replay_pid* +### Terraform ### +**/.terraform/ +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +!*.tfvars.example +infra/prod/backend.hcl + ### JetBrains ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..7990fa3 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,71 @@ +# Terraform Infrastructure + +`bootstrap` creates the dedicated S3 backend resources. It intentionally uses +local state for this one-time setup. `prod` manages the existing Moru +production infrastructure after its backend is configured. + +## Safety Boundary + +The current `moru-prod` AWS profile is read-only. It can run discovery, +`terraform fmt`, `terraform validate`, and a read-only import plan. It cannot +create the state bucket or import resources into remote state. + +Do not run `terraform apply`, `terraform import`, or `terraform destroy` until +the plan is reviewed and explicit approval is given. + +## State Backend + +The backend is deliberately separate from the existing application buckets. +The bootstrap configuration creates a private bucket with: + +- AES256 server-side encryption +- S3 bucket versioning for state recovery +- S3 lockfiles through `use_lockfile = true` +- public-access blocking and bucket-owner-enforced ownership +- a transport-security bucket policy +- an unattached least-privilege policy for the future Terraform operator + +HashiCorp recommends bucket versioning for S3 state recovery and S3 lockfiles +for state locking. DynamoDB locking is not used because it is deprecated by the +S3 backend. + +### Bootstrap after approval + +1. Sign in with a separately approved AWS identity that can create the backend + bucket and IAM policy. Do not use the root account. +2. Choose a globally unique state bucket name. The recommended candidate is + `moru-prod-terraform-state-488230509502`, subject to availability. +3. Run the following from `infra/bootstrap`: + + ```bash + terraform init + terraform plan -var='state_bucket_name=CHOSEN_BUCKET_NAME' + ``` + +4. Review that the plan only creates the state bucket resources and the + unattached operator policy. After explicit approval, run `terraform apply` + with the same variable. +5. Attach the created state-access policy only to the approved Terraform + operator identity. Keep the `moru_terraform` discovery user read-only. + +### Configure the production backend + +1. Copy `infra/prod/backend.hcl.example` to `infra/prod/backend.hcl`. +2. Replace the bucket name with the approved state bucket name. Do not commit + this file. +3. Run the following from `infra/prod`: + + ```bash + terraform init -backend-config=backend.hcl + terraform plan + ``` + +The first production plan should show imports only. Any planned create, update, +replacement, or destroy for an existing production resource is a stop signal. + +## Non-Terraform Host Configuration + +Docker Compose, Redis, GitHub Actions, Nginx, Certbot, DuckDNS, and EC2-hosted +files remain outside the Terraform scope in this phase. Their existing setup is +documented in the repository and on the production host; it must not be changed +as part of adoption. diff --git a/infra/bootstrap/.terraform.lock.hcl b/infra/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..d591841 --- /dev/null +++ b/infra/bootstrap/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.65.0" + constraints = "~> 6.0" + hashes = [ + "h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=", + "zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be", + "zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3", + "zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917", + "zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8", + "zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09", + "zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8", + "zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7", + "zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5", + "zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294", + "zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f", + "zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab", + "zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af", + "zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87", + "zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59", + ] +} diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf new file mode 100644 index 0000000..27664f2 --- /dev/null +++ b/infra/bootstrap/main.tf @@ -0,0 +1,131 @@ +locals { + common_tags = { + Environment = var.environment + ManagedBy = "Terraform" + Project = var.project + Purpose = "terraform-state" + } +} + +data "aws_iam_policy_document" "state_bucket" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + + resources = [ + "arn:aws:s3:::${var.state_bucket_name}", + "arn:aws:s3:::${var.state_bucket_name}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +data "aws_iam_policy_document" "state_operator" { + statement { + sid = "ListStatePrefix" + effect = "Allow" + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::${var.state_bucket_name}"] + + condition { + test = "StringLike" + variable = "s3:prefix" + values = [ + var.state_key, + "${var.state_key}.tflock", + ] + } + } + + statement { + sid = "ReadAndWriteState" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + ] + resources = ["arn:aws:s3:::${var.state_bucket_name}/${var.state_key}"] + } + + statement { + sid = "ManageStateLock" + effect = "Allow" + actions = [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject", + ] + resources = ["arn:aws:s3:::${var.state_bucket_name}/${var.state_key}.tflock"] + } +} + +resource "aws_s3_bucket" "terraform_state" { + bucket = var.state_bucket_name + force_destroy = false + tags = local.common_tags + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_versioning" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_policy" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + policy = data.aws_iam_policy_document.state_bucket.json +} + +resource "aws_iam_policy" "terraform_state_operator" { + name = "${var.project}-${var.environment}-terraform-state-access" + description = "Least-privilege access to the Moru production Terraform state and lockfile." + policy = data.aws_iam_policy_document.state_operator.json + tags = local.common_tags +} diff --git a/infra/bootstrap/outputs.tf b/infra/bootstrap/outputs.tf new file mode 100644 index 0000000..c7f55b2 --- /dev/null +++ b/infra/bootstrap/outputs.tf @@ -0,0 +1,9 @@ +output "state_bucket_name" { + description = "Name of the created Terraform state bucket." + value = aws_s3_bucket.terraform_state.id +} + +output "terraform_state_operator_policy_arn" { + description = "Attach this policy only to the approved Terraform operator identity." + value = aws_iam_policy.terraform_state_operator.arn +} diff --git a/infra/bootstrap/variables.tf b/infra/bootstrap/variables.tf new file mode 100644 index 0000000..7e5a7b5 --- /dev/null +++ b/infra/bootstrap/variables.tf @@ -0,0 +1,29 @@ +variable "aws_region" { + type = string + description = "AWS region for the Terraform state bucket." + default = "ap-northeast-2" +} + +variable "project" { + type = string + description = "Project tag for newly created backend resources." + default = "moru" +} + +variable "environment" { + type = string + description = "Environment tag for newly created backend resources." + default = "prod" +} + +variable "state_bucket_name" { + type = string + description = "Globally unique name for the dedicated Terraform state bucket." + nullable = false +} + +variable "state_key" { + type = string + description = "Object key used by the production Terraform backend." + default = "prod/terraform.tfstate" +} diff --git a/infra/bootstrap/versions.tf b/infra/bootstrap/versions.tf new file mode 100644 index 0000000..758b18e --- /dev/null +++ b/infra/bootstrap/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = "~> 1.15.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.aws_region +} diff --git a/infra/prod/.terraform.lock.hcl b/infra/prod/.terraform.lock.hcl new file mode 100644 index 0000000..d591841 --- /dev/null +++ b/infra/prod/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.65.0" + constraints = "~> 6.0" + hashes = [ + "h1:/VgIzAOR/v+p135IFsJjYT7q3pA24yE3lZGBV0Otqq0=", + "zh:15b5bd81119965363893197b3b6065bdf46888b93c536623fd113b5505c375be", + "zh:16409fd045116a31b28adce98fcaaa56a7c01487369713e4a2a04af1cfa96fa3", + "zh:422ea20ef4be8e5b942118d1da61cbde818cadb512ec1132306d65120f983917", + "zh:42cda6703a6a51585c2cb2b8b3ab3a7c80a3ee08838138be8ecaa6b97b1d74d8", + "zh:718a880d81bfd9af7e297ed3d7bf98d1febebe8b9ebe3333854a4c17f2c4de09", + "zh:74e538a8ff4ea27b2040be426cdd2b952725883f5b45c8c03b27eff7d82b40f8", + "zh:876bf62e56a41e0c7a514652e22a41246e7c1d67be3b2c1b68553fa3a8dae6d7", + "zh:8d571e06d78b91b28faa7fafee99dee920d4f7a50f07ec9cd21695a385bcc0d5", + "zh:93154e33f4cbd39825a92a230642082e7b2b8b058c27cf93588ee6824aa1c294", + "zh:935f9523c940dc5795ce8afac37aa8a2ed06c0012196ab39b1de2ea26acc129e", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9bffe18e907e04d98d7d6b9a6a4c2381448e365441df423e90e1eeaf3a79fd2f", + "zh:9ddfdefef226c8ff3c8de03d8df23ab3199fed9f0684618a62489e0e90a21cab", + "zh:9eab3abf041fe8e1ce5959fda9c20ddfaf331b7c29ff707e914451abce7841af", + "zh:ed749702f6c56b26a390a52bfd31d3bdf7f0af800bc16244276ca71d6f541e87", + "zh:f304df223a0bc3e840a806a5dffb75e6b2b75c879053dc4ebd0228484923ee59", + ] +} diff --git a/infra/prod/backend.hcl.example b/infra/prod/backend.hcl.example new file mode 100644 index 0000000..4eb3cf2 --- /dev/null +++ b/infra/prod/backend.hcl.example @@ -0,0 +1,5 @@ +bucket = "REPLACE_WITH_APPROVED_STATE_BUCKET" +key = "prod/terraform.tfstate" +region = "ap-northeast-2" +encrypt = true +use_lockfile = true diff --git a/infra/prod/versions.tf b/infra/prod/versions.tf new file mode 100644 index 0000000..d16a787 --- /dev/null +++ b/infra/prod/versions.tf @@ -0,0 +1,16 @@ +terraform { + required_version = "~> 1.15.0" + + backend "s3" {} + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" +} From 7c0e8278706330c637c9073574521706290c6cdf Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:18:16 +0900 Subject: [PATCH 03/10] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20EC2=20?= =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=EC=99=80=20IAM=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/prod/compute.tf | 48 +++++++++++++ infra/prod/data.tf | 16 +++++ infra/prod/ec2_security_group.tf | 63 +++++++++++++++++ infra/prod/iam.tf | 113 +++++++++++++++++++++++++++++++ infra/prod/variables.tf | 47 +++++++++++++ infra/prod/versions.tf | 2 +- 6 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 infra/prod/compute.tf create mode 100644 infra/prod/data.tf create mode 100644 infra/prod/ec2_security_group.tf create mode 100644 infra/prod/iam.tf create mode 100644 infra/prod/variables.tf diff --git a/infra/prod/compute.tf b/infra/prod/compute.tf new file mode 100644 index 0000000..88de452 --- /dev/null +++ b/infra/prod/compute.tf @@ -0,0 +1,48 @@ +resource "aws_instance" "moru_server" { + ami = "ami-0e4ab31f1847c850c" + instance_type = "t3.micro" + key_name = "moru-key" + subnet_id = data.aws_subnet.production_ec2.id + private_ip = "172.31.37.85" + vpc_security_group_ids = [aws_security_group.moru_ec2.id] + iam_instance_profile = aws_iam_instance_profile.moru_server.name + + ebs_optimized = true + monitoring = false + source_dest_check = true + + metadata_options { + http_endpoint = "enabled" + http_protocol_ipv6 = "disabled" + http_put_response_hop_limit = 2 + http_tokens = "required" + instance_metadata_tags = "disabled" + } + + root_block_device { + delete_on_termination = true + encrypted = true + iops = 3000 + kms_key_id = "arn:aws:kms:ap-northeast-2:488230509502:key/83e16ab6-e2c5-4a75-be69-f0af42d60e8b" + throughput = 125 + volume_size = 20 + volume_type = "gp3" + } + + tags = { + Name = "moru-server" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_eip" "moru_server" { + domain = "vpc" + instance = aws_instance.moru_server.id + + lifecycle { + prevent_destroy = true + } +} diff --git a/infra/prod/data.tf b/infra/prod/data.tf new file mode 100644 index 0000000..cee9d2f --- /dev/null +++ b/infra/prod/data.tf @@ -0,0 +1,16 @@ +data "aws_caller_identity" "current" {} + +data "aws_vpc" "existing" { + id = var.vpc_id +} + +data "aws_subnet" "production_ec2" { + id = var.ec2_subnet_id +} + +check "expected_aws_account" { + assert { + condition = data.aws_caller_identity.current.account_id == var.aws_account_id + error_message = "This configuration is restricted to AWS account ${var.aws_account_id}." + } +} diff --git a/infra/prod/ec2_security_group.tf b/infra/prod/ec2_security_group.tf new file mode 100644 index 0000000..91aaf5e --- /dev/null +++ b/infra/prod/ec2_security_group.tf @@ -0,0 +1,63 @@ +resource "aws_security_group" "moru_ec2" { + name = "moru-ec2-sg" + description = "launch-wizard-2 created 2026-07-12T14:22:52.206Z" + vpc_id = data.aws_vpc.existing.id + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_ssh_ipv4" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 22 + ip_protocol = "tcp" + to_port = 22 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_http_ipv4" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 80 + ip_protocol = "tcp" + to_port = 80 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_http_ipv6" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv6 = "::/0" + from_port = 80 + ip_protocol = "tcp" + to_port = 80 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_https_ipv4" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "0.0.0.0/0" + from_port = 443 + ip_protocol = "tcp" + to_port = 443 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_https_ipv6" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv6 = "::/0" + from_port = 443 + ip_protocol = "tcp" + to_port = 443 +} + +resource "aws_vpc_security_group_ingress_rule" "moru_ec2_application_test" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "58.29.146.38/32" + from_port = 8080 + ip_protocol = "tcp" + to_port = 8080 +} + +resource "aws_vpc_security_group_egress_rule" "moru_ec2_all_ipv4" { + security_group_id = aws_security_group.moru_ec2.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} diff --git a/infra/prod/iam.tf b/infra/prod/iam.tf new file mode 100644 index 0000000..1eb7589 --- /dev/null +++ b/infra/prod/iam.tf @@ -0,0 +1,113 @@ +data "aws_iam_policy_document" "moru_server_assume_role" { + statement { + effect = "Allow" + + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + + actions = ["sts:AssumeRole"] + } +} + +data "aws_iam_policy_document" "moru_dev_assets" { + statement { + sid = "AccessMoruAssetBucket" + effect = "Allow" + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::moru-dev-assets-488230509502"] + + condition { + test = "StringLike" + variable = "s3:prefix" + values = ["tts/*", "profiles/*"] + } + } + + statement { + sid = "ManageMoruAssetObjects" + effect = "Allow" + actions = [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject", + ] + resources = [ + "arn:aws:s3:::moru-dev-assets-488230509502/tts/*", + "arn:aws:s3:::moru-dev-assets-488230509502/profiles/*", + ] + } +} + +data "aws_iam_policy_document" "moru_prod_assets" { + statement { + sid = "AccessMoruAssetBucket" + effect = "Allow" + actions = ["s3:ListBucket"] + resources = ["arn:aws:s3:::moru-prod-assets-488230509502"] + + condition { + test = "StringLike" + variable = "s3:prefix" + values = ["tts/*", "profiles/*"] + } + } + + statement { + sid = "ManageMoruAssetObjects" + effect = "Allow" + actions = [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject", + ] + resources = [ + "arn:aws:s3:::moru-prod-assets-488230509502/tts/*", + "arn:aws:s3:::moru-prod-assets-488230509502/profiles/*", + ] + } + + statement { + sid = "WriteMoruApplicationLogs" + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:DescribeLogStreams", + "logs:PutLogEvents", + ] + resources = ["arn:aws:logs:${var.aws_region}:${var.aws_account_id}:log-group:/moru/prod/app:*"] + } +} + +resource "aws_iam_role" "moru_server" { + name = var.ec2_iam_role_name + description = "Allows EC2 instances to call AWS services on your behalf." + assume_role_policy = data.aws_iam_policy_document.moru_server_assume_role.json + max_session_duration = 3600 + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_iam_role_policy" "moru_dev_assets" { + name = "moru-dev-assets-policy" + role = aws_iam_role.moru_server.id + policy = data.aws_iam_policy_document.moru_dev_assets.json +} + +resource "aws_iam_role_policy" "moru_prod_assets" { + name = "moru-prod-assets-policy" + role = aws_iam_role.moru_server.id + policy = data.aws_iam_policy_document.moru_prod_assets.json +} + +resource "aws_iam_instance_profile" "moru_server" { + name = "moru-server-s3-role" + role = aws_iam_role.moru_server.name + + lifecycle { + prevent_destroy = true + } +} diff --git a/infra/prod/variables.tf b/infra/prod/variables.tf new file mode 100644 index 0000000..e38e159 --- /dev/null +++ b/infra/prod/variables.tf @@ -0,0 +1,47 @@ +variable "aws_region" { + type = string + description = "Region that contains the existing production resources." + default = "ap-northeast-2" +} + +variable "aws_account_id" { + type = string + description = "AWS account that owns the existing production resources." + default = "488230509502" +} + +variable "vpc_id" { + type = string + description = "Existing default VPC. Terraform must only reference it." + default = "vpc-087c2d76939191694" +} + +variable "ec2_subnet_id" { + type = string + description = "Existing default public subnet used by the production EC2 instance." + default = "subnet-0a94acc68b254e59f" +} + +variable "ec2_instance_id" { + type = string + description = "Existing Moru production EC2 instance ID." + default = "i-0b4fe94824ca7d3c7" +} + +variable "ec2_eip_allocation_id" { + type = string + description = "Existing Elastic IP allocation associated with the production EC2 instance." + default = "eipalloc-0423074c40c068102" +} + +variable "ec2_security_group_id" { + type = string + description = "Existing security group attached to the production EC2 instance." + default = "sg-0ff632924dad2adb6" +} + +variable "ec2_iam_role_name" { + type = string + description = "Existing EC2 role that grants S3 and CloudWatch access." + default = "moru-server-s3-role" +} diff --git a/infra/prod/versions.tf b/infra/prod/versions.tf index d16a787..5bb76dd 100644 --- a/infra/prod/versions.tf +++ b/infra/prod/versions.tf @@ -12,5 +12,5 @@ terraform { } provider "aws" { - region = "ap-northeast-2" + region = var.aws_region } From e2c08deaebff943399f4e085b46531b22e10e456 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:20:11 +0900 Subject: [PATCH 04/10] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20RDS=20?= =?UTF-8?q?=EB=A6=AC=EC=86=8C=EC=8A=A4=EC=99=80=20DB=20=EB=B3=B4=EC=95=88?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/prod/data.tf | 4 ++ infra/prod/rds.tf | 104 +++++++++++++++++++++++++++++++ infra/prod/rds_security_group.tf | 27 ++++++++ infra/prod/variables.tf | 18 ++++++ 4 files changed, 153 insertions(+) create mode 100644 infra/prod/rds.tf create mode 100644 infra/prod/rds_security_group.tf diff --git a/infra/prod/data.tf b/infra/prod/data.tf index cee9d2f..9afd242 100644 --- a/infra/prod/data.tf +++ b/infra/prod/data.tf @@ -8,6 +8,10 @@ data "aws_subnet" "production_ec2" { id = var.ec2_subnet_id } +data "aws_db_subnet_group" "existing" { + name = var.rds_subnet_group_name +} + check "expected_aws_account" { assert { condition = data.aws_caller_identity.current.account_id == var.aws_account_id diff --git a/infra/prod/rds.tf b/infra/prod/rds.tf new file mode 100644 index 0000000..6a73d4d --- /dev/null +++ b/infra/prod/rds.tf @@ -0,0 +1,104 @@ +resource "aws_db_parameter_group" "moru" { + name = "korean" + family = "mysql8.4" + description = "korean setting" + + parameter { + name = "character_set_client" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_connection" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_database" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_filesystem" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_results" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "character_set_server" + value = "utf8mb4" + apply_method = "immediate" + } + + parameter { + name = "collation_connection" + value = "utf8mb4_general_ci" + apply_method = "immediate" + } + + parameter { + name = "collation_server" + value = "utf8mb4_general_ci" + apply_method = "immediate" + } + + parameter { + name = "max_connections" + value = "150" + apply_method = "immediate" + } + + parameter { + name = "time_zone" + value = "Asia/Seoul" + apply_method = "immediate" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_db_instance" "moru" { + identifier = var.rds_instance_identifier + engine = "mysql" + engine_version = "8.4.9" + instance_class = "db.t4g.micro" + db_name = "moru" + port = 3306 + + allocated_storage = 20 + storage_type = "gp2" + storage_encrypted = true + + availability_zone = "ap-northeast-2a" + db_subnet_group_name = data.aws_db_subnet_group.existing.name + vpc_security_group_ids = [aws_security_group.moru_rds.id] + parameter_group_name = aws_db_parameter_group.moru.name + option_group_name = "default:mysql-8-4" + network_type = "IPV4" + publicly_accessible = true + multi_az = false + deletion_protection = false + copy_tags_to_snapshot = true + iam_database_authentication_enabled = false + + auto_minor_version_upgrade = true + backup_retention_period = 1 + backup_window = "16:19-16:49" + maintenance_window = "sun:14:52-sun:15:22" + apply_immediately = false + + lifecycle { + prevent_destroy = true + } +} diff --git a/infra/prod/rds_security_group.tf b/infra/prod/rds_security_group.tf new file mode 100644 index 0000000..5096a97 --- /dev/null +++ b/infra/prod/rds_security_group.tf @@ -0,0 +1,27 @@ +resource "aws_security_group" "moru_rds" { + name = "moru-rds-sg" + description = "Created by RDS management console" + vpc_id = data.aws_vpc.existing.id + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_vpc_security_group_ingress_rule" "moru_rds_all_ipv4" { + security_group_id = aws_security_group.moru_rds.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} + +resource "aws_vpc_security_group_ingress_rule" "moru_rds_all_ipv6" { + security_group_id = aws_security_group.moru_rds.id + cidr_ipv6 = "::/0" + ip_protocol = "-1" +} + +resource "aws_vpc_security_group_egress_rule" "moru_rds_all_ipv4" { + security_group_id = aws_security_group.moru_rds.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "-1" +} diff --git a/infra/prod/variables.tf b/infra/prod/variables.tf index e38e159..74739f2 100644 --- a/infra/prod/variables.tf +++ b/infra/prod/variables.tf @@ -45,3 +45,21 @@ variable "ec2_iam_role_name" { description = "Existing EC2 role that grants S3 and CloudWatch access." default = "moru-server-s3-role" } + +variable "rds_instance_identifier" { + type = string + description = "Existing production RDS instance identifier." + default = "moru-db" +} + +variable "rds_security_group_id" { + type = string + description = "Existing security group attached to the production RDS instance." + default = "sg-0056408c9dc50cef4" +} + +variable "rds_subnet_group_name" { + type = string + description = "Existing default DB subnet group. Terraform must only reference it." + default = "default-vpc-087c2d76939191694" +} From 85206ff860238b4ad761b6e6691c04c9b021e429 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:21:14 +0900 Subject: [PATCH 05/10] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20S3=EC=99=80?= =?UTF-8?q?=20CloudWatch=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/prod/observability.tf | 8 +++ infra/prod/storage.tf | 99 +++++++++++++++++++++++++++++++++++++ infra/prod/variables.tf | 18 +++++++ 3 files changed, 125 insertions(+) create mode 100644 infra/prod/observability.tf create mode 100644 infra/prod/storage.tf diff --git a/infra/prod/observability.tf b/infra/prod/observability.tf new file mode 100644 index 0000000..853b8c8 --- /dev/null +++ b/infra/prod/observability.tf @@ -0,0 +1,8 @@ +resource "aws_cloudwatch_log_group" "application" { + name = var.application_log_group_name + retention_in_days = 14 + + lifecycle { + prevent_destroy = true + } +} diff --git a/infra/prod/storage.tf b/infra/prod/storage.tf new file mode 100644 index 0000000..ea0b063 --- /dev/null +++ b/infra/prod/storage.tf @@ -0,0 +1,99 @@ +resource "aws_s3_bucket" "production_assets" { + bucket = var.production_assets_bucket_name + force_destroy = false + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "production_assets" { + bucket = aws_s3_bucket.production_assets.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "production_assets" { + bucket = aws_s3_bucket.production_assets.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "production_assets" { + bucket = aws_s3_bucket.production_assets.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + + bucket_key_enabled = true + } +} + +data "aws_iam_policy_document" "production_preview_public_read" { + statement { + sid = "AllowPublicReadTtsAudio" + effect = "Allow" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:GetObject"] + + resources = [ + "arn:aws:s3:::${var.production_preview_bucket_name}/tts/previews/*", + "arn:aws:s3:::${var.production_preview_bucket_name}/tts/common/*", + ] + } +} + +resource "aws_s3_bucket" "production_preview" { + bucket = var.production_preview_bucket_name + force_destroy = false + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "production_preview" { + bucket = aws_s3_bucket.production_preview.id + + block_public_acls = true + block_public_policy = false + ignore_public_acls = true + restrict_public_buckets = false +} + +resource "aws_s3_bucket_ownership_controls" "production_preview" { + bucket = aws_s3_bucket.production_preview.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "production_preview" { + bucket = aws_s3_bucket.production_preview.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_policy" "production_preview" { + bucket = aws_s3_bucket.production_preview.id + policy = data.aws_iam_policy_document.production_preview_public_read.json +} diff --git a/infra/prod/variables.tf b/infra/prod/variables.tf index 74739f2..28e0dea 100644 --- a/infra/prod/variables.tf +++ b/infra/prod/variables.tf @@ -63,3 +63,21 @@ variable "rds_subnet_group_name" { description = "Existing default DB subnet group. Terraform must only reference it." default = "default-vpc-087c2d76939191694" } + +variable "production_assets_bucket_name" { + type = string + description = "Existing private production assets bucket." + default = "moru-prod-assets-488230509502" +} + +variable "production_preview_bucket_name" { + type = string + description = "Existing production TTS preview bucket." + default = "moru-prod-preview-assets-488230509502" +} + +variable "application_log_group_name" { + type = string + description = "Existing CloudWatch log group used by Docker awslogs." + default = "/moru/prod/app" +} From 4fa92abeb36065125a1541bf5bab12fca7afedb8 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 15:26:20 +0900 Subject: [PATCH 06/10] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20import=20=EC=A0=88=EC=B0=A8=EC=99=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../terraform-import-runbook.md | 109 +++++++++++++ infra/README.md | 9 +- infra/prod/backend.tf.example | 3 + infra/prod/iam.tf | 2 - infra/prod/imports.tf | 149 ++++++++++++++++++ infra/prod/rds.tf | 1 + infra/prod/versions.tf | 2 - 8 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 docs/infrastructure/terraform-import-runbook.md create mode 100644 infra/prod/backend.tf.example create mode 100644 infra/prod/imports.tf diff --git a/.gitignore b/.gitignore index c2451b0..fda3f1b 100644 --- a/.gitignore +++ b/.gitignore @@ -223,6 +223,7 @@ replay_pid* *.tfvars !*.tfvars.example infra/prod/backend.hcl +infra/prod/backend.tf ### JetBrains ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider diff --git a/docs/infrastructure/terraform-import-runbook.md b/docs/infrastructure/terraform-import-runbook.md new file mode 100644 index 0000000..466d286 --- /dev/null +++ b/docs/infrastructure/terraform-import-runbook.md @@ -0,0 +1,109 @@ +# Production Terraform Import Runbook + +## Purpose + +This runbook adopts existing Moru production AWS resources into Terraform +state. It must not create, update, replace, or delete an application resource. +The configuration uses declarative `import` blocks, so the import is reviewed +through the normal `plan` and `apply` workflow. + +## Preconditions + +1. The Terraform state bucket has been created from `infra/bootstrap` after a + separate approval. +2. The approved Terraform operator has the state-access policy created by the + bootstrap configuration, plus only the AWS permissions needed to manage the + imported resources. +3. The `moru_terraform` discovery user remains read-only. Do not use it for + import or apply. +4. No concurrent Terraform operation is running for prod. +5. The production service is healthy before beginning. Record the current + health result and do not restart Docker, Nginx, or EC2 as part of this work. + +## Import Order + +The import blocks in `infra/prod/imports.tf` cover the following existing +resources: + +1. EC2 security group and its seven rules +2. EC2 IAM role, two inline policies, and instance profile +3. EC2 instance and its Elastic IP +4. RDS security group and its three rules +5. RDS parameter group and MySQL instance +6. Private production assets S3 bucket and its security settings +7. Public TTS preview S3 bucket, its security settings, and its public-read + bucket policy +8. CloudWatch application log group + +The default VPC, default subnets, default DB subnet group, Docker Compose, +Redis, GitHub Actions, Nginx, Certbot, DuckDNS, and EC2-hosted files are not +imported in this phase. + +## Read-Only Validation + +The following commands do not write Terraform state and are safe to run with +the `moru_terraform` read-only profile after login: + +```bash +cd infra/prod +AWS_PROFILE=moru-prod terraform init -reconfigure +AWS_PROFILE=moru-prod terraform validate +AWS_PROFILE=moru-prod terraform plan -lock=false +``` + +The expected result is a plan containing imports only. Stop immediately when +the plan includes an action other than `import` for an existing resource. + +## Approved Import Procedure + +1. Use the approved Terraform operator profile and configure + `infra/prod/backend.tf` and `infra/prod/backend.hcl` from the committed + examples. +2. Initialize the remote state backend: + + ```bash + terraform init -reconfigure -backend-config=backend.hcl + ``` + +3. Produce a reviewable plan: + + ```bash + terraform plan -out=prod-import.tfplan -lock-timeout=5m + terraform show -no-color prod-import.tfplan + ``` + +4. Confirm every action is an import and no existing resource has a planned + update, replacement, or deletion. Obtain explicit approval for the exact + plan. +5. Apply the reviewed plan once: + + ```bash + terraform apply prod-import.tfplan + ``` + +6. Verify Terraform now reports no drift: + + ```bash + terraform plan -lock-timeout=5m + terraform state list + ``` + +7. Remove the local `prod-import.tfplan` file. It is ignored by Git and must + never be committed. + +## Stop Conditions + +Stop and investigate before an apply when any of the following occurs: + +- A plan proposes a create, change, replace, or destroy action for an existing + production resource. +- The AWS account check is not `488230509502`. +- The state bucket cannot be encrypted, versioned, or locked. +- A resource ID differs from `docs/infrastructure/prod-inventory.md`. +- The application health check fails before or during the activity. + +## Post-Import Follow-Up + +The open RDS and SSH network rules are documented security findings, not an +incidental Terraform migration change. Address them in a separate, reviewed +security issue with an explicit connectivity test and rollback procedure. diff --git a/infra/README.md b/infra/README.md index 7990fa3..d1005c0 100644 --- a/infra/README.md +++ b/infra/README.md @@ -50,9 +50,10 @@ S3 backend. ### Configure the production backend -1. Copy `infra/prod/backend.hcl.example` to `infra/prod/backend.hcl`. +1. Copy `infra/prod/backend.tf.example` to `infra/prod/backend.tf` and copy + `infra/prod/backend.hcl.example` to `infra/prod/backend.hcl`. 2. Replace the bucket name with the approved state bucket name. Do not commit - this file. + either file. 3. Run the following from `infra/prod`: ```bash @@ -63,6 +64,10 @@ S3 backend. The first production plan should show imports only. Any planned create, update, replacement, or destroy for an existing production resource is a stop signal. +Before the state bucket exists, leave `backend.tf` absent and use Terraform's +local backend only for read-only validation. Local state and plan files are +ignored by Git and must never be committed. + ## Non-Terraform Host Configuration Docker Compose, Redis, GitHub Actions, Nginx, Certbot, DuckDNS, and EC2-hosted diff --git a/infra/prod/backend.tf.example b/infra/prod/backend.tf.example new file mode 100644 index 0000000..12c0dbe --- /dev/null +++ b/infra/prod/backend.tf.example @@ -0,0 +1,3 @@ +terraform { + backend "s3" {} +} diff --git a/infra/prod/iam.tf b/infra/prod/iam.tf index 1eb7589..58c5f46 100644 --- a/infra/prod/iam.tf +++ b/infra/prod/iam.tf @@ -13,7 +13,6 @@ data "aws_iam_policy_document" "moru_server_assume_role" { data "aws_iam_policy_document" "moru_dev_assets" { statement { - sid = "AccessMoruAssetBucket" effect = "Allow" actions = ["s3:ListBucket"] resources = ["arn:aws:s3:::moru-dev-assets-488230509502"] @@ -26,7 +25,6 @@ data "aws_iam_policy_document" "moru_dev_assets" { } statement { - sid = "ManageMoruAssetObjects" effect = "Allow" actions = [ "s3:DeleteObject", diff --git a/infra/prod/imports.tf b/infra/prod/imports.tf new file mode 100644 index 0000000..0711f30 --- /dev/null +++ b/infra/prod/imports.tf @@ -0,0 +1,149 @@ +import { + to = aws_security_group.moru_ec2 + id = var.ec2_security_group_id +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_ssh_ipv4 + id = "sgr-004e04b488a69a503" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_http_ipv4 + id = "sgr-01059589aaad7c6a1" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_http_ipv6 + id = "sgr-03bb52b4064251e44" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_https_ipv4 + id = "sgr-0a3f486457056be64" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_https_ipv6 + id = "sgr-0e861ece375b623f2" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_ec2_application_test + id = "sgr-0f864a686e6cd7576" +} + +import { + to = aws_vpc_security_group_egress_rule.moru_ec2_all_ipv4 + id = "sgr-0a32ac6e70b28569a" +} + +import { + to = aws_iam_role.moru_server + id = var.ec2_iam_role_name +} + +import { + to = aws_iam_role_policy.moru_dev_assets + id = "moru-server-s3-role:moru-dev-assets-policy" +} + +import { + to = aws_iam_role_policy.moru_prod_assets + id = "moru-server-s3-role:moru-prod-assets-policy" +} + +import { + to = aws_iam_instance_profile.moru_server + id = "moru-server-s3-role" +} + +import { + to = aws_instance.moru_server + id = var.ec2_instance_id +} + +import { + to = aws_eip.moru_server + id = var.ec2_eip_allocation_id +} + +import { + to = aws_security_group.moru_rds + id = var.rds_security_group_id +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_rds_all_ipv4 + id = "sgr-0c65f28523cfde22d" +} + +import { + to = aws_vpc_security_group_ingress_rule.moru_rds_all_ipv6 + id = "sgr-0d3eba8272521a11d" +} + +import { + to = aws_vpc_security_group_egress_rule.moru_rds_all_ipv4 + id = "sgr-010c3ce6d12b7f436" +} + +import { + to = aws_db_parameter_group.moru + id = "korean" +} + +import { + to = aws_db_instance.moru + id = var.rds_instance_identifier +} + +import { + to = aws_s3_bucket.production_assets + id = var.production_assets_bucket_name +} + +import { + to = aws_s3_bucket_public_access_block.production_assets + id = var.production_assets_bucket_name +} + +import { + to = aws_s3_bucket_ownership_controls.production_assets + id = var.production_assets_bucket_name +} + +import { + to = aws_s3_bucket_server_side_encryption_configuration.production_assets + id = var.production_assets_bucket_name +} + +import { + to = aws_s3_bucket.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_s3_bucket_public_access_block.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_s3_bucket_ownership_controls.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_s3_bucket_server_side_encryption_configuration.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_s3_bucket_policy.production_preview + id = var.production_preview_bucket_name +} + +import { + to = aws_cloudwatch_log_group.application + id = var.application_log_group_name +} diff --git a/infra/prod/rds.tf b/infra/prod/rds.tf index 6a73d4d..64dbb45 100644 --- a/infra/prod/rds.tf +++ b/infra/prod/rds.tf @@ -97,6 +97,7 @@ resource "aws_db_instance" "moru" { backup_window = "16:19-16:49" maintenance_window = "sun:14:52-sun:15:22" apply_immediately = false + skip_final_snapshot = true lifecycle { prevent_destroy = true diff --git a/infra/prod/versions.tf b/infra/prod/versions.tf index 5bb76dd..758b18e 100644 --- a/infra/prod/versions.tf +++ b/infra/prod/versions.tf @@ -1,8 +1,6 @@ terraform { required_version = "~> 1.15.0" - backend "s3" {} - required_providers { aws = { source = "hashicorp/aws" From 8770d9ed08f486af8aa159096eebca81def2c5b7 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 20 Sep 2026 16:49:42 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/infrastructure/terraform-import-runbook.md | 3 ++- infra/prod/data.tf | 16 ++++++++-------- infra/prod/versions.tf | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/infrastructure/terraform-import-runbook.md b/docs/infrastructure/terraform-import-runbook.md index 466d286..32e15d2 100644 --- a/docs/infrastructure/terraform-import-runbook.md +++ b/docs/infrastructure/terraform-import-runbook.md @@ -42,7 +42,8 @@ imported in this phase. ## Read-Only Validation The following commands do not write Terraform state and are safe to run with -the `moru_terraform` read-only profile after login: +the `moru-prod` read-only profile after login (`moru_terraform` is the +read-only discovery user, not the profile name): ```bash cd infra/prod diff --git a/infra/prod/data.tf b/infra/prod/data.tf index 9afd242..f8bc470 100644 --- a/infra/prod/data.tf +++ b/infra/prod/data.tf @@ -1,4 +1,11 @@ -data "aws_caller_identity" "current" {} +data "aws_caller_identity" "current" { + lifecycle { + postcondition { + condition = self.account_id == var.aws_account_id + error_message = "This configuration is restricted to AWS account ${var.aws_account_id}." + } + } +} data "aws_vpc" "existing" { id = var.vpc_id @@ -11,10 +18,3 @@ data "aws_subnet" "production_ec2" { data "aws_db_subnet_group" "existing" { name = var.rds_subnet_group_name } - -check "expected_aws_account" { - assert { - condition = data.aws_caller_identity.current.account_id == var.aws_account_id - error_message = "This configuration is restricted to AWS account ${var.aws_account_id}." - } -} diff --git a/infra/prod/versions.tf b/infra/prod/versions.tf index 758b18e..9d1b4fd 100644 --- a/infra/prod/versions.tf +++ b/infra/prod/versions.tf @@ -10,5 +10,6 @@ terraform { } provider "aws" { - region = var.aws_region + region = var.aws_region + allowed_account_ids = ["488230509502"] } From 6052a1db0e33525e2b2bbffba2cd6a7760de5285 Mon Sep 17 00:00:00 2001 From: issuejong Date: Wed, 23 Sep 2026 17:42:20 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20Terraform=20state=20workspace=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B6=8C=ED=95=9C=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/bootstrap/main.tf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf index 27664f2..5bc4b23 100644 --- a/infra/bootstrap/main.tf +++ b/infra/bootstrap/main.tf @@ -45,6 +45,8 @@ data "aws_iam_policy_document" "state_operator" { values = [ var.state_key, "${var.state_key}.tflock", + "env:/", + "env:/*", ] } } From 91bab9d98129146de52181ace5b1f49fa759b37f Mon Sep 17 00:00:00 2001 From: issuejong Date: Wed, 23 Sep 2026 18:08:45 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20Terraform=20bootstrap=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=A0=9C=ED=95=9C=EA=B3=BC=20S3=20=EC=95=94?= =?UTF-8?q?=ED=98=B8=ED=99=94=20=EC=84=A4=EC=A0=95=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/bootstrap/main.tf | 2 -- infra/bootstrap/variables.tf | 6 ++++++ infra/bootstrap/versions.tf | 3 ++- infra/prod/storage.tf | 4 ---- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf index 5bc4b23..fac967b 100644 --- a/infra/bootstrap/main.tf +++ b/infra/bootstrap/main.tf @@ -107,8 +107,6 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" apply_server_side_encryption_by_default { sse_algorithm = "AES256" } - - bucket_key_enabled = true } } diff --git a/infra/bootstrap/variables.tf b/infra/bootstrap/variables.tf index 7e5a7b5..8a2c621 100644 --- a/infra/bootstrap/variables.tf +++ b/infra/bootstrap/variables.tf @@ -4,6 +4,12 @@ variable "aws_region" { default = "ap-northeast-2" } +variable "aws_account_id" { + type = string + description = "AWS account that owns the production Terraform state bucket." + default = "488230509502" +} + variable "project" { type = string description = "Project tag for newly created backend resources." diff --git a/infra/bootstrap/versions.tf b/infra/bootstrap/versions.tf index 758b18e..e5e6961 100644 --- a/infra/bootstrap/versions.tf +++ b/infra/bootstrap/versions.tf @@ -10,5 +10,6 @@ terraform { } provider "aws" { - region = var.aws_region + region = var.aws_region + allowed_account_ids = [var.aws_account_id] } diff --git a/infra/prod/storage.tf b/infra/prod/storage.tf index ea0b063..cba6460 100644 --- a/infra/prod/storage.tf +++ b/infra/prod/storage.tf @@ -31,8 +31,6 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "production_assets apply_server_side_encryption_by_default { sse_algorithm = "AES256" } - - bucket_key_enabled = true } } @@ -88,8 +86,6 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "production_previe apply_server_side_encryption_by_default { sse_algorithm = "AES256" } - - bucket_key_enabled = true } } From 9e4b8bf977c4bdb497e4d4cb9248a725d4b9c096 Mon Sep 17 00:00:00 2001 From: issuejong Date: Fri, 25 Sep 2026 21:19:48 +0900 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20Terraform=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=99=84=EB=A3=8C=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=88=EC=B0=A8=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../terraform-import-runbook.md | 52 +++++--- infra/README.md | 123 +++++++----------- 2 files changed, 85 insertions(+), 90 deletions(-) diff --git a/docs/infrastructure/terraform-import-runbook.md b/docs/infrastructure/terraform-import-runbook.md index 32e15d2..ae4788c 100644 --- a/docs/infrastructure/terraform-import-runbook.md +++ b/docs/infrastructure/terraform-import-runbook.md @@ -7,15 +7,27 @@ state. It must not create, update, replace, or delete an application resource. The configuration uses declarative `import` blocks, so the import is reviewed through the normal `plan` and `apply` workflow. -## Preconditions +## Completion Record + +The production import was completed on 2026-09-24 using the `moru-prod` AWS +profile in account `488230509502`. The reviewed plan contained 30 imports and +no additions, changes, or deletions. All 30 managed resources appeared in the +remote state afterward, and a fresh plan reported `No changes`. + +The backend is the private `moru-prod-terraform-state-488230509502` bucket, +with state at `prod/terraform.tfstate`. Do not reapply the saved import plan or +repeat this procedure against the populated state. For future changes, follow +the approval workflow in [infra/README.md](../../infra/README.md). + +## Historical Preconditions 1. The Terraform state bucket has been created from `infra/bootstrap` after a separate approval. -2. The approved Terraform operator has the state-access policy created by the - bootstrap configuration, plus only the AWS permissions needed to manage the - imported resources. -3. The `moru_terraform` discovery user remains read-only. Do not use it for - import or apply. +2. The `moru_terraform` IAM user has production read access and the dedicated + state-access policy. This allowed the import-only apply to update state + without changing the imported AWS resources. +3. Future infrastructure changes require separately approved, scoped AWS + write permissions; the state-access policy alone is not sufficient. 4. No concurrent Terraform operation is running for prod. 5. The production service is healthy before beginning. Record the current health result and do not restart Docker, Nginx, or EC2 as part of this work. @@ -39,23 +51,25 @@ The default VPC, default subnets, default DB subnet group, Docker Compose, Redis, GitHub Actions, Nginx, Certbot, DuckDNS, and EC2-hosted files are not imported in this phase. -## Read-Only Validation +## Current Validation -The following commands do not write Terraform state and are safe to run with -the `moru-prod` read-only profile after login (`moru_terraform` is the -read-only discovery user, not the profile name): +The following commands validate the configuration and check for drift using +the `moru-prod` profile after login: ```bash cd infra/prod -AWS_PROFILE=moru-prod terraform init -reconfigure +AWS_PROFILE=moru-prod terraform init -reconfigure -backend-config=backend.hcl AWS_PROFILE=moru-prod terraform validate -AWS_PROFILE=moru-prod terraform plan -lock=false +AWS_PROFILE=moru-prod terraform plan -lock-timeout=5m ``` -The expected result is a plan containing imports only. Stop immediately when -the plan includes an action other than `import` for an existing resource. +The expected result now is `No changes`. Any proposed create, update, +replacement, or deletion needs investigation and approval before an apply. -## Approved Import Procedure +## Historical Import Procedure (Completed) + +The steps below record the one-time migration. Do not run them again for this +production state. 1. Use the approved Terraform operator profile and configure `infra/prod/backend.tf` and `infra/prod/backend.hcl` from the committed @@ -89,8 +103,8 @@ the plan includes an action other than `import` for an existing resource. terraform state list ``` -7. Remove the local `prod-import.tfplan` file. It is ignored by Git and must - never be committed. +7. Keep the local `prod-import.tfplan` file out of Git and remove it when no + longer needed; saved plans can contain sensitive values. ## Stop Conditions @@ -105,6 +119,10 @@ Stop and investigate before an apply when any of the following occurs: ## Post-Import Follow-Up +Keep an access-controlled backup of the local +`infra/bootstrap/terraform.tfstate`, which manages the state bucket itself. +The application deployment workflow does not run Terraform. + The open RDS and SSH network rules are documented security findings, not an incidental Terraform migration change. Address them in a separate, reviewed security issue with an explicit connectivity test and rollback procedure. diff --git a/infra/README.md b/infra/README.md index d1005c0..ecd573c 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,76 +1,53 @@ # Terraform Infrastructure -`bootstrap` creates the dedicated S3 backend resources. It intentionally uses -local state for this one-time setup. `prod` manages the existing Moru -production infrastructure after its backend is configured. - -## Safety Boundary - -The current `moru-prod` AWS profile is read-only. It can run discovery, -`terraform fmt`, `terraform validate`, and a read-only import plan. It cannot -create the state bucket or import resources into remote state. - -Do not run `terraform apply`, `terraform import`, or `terraform destroy` until -the plan is reviewed and explicit approval is given. - -## State Backend - -The backend is deliberately separate from the existing application buckets. -The bootstrap configuration creates a private bucket with: - -- AES256 server-side encryption -- S3 bucket versioning for state recovery -- S3 lockfiles through `use_lockfile = true` -- public-access blocking and bucket-owner-enforced ownership -- a transport-security bucket policy -- an unattached least-privilege policy for the future Terraform operator - -HashiCorp recommends bucket versioning for S3 state recovery and S3 lockfiles -for state locking. DynamoDB locking is not used because it is deprecated by the -S3 backend. - -### Bootstrap after approval - -1. Sign in with a separately approved AWS identity that can create the backend - bucket and IAM policy. Do not use the root account. -2. Choose a globally unique state bucket name. The recommended candidate is - `moru-prod-terraform-state-488230509502`, subject to availability. -3. Run the following from `infra/bootstrap`: - - ```bash - terraform init - terraform plan -var='state_bucket_name=CHOSEN_BUCKET_NAME' - ``` - -4. Review that the plan only creates the state bucket resources and the - unattached operator policy. After explicit approval, run `terraform apply` - with the same variable. -5. Attach the created state-access policy only to the approved Terraform - operator identity. Keep the `moru_terraform` discovery user read-only. - -### Configure the production backend - -1. Copy `infra/prod/backend.tf.example` to `infra/prod/backend.tf` and copy - `infra/prod/backend.hcl.example` to `infra/prod/backend.hcl`. -2. Replace the bucket name with the approved state bucket name. Do not commit - either file. -3. Run the following from `infra/prod`: - - ```bash - terraform init -backend-config=backend.hcl - terraform plan - ``` - -The first production plan should show imports only. Any planned create, update, -replacement, or destroy for an existing production resource is a stop signal. - -Before the state bucket exists, leave `backend.tf` absent and use Terraform's -local backend only for read-only validation. Local state and plan files are -ignored by Git and must never be committed. - -## Non-Terraform Host Configuration - +`bootstrap` created the dedicated S3 backend and retains local state. `prod` +manages the 30 existing Moru production AWS resources imported on 2026-09-24. +The post-import plan reported 0 to add, 0 to change, and 0 to destroy. See the +[import runbook](../docs/infrastructure/terraform-import-runbook.md) for the +original scope and verification record. + +## State and Access + +The production state is stored in the private +`moru-prod-terraform-state-488230509502` bucket at `prod/terraform.tfstate`. +The bucket uses AES256 encryption, versioning, public-access blocking, and S3 +lockfiles. Keep state, plan files, and local backend configuration files out of +Git; state and plans may contain sensitive values. The local +`infra/bootstrap/terraform.tfstate` is also sensitive and must be backed up in +an access-controlled location outside the repository. + +The `moru-prod` AWS CLI profile uses the `moru_terraform` IAM user. It has +production read access and the dedicated state-bucket policy, but not general +write access to EC2, RDS, or application S3 buckets. Grant only the specific +AWS write permissions required for an approved infrastructure change. Never +use the root account for Terraform operations. + +On a new workstation, copy `infra/prod/backend.tf.example` to +`infra/prod/backend.tf` and `infra/prod/backend.hcl.example` to +`infra/prod/backend.hcl`. Set the approved bucket name in `backend.hcl`. Both +files are ignored by Git. Then initialize from `infra/prod` with +`AWS_PROFILE=moru-prod terraform init -reconfigure -backend-config=backend.hcl`. +Do not create another state bucket or repeat the production import. + +## Production Changes + +1. Change the Terraform configuration in a reviewed PR. Keep application + deployment and host configuration outside this change unless separately + approved. +2. From `infra/prod`, run `terraform fmt -check`, `terraform validate`, and + `AWS_PROFILE=moru-prod terraform plan -out=prod.tfplan -lock-timeout=5m`. + Review the exact plan, especially any replacement or deletion. Do not use + `-lock=false` for production changes. +3. After explicit approval and with scoped AWS write permissions, apply the + reviewed plan with `AWS_PROFILE=moru-prod terraform apply prod.tfplan`. +4. Run `AWS_PROFILE=moru-prod terraform plan -lock-timeout=5m` again and check + for `No changes`. Do not commit the saved plan. + +The existing GitHub Actions workflow builds and deploys the application; it +does not run Terraform. Terraform applies remain manual and approval-gated. + +## Outside Terraform + +The shared VPC, subnets, and DB subnet group are referenced as data sources. Docker Compose, Redis, GitHub Actions, Nginx, Certbot, DuckDNS, and EC2-hosted -files remain outside the Terraform scope in this phase. Their existing setup is -documented in the repository and on the production host; it must not be changed -as part of adoption. +files remain outside Terraform management in this phase.