2026年7月13日

2026年7月13日

TerraformでAWSインフラを構築する方法|VPC・サブネット・セキュリティグループ・EC2を順に作る

TerraformでAWSのインフラを構築するときは、「VPCとサブネット」「インターネットへの出入口と経路」「セキュリティグループ」「サーバー(EC2)とその権限」を、依存関係の順に .tf ファイルへ書いていきます。この記事では、2つのアベイラビリティーゾーン(AZ)にまたがるVPCを作り、公開用のサブネットにWebサーバーを1台置き、SSHのポートを開けずにSession Managerで接続できるところまでを、コードと設計の理由をあわせて説明します。

Terraformのインストールと基本コマンド、stateをS3に置く方法は別の記事で扱っているので、ここではAWSのリソースを組み立てる部分に絞ります。

作る構成と前提

完成形は次のとおりです。

  • VPC(10.0.0.0/16)
  • 公開サブネット2つ、非公開サブネット2つ(それぞれ別のAZ)
  • インターネットゲートウェイと、公開サブネット用のルートテーブル
  • Webサーバー用のセキュリティグループ(HTTP/HTTPSのみ受け付け)
  • EC2インスタンス1台(Amazon Linux 2023、IMDSv2必須、暗号化したディスク)
  • Session Manager用のIAMロールと固定のパブリックIP(Elastic IP)

非公開サブネットは、後からRDSなどのデータベースを置く場所として用意しておきます。

AWSへの認証は、アクセスキーをコードに書かず、AWS CLIのプロファイルやIAM Identity Center(旧AWS SSO)のログインを使います。たとえば aws sso login --profile your-profile でログインし、AWS_PROFILE 環境変数で指定してからTerraformを実行します。作業用のアカウントは、本番と分けた検証用のものを使うと、誤操作の影響を抑えられます。

なお、EC2やElastic IP、パブリックIPv4アドレスなどは作成した時点から料金が発生します。料金の体系は変わることがあるため、AWSの料金ページで確認し、検証が終わったら削除してください。

ファイルを役割ごとに分ける

Terraformはディレクトリ内の .tf ファイルをすべて読み込むので、ファイルの分け方は自由です。ただ、役割ごとに分けておくと、変更箇所を探しやすくなります。

aws-web/
├── versions.tf    # Terraform とプロバイダーの版、バックエンド
├── providers.tf   # AWS プロバイダーの設定
├── variables.tf   # 入力変数
├── network.tf     # VPC・サブネット・経路
├── security.tf    # セキュリティグループ・IAM
├── compute.tf     # EC2・Elastic IP
└── outputs.tf     # 出力

プロバイダーと変数を定義する

# versions.tf
terraform {
  required_version = ">= 1.10"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}
# providers.tf
provider "aws" {
  region = var.region

  default_tags {
    tags = {
      Project   = var.project
      ManagedBy = "terraform"
    }
  }
}

default_tags を使うと、このプロバイダーで作るリソースすべてに同じタグが付きます。「どのリソースがTerraformで作られたか」「どのプロジェクトのものか」が管理画面や請求の内訳から分かるようになり、消し忘れの発見にも役立ちます。

AWSプロバイダーはメジャーバージョンで引数名や既定の動きが変わることがあります。~> 6.0 のように範囲を固定し、版を上げるときはアップグレードガイドを読んでから terraform init -upgrade を実行します(2026年10月現在の主系列を例にしています)。

# variables.tf
variable "region" {
  type    = string
  default = "ap-northeast-1"
}

variable "project" {
  type    = string
  default = "aws-web"
}

variable "vpc_cidr" {
  type    = string
  default = "10.0.0.0/16"
}

variable "instance_type" {
  type    = string
  default = "t3.micro"
}

VPCとサブネット、経路を作る

# network.tf
data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = "${var.project}-vpc" }
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = false

  tags = { Name = "${var.project}-public-${count.index}" }
}

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = { Name = "${var.project}-private-${count.index}" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${var.project}-igw" }
}

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.project}-public-rt" }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

設計のポイントを順に説明します。

AZ名を書き込まない。 aws_availability_zones データソースで、そのリージョンで使えるAZの一覧を取得しています。AZ名を直接書くと、別のリージョンやアカウントで使い回したときに存在しないAZを指定してしまうことがあります。

**cidrsubnet でアドレスを計算する。** cidrsubnet("10.0.0.0/16", 8, 0) は 10.0.0.0/24、count.index + 10 の方は 10.0.10.0/24 からになります。手でCIDRを並べるより重複や書き間違いが起きにくく、後からサブネットを足す余地も残せます。

「公開」かどうかは経路で決まる。 サブネット自体に公開・非公開の種類があるわけではなく、インターネットゲートウェイへの経路(0.0.0.0/0)を持つルートテーブルに関連付けたものが公開サブネットになります。非公開サブネットはどこにも関連付けていないため、VPCのメインルートテーブル(VPC内の通信のみ)が使われます。非公開サブネットから外部へ出る通信(OSの更新など)が必要な場合はNATゲートウェイなどを追加しますが、常時課金されるリソースなので、必要になってから足すのが一般的です。

map_public_ip_on_launch は false にし、パブリックIPが必要なサーバーだけを個別に指定する方針にしています。公開サブネットに置いたものすべてに自動でパブリックIPが付くのを避けるためです。

セキュリティグループとIAMロールを作る

# security.tf
resource "aws_security_group" "web" {
  name        = "${var.project}-web"
  description = "Web server"
  vpc_id      = aws_vpc.main.id
}

resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "https" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "all" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

