はじめに
個人開発でRails API + Next.jsのWebアプリをAWSにデプロイするにあたり、Terraformでインフラをコード化しました。
この記事では、実際にコマンドを打ってインフラを段階的に構築した手順をまとめています。
最終的な構成
インターネット
│
▼
┌──────────────────────────────────────┐
│ CloudFront (CDN) │
│ /api/* → ALB (バックエンド) │
│ /* → S3 (フロントエンド) │
└──────────────┬───────────────────────┘
│
┌───────┴────────┐
▼ ▼
┌────────────┐ ┌────────────────────────┐
│ S3 Bucket │ │ ALB (ロードバランサー) │
│ (Next.js │ │ public subnet AZ-a/c │
│ 静的ファイ │ └──────────┬─────────────┘
│ ル) │ │
└────────────┘ ▼
┌─────────────────┐
│ ECS Fargate │
│ (Rails API) │
│ private subnet │
└────────┬────────┘
│
▼
┌─────────────────┐
│ RDS PostgreSQL │
│ Primary/Replica│
│ private subnet │
└─────────────────┘
【CI/CD】
GitHub Actions
→ OIDC認証でAWS IAMロールを取得
→ ECRへDockerイメージをpush
→ ECSサービスを更新 / S3へフロントエンドをデプロイ
この構成の注意点
- CloudFront → ALB間はHTTP通信: CloudFrontがHTTPS終端を担い、ALBにはHTTPで転送しています。ALBにSSL証明書は設定していません
-
Railsに
assume_ssl = trueが必要: CloudFrontでHTTPSが終端されているのに、ALB→ECS間がHTTPのため、Railsが「HTTPSではない」と判断してリダイレクトループになります。この設定でRailsにSSL終端済みと伝えます -
/api/*はキャッシュTTL=0: CloudFrontはCDN(キャッシュサーバー)なので、デフォルトではレスポンスをキャッシュして使い回します。静的ファイルなら問題ないですが、APIだと「ユーザーAのレスポンスがキャッシュされ、ユーザーBに返る」事故が起きます。これを防ぐためにTTL=0(キャッシュなし)にし、全ヘッダー・Cookieを転送して認証トークンをそのままRailsに渡しています。つまりAPIに関してはCloudFrontはキャッシュではなくただのプロキシとして動いています - なぜCloudFrontでAPIをプロキシするのか: フロントエンド(S3)とAPI(ALB)を同じCloudFrontドメインにまとめることで、CORS問題を回避するためです。本来CloudFrontはAPIに使うものではなく、本番サービスでは独自ドメイン+ALBにSSL証明書を設定してCORS設定するのが理想です
構築手順
以下の順序で .tf ファイルを追加しながら terraform apply を繰り返してインフラを段階的に構築しました。
Step 0: 初期設定
mkdir infra && cd infra
main.tf(プロバイダー設定)
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# tfstateをS3で管理(初回はコメントアウトしてローカルで実行)
# backend "s3" {
# bucket = "first-step-tfstate"
# key = "terraform.tfstate"
# region = "ap-northeast-1"
# profile = "your-profile"
# }
}
provider "aws" {
region = "ap-northeast-1"
# profile = "your-profile" ← 自分のAWS CLIプロファイル名に変更
}
variables.tf(変数の型定義)
各.tfファイルで使う変数の名前・説明・デフォルト値を定義します。プログラミングでいう型定義のようなものです。
variable "app_name" {
description = "アプリケーション名"
default = "first-step"
}
variable "environment" {
description = "環境名"
default = "production"
}
variable "aws_region" {
description = "AWSリージョン"
default = "ap-northeast-1"
}
variable "db_username" {
description = "DBユーザー名"
default = "postgres"
}
variable "db_password" {
description = "DBパスワード"
sensitive = true
}
variable "rails_master_key" {
description = "Railsのマスターキー"
sensitive = true
}
variable "github_repository" {
description = "GitHubリポジトリ名(例: username/first-step)"
}
terraform.tfvars(変数の実値)
variables.tf は変数の「型と名前」を定義するだけで、値は入っていません(default があるものを除く)。terraform.tfvars に実際の値を書きます。terraform apply 時にこのファイルが自動で読み込まれ、var.db_password 等として各.tfファイルから参照できるようになります。
# variables.tf で default がない変数 = ここで値を指定する必要がある
db_password = "xxxxxxxx"
rails_master_key = "xxxxxxxx"
github_repository = "username/first-step"
注意: 機密情報を含むため
.gitignoreに追加してgit管理外にしてください。
terraform init # AWSプロバイダーをダウンロード
Step 1: ECRリポジトリを作成
Dockerイメージの保管先を先に用意します。
ecr.tf
resource "aws_ecr_repository" "backend" {
name = "${var.app_name}-backend"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_ecr_lifecycle_policy" "backend" {
repository = aws_ecr_repository.backend.name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "最新5世代のイメージだけ保持"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 5
}
action = {
type = "expire"
}
}
]
})
}
terraform apply
この時点でECRリポジトリができるので、GitHub ActionsからDockerイメージをpushできるようになります。
Step 2: VPC・サブネット・ネットワークを構築
vpc.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.app_name}-vpc" }
}
# パブリックサブネット(ALB・NATゲートウェイを配置)
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
tags = { Name = "${var.app_name}-public-a" }
}
resource "aws_subnet" "public_c" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.aws_region}c"
tags = { Name = "${var.app_name}-public-c" }
}
# プライベートサブネット(ECS・RDSを配置)
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = "${var.aws_region}a"
tags = { Name = "${var.app_name}-private-a" }
}
resource "aws_subnet" "private_c" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
availability_zone = "${var.aws_region}c"
tags = { Name = "${var.app_name}-private-c" }
}
# インターネットゲートウェイ
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.app_name}-igw" }
}
# NATゲートウェイ(プライベートサブネットからのアウトバウンド通信用)
resource "aws_eip" "nat" {
domain = "vpc"
tags = { Name = "${var.app_name}-nat-eip" }
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_a.id
tags = { Name = "${var.app_name}-nat" }
}
# ルートテーブル
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "${var.app_name}-public-rt" }
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = { Name = "${var.app_name}-private-rt" }
}
# ルートテーブルをサブネットに紐付け
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_c" {
subnet_id = aws_subnet.public_c.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_a" {
subnet_id = aws_subnet.private_a.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_c" {
subnet_id = aws_subnet.private_c.id
route_table_id = aws_route_table.private.id
}
terraform apply
Step 3: セキュリティグループを作成
通信の許可ルールをチェーン型で定義します。
インターネット → ALB-SG (80/443)
→ ECS-SG (3000)
→ RDS-SG (5432)
security_group.tf
# ALB用(インターネットからHTTP/HTTPSを受け付ける)
resource "aws_security_group" "alb" {
name = "${var.app_name}-alb-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# ECS用(ALBからの通信のみ受け付ける)
resource "aws_security_group" "ecs" {
name = "${var.app_name}-ecs-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 3000
to_port = 3000
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# RDS用(ECSからの通信のみ受け付ける)
resource "aws_security_group" "rds" {
name = "${var.app_name}-rds-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.ecs.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
terraform apply
Step 4: RDSを作成
PostgreSQL 16のPrimary + Read Replicaを構築します。
rds.tf
resource "aws_db_subnet_group" "main" {
name = "${var.app_name}-db-subnet-group"
subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_c.id]
}
resource "aws_db_instance" "primary" {
identifier = "${var.app_name}-db-primary"
engine = "postgres"
engine_version = "16"
instance_class = "db.t3.micro"
allocated_storage = 20
db_name = "first_step_production"
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
deletion_protection = false
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
skip_final_snapshot = true
}
resource "aws_db_instance" "replica" {
identifier = "${var.app_name}-db-replica"
instance_class = "db.t3.micro"
replicate_source_db = aws_db_instance.primary.identifier
vpc_security_group_ids = [aws_security_group.rds.id]
skip_final_snapshot = true
}
terraform apply # RDSの作成は数分かかる
Step 5: ALB・ECS・SSMを作成
alb.tf
resource "aws_lb" "main" {
name = "${var.app_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_a.id, aws_subnet.public_c.id]
}
resource "aws_lb_target_group" "backend" {
name = "${var.app_name}-tg"
port = 3000
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/up"
matcher = "200-499"
healthy_threshold = 2
unhealthy_threshold = 5
interval = 60
timeout = 30
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.backend.arn
}
}
ecs.tf
resource "aws_ecs_cluster" "main" {
name = "${var.app_name}-cluster"
}
# タスク実行ロール(ECRからイメージをpull・SSMからシークレットを取得)
resource "aws_iam_role" "ecs_task_execution" {
name = "${var.app_name}-ecs-task-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
# タスクロール(ECS Exec用)
resource "aws_iam_role" "ecs_task" {
name = "${var.app_name}-ecs-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "ecs_task_exec" {
name = "${var.app_name}-ecs-task-exec-policy"
role = aws_iam_role.ecs_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel"
]
Resource = "*"
}]
})
}
resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "ecs_ssm" {
name = "${var.app_name}-ecs-ssm-policy"
role = aws_iam_role.ecs_task_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["ssm:GetParameters", "ssm:GetParameter"]
Resource = "arn:aws:ssm:${var.aws_region}:*:parameter/${var.app_name}/*"
}]
})
}
resource "aws_ecs_task_definition" "backend" {
family = "${var.app_name}-backend"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([{
name = "backend"
image = "${aws_ecr_repository.backend.repository_url}:latest"
portMappings = [{ containerPort = 3000, protocol = "tcp" }]
environment = [
{ name = "RAILS_ENV", value = "production" },
{ name = "RAILS_LOG_TO_STDOUT", value = "true" },
{ name = "FRONTEND_URL", value = "https://xxxxx.cloudfront.net" }
]
secrets = [
{ name = "DATABASE_URL", valueFrom = aws_ssm_parameter.database_url.arn },
{ name = "DATABASE_REPLICA_URL", valueFrom = aws_ssm_parameter.database_replica_url.arn },
{ name = "RAILS_MASTER_KEY", valueFrom = aws_ssm_parameter.rails_master_key.arn }
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/${var.app_name}-backend"
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
}
}
}])
}
resource "aws_cloudwatch_log_group" "backend" {
name = "/ecs/${var.app_name}-backend"
retention_in_days = 30
}
resource "aws_ecs_service" "backend" {
name = "${var.app_name}-backend-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.backend.arn
desired_count = 1
launch_type = "FARGATE"
enable_execute_command = true
network_configuration {
subnets = [aws_subnet.private_a.id]
security_groups = [aws_security_group.ecs.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.backend.arn
container_name = "backend"
container_port = 3000
}
lifecycle {
ignore_changes = [task_definition]
}
}
ssm.tf
resource "aws_ssm_parameter" "database_url" {
name = "/${var.app_name}/DATABASE_URL"
type = "SecureString"
value = "postgresql://${var.db_username}:${var.db_password}@${aws_db_instance.primary.endpoint}/first_step_production"
}
resource "aws_ssm_parameter" "database_replica_url" {
name = "/${var.app_name}/DATABASE_REPLICA_URL"
type = "SecureString"
value = "postgresql://${var.db_username}:${var.db_password}@${aws_db_instance.replica.endpoint}/first_step_production"
}
resource "aws_ssm_parameter" "rails_master_key" {
name = "/${var.app_name}/RAILS_MASTER_KEY"
type = "SecureString"
value = var.rails_master_key
}
terraform apply
この時点でバックエンドAPIが動く状態になります。
Step 6: S3・CloudFrontを作成
s3.tf
resource "aws_s3_bucket" "frontend" {
bucket = "${var.app_name}-frontend-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_public_access_block" "frontend" {
bucket = aws_s3_bucket.frontend.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_policy" "frontend" {
bucket = aws_s3_bucket.frontend.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowCloudFrontAccess"
Effect = "Allow"
Principal = { Service = "cloudfront.amazonaws.com" }
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.frontend.arn}/*"
Condition = {
StringEquals = {
"AWS:SourceArn" = aws_cloudfront_distribution.frontend.arn
}
}
}]
})
}
cloudfront.tf
resource "aws_cloudfront_origin_access_control" "frontend" {
name = "${var.app_name}-frontend-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "frontend" {
enabled = true
default_root_object = "index.html"
# S3オリジン(フロントエンド)
origin {
domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
origin_id = "S3-${var.app_name}-frontend"
origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id
}
# ALBオリジン(APIプロキシ)
origin {
domain_name = aws_lb.main.dns_name
origin_id = "ALB-${var.app_name}-backend"
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "http-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
# /api/* はALBに転送(キャッシュなし・全ヘッダー転送)
ordered_cache_behavior {
path_pattern = "/api/*"
target_origin_id = "ALB-${var.app_name}-backend"
viewer_protocol_policy = "https-only"
allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
cached_methods = ["GET", "HEAD"]
forwarded_values {
query_string = true
headers = ["*"]
cookies { forward = "all" }
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# /up もALBに転送
ordered_cache_behavior {
path_pattern = "/up"
target_origin_id = "ALB-${var.app_name}-backend"
viewer_protocol_policy = "https-only"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 0
max_ttl = 0
}
# デフォルトはS3(フロントエンド)
default_cache_behavior {
target_origin_id = "S3-${var.app_name}-frontend"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
}
# SPA対応(404/403をindex.htmlにフォールバック)
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
terraform apply
Step 7: GitHub Actions OIDC を設定
GitHub ActionsからAWSにパスワードレス認証でアクセスするための設定です。
oidc.tf
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
resource "aws_iam_role" "github_actions" {
name = "${var.app_name}-github-actions-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:${var.github_repository}:*"
}
}
}]
})
}
# ECRへのpush権限
resource "aws_iam_role_policy" "github_actions_ecr" {
name = "${var.app_name}-github-actions-ecr-policy"
role = aws_iam_role.github_actions.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
]
Resource = "*"
}]
})
}
# S3・CloudFrontのデプロイ権限
resource "aws_iam_role_policy" "github_actions_frontend" {
name = "${var.app_name}-github-actions-frontend-policy"
role = aws_iam_role.github_actions.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"]
Resource = [
aws_s3_bucket.frontend.arn,
"${aws_s3_bucket.frontend.arn}/*"
]
},
{
Effect = "Allow"
Action = "cloudfront:CreateInvalidation"
Resource = "*"
}
]
})
}
# ECSデプロイ権限
resource "aws_iam_role_policy" "github_actions_ecs" {
name = "${var.app_name}-github-actions-ecs-policy"
role = aws_iam_role.github_actions.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ecs:UpdateService",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition",
"iam:PassRole"
]
Resource = "*"
}]
})
}
terraform apply
outputs.tf
output "github_actions_role_arn" {
description = "GitHub ActionsのIAMロールARN(GitHub Secretsに登録する)"
value = aws_iam_role.github_actions.arn
}
output "ecr_repository_url" {
description = "ECRリポジトリURL"
value = aws_ecr_repository.backend.repository_url
}
output "alb_dns_name" {
description = "ALBのDNS名"
value = aws_lb.main.dns_name
}
output "cloudfront_domain_name" {
description = "CloudFrontのドメイン名(FRONTEND_URLに設定する)"
value = aws_cloudfront_distribution.frontend.domain_name
}
Step 8: 出力値を確認
terraform output
github_actions_role_arn = "arn:aws:iam::xxxxx:role/first-step-github-actions-role"
ecr_repository_url = "xxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/first-step-backend"
alb_dns_name = "first-step-alb-xxxxx.ap-northeast-1.elb.amazonaws.com"
cloudfront_domain_name = "xxxxx.cloudfront.net"
これらをGitHub SecretsやフロントエンドのAPI URLとして設定します。
まとめ
Terraformの本質はシンプルです。
-
resourceで「こうなってほしい」を宣言する -
variableで値をパラメータ化する - リソース間は
種別.名前.属性で参照する -
terraform applyで差分だけ反映される
難しいのはTerraform自体ではなく、AWSのアーキテクチャ設計(ネットワーク設計・セキュリティ・CI/CD認証など)の方でした。