data "aws_iam_policy_document" "ec2_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "web" {
  name               = "${var.project}-web"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.web.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "web" {
  name = "${var.project}-web"
  role = aws_iam_role.web.name
}

ルールは別リソースで書く。 セキュリティグループのルールは、aws_security_group の中に ingress ブロックとして書く方法もありますが、現在のAWSプロバイダーのドキュメントでは、ルールごとに aws_vpc_security_group_ingress_rule などの独立したリソースで書く方法が推奨されています。1つのルールの追加・削除がほかのルールに影響しにくく、差分も読みやすくなります。同じセキュリティグループに対して、インラインの書き方と別リソースの書き方を混ぜると、互いに上書きし合うので避けてください。

送信ルールを明示する。 Terraformでセキュリティグループを作ると、AWSが既定で付ける「すべての送信を許可」のルールは削除されます。送信ルールを書かないと、サーバーからパッケージの取得などができなくなるため、明示的に追加しています。

SSHのポートを開けない。 22番ポートを開ける代わりに、Systems ManagerのSession Managerで接続します。そのための権限が、AWS管理ポリシー AmazonSSMManagedInstanceCore です。鍵の管理が不要になり、接続の記録も残せます。

EC2インスタンスを作る

# compute.tf
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public[0].id
  vpc_security_group_ids = [aws_security_group.web.id]
  iam_instance_profile   = aws_iam_instance_profile.web.name

  # 起動直後(Elastic IP が付く前)から外部に出られるようにする
  associate_public_ip_address = true

  metadata_options {
    http_tokens = "required"
  }

  root_block_device {
    volume_type = "gp3"
    volume_size = 20
    encrypted   = true
  }

  user_data = <<-EOF
    #!/bin/bash
    dnf install -y nginx
    systemctl enable --now nginx
  EOF

  lifecycle {
    ignore_changes = [ami]
  }

  tags = { Name = "${var.project}-web" }
}

resource "aws_eip" "web" {
  domain   = "vpc"
  instance = aws_instance.web.id
}
# outputs.tf
output "instance_id" {
  value = aws_instance.web.id
}

output "public_ip" {
  value = aws_eip.web.public_ip
}

AMIのIDを書き込まない。 AMIのIDはリージョンごとに異なり、新しい版が出るたびに変わります。aws_ami データソースで最新のAmazon Linux 2023を検索しています。ただしこのままだと、新しいAMIが公開されるたびに plan が「インスタンスの作り直し」を提案してしまいます。ignore_changes = [ami] でそれを止め、OSの更新は作り直しではなく別の手段(パッチ適用や計画的な入れ替え)で行う、という方針にしています。

起動時のパブリックIP。 Elastic IPはインスタンスの作成後に関連付けられるため、それまでの間はパブリックIPがありません。初回起動時の user_data でパッケージを取得できるよう、このインスタンスにだけ associate_public_ip_address = true を指定しています。Elastic IPが付くと、外部からのアドレスはElastic IPに置き換わります。

IMDSv2を必須にする。 http_tokens = "required" は、インスタンスメタデータへのアクセスにトークンを必須とする設定です。サーバー上のアプリの脆弱性を突いて認証情報を盗まれるリスクを下げられます。

**user_data の変更は慎重に。** user_data は初回起動時に実行されるスクリプトです。後から内容を変えたときの動き(停止して更新するか、作り直すか)は user_data_replace_on_change などの設定で変わるので、plan で確認してください。サーバーの細かい設定は、Ansibleなどの構成管理ツールに分けると見通しがよくなります。

適用と確認、片付け

terraform init
terraform plan -out=tfplan
terraform apply tfplan

plan では、作成されるリソースの数(この構成では十数件)と、意図しない削除が無いことを確かめます。適用後、出力された public_ip にブラウザでアクセスし、Nginxの初期ページが表示されれば成功です。サーバーへの接続は、AWS CLIとSession Managerプラグインを入れたうえで次のように行います。

aws ssm start-session --target $(terraform output -raw instance_id)

接続できない場合は、インスタンスがSystems Managerに登録されるまで数分待つ、IAMロールが付いているか確かめる、送信ルールが削除されていないか見直す、の順に確認します。

検証が終わったら、terraform destroy で作ったものをまとめて削除します。本番運用に移る段階では、データベースなど消えては困るリソースに lifecycle { prevent_destroy = true } を付け、stateをS3に置いてロックを有効にしておきます。

構成が大きくなってきたら、VPCの部分をモジュールに切り出すことも検討してください。Terraform Registryにはコミュニティが保守するVPCモジュール(terraform-aws-modules/vpc/aws など)もありますが、引数が多く既定値の挙動を理解しておく必要があるので、まずは今回のように素のリソースで書いて仕組みをつかんでから使うと、問題が起きたときに読み解きやすくなります。

まとめ

  • AWSのインフラは「VPC・サブネット → 経路 → セキュリティグループ・IAM → EC2」の順に、依存関係を参照でつないで書きます。順番はTerraformが自動で判断します。
  • AZ名やAMIのIDはデータソースで取得し、CIDRは cidrsubnet で計算すると、使い回しやすく書き間違いも減ります。
  • 公開サブネットかどうかはルートテーブルで決まります。NATゲートウェイなど常時課金のリソースは必要になってから足します。
  • セキュリティグループのルールは別リソースで書き、送信ルールを明示します。SSHは開けずにSession Managerで接続し、IMDSv2とディスク暗号化を有効にします。
  • 認証情報はコードに書かず、検証が終わったら destroy で片付けます。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