AWS EC2 ve VPC Kurulumu: Terraform ile Adım Adım Rehber
Altyapıyı elle kurmak bir noktadan sonra sürdürülemez hale gelir. Bir EC2 instance’ı ayağa kaldırmak için AWS konsoluna girip tıklamak ilk seferinde hızlı gelebilir, ama aynı ortamı staging, production ve DR için tekrar etmek zorunda kaldığında işler karmaşıklaşır. Terraform tam bu noktada devreye giriyor: altyapını kod olarak tanımlıyorsun, versiyon kontrolüne alıyorsun ve tekrarlanabilir deployment’lar yapıyorsun. Bu yazıda gerçek dünya senaryolarıyla AWS üzerinde VPC ve EC2 kurulumunu Terraform ile nasıl yapacağını adım adım anlatacağım.
Ön Hazırlık: Ortamını Hazırla
Başlamadan önce birkaç şeyi yerine getirmen gerekiyor. Terraform CLI kurulu olmalı, AWS CLI yapılandırılmış olmalı ve tabii ki bir AWS hesabın bulunmalı.
Terraform kurulumu için:
# Linux üzerinde Terraform kurulumu
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform -y
# Kurulumu doğrula
terraform version
AWS CLI tarafında ise credentials’larını yapılandırman gerekiyor. Burada IAM kullanıcısı yerine IAM role kullanmayı öneririm, özellikle CI/CD pipeline’larında çalışıyorsan. Ama lokal geliştirme için şimdilik kullanıcı bazlı devam edelim:
aws configure
# AWS Access Key ID: [buraya kendi key'ini gir]
# AWS Secret Access Key: [buraya kendi secret'ını gir]
# Default region name: eu-west-1
# Default output format: json
# Bağlantıyı test et
aws sts get-caller-identity
Proje dizin yapısını da baştan düzgün kuralım. Küçük projelerde her şeyi tek dosyaya yazabilirsin, ama büyüdükçe pişman olursun. Modüler bir yapı ile başlamak mantıklı:
mkdir -p terraform-aws-demo/{modules/vpc,modules/ec2,environments/prod,environments/staging}
cd terraform-aws-demo
touch main.tf variables.tf outputs.tf providers.tf
Provider ve Backend Yapılandırması
providers.tf dosyası ile başlayalım. Provider versiyonlarını sabitlemek önemli, yoksa bir gün terraform init çalıştırdığında breaking change ile karşılabilirsin:
# providers.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Remote state için S3 backend - production'da mutlaka kullan
backend "s3" {
bucket = "sirket-terraform-state"
key = "prod/vpc-ec2/terraform.tfstate"
region = "eu-west-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = var.project_name
}
}
}
S3 backend kullanıyorsan DynamoDB tablosunu da önceden oluşturman gerekiyor. Bu tabloyu state locking için kullanıyoruz, yani iki kişi aynı anda terraform apply çalıştırırsa birisi beklemek zorunda kalıyor:
# State bucket oluştur
aws s3api create-bucket
--bucket sirket-terraform-state
--region eu-west-1
--create-bucket-configuration LocationConstraint=eu-west-1
# Bucket versioning aç
aws s3api put-bucket-versioning
--bucket sirket-terraform-state
--versioning-configuration Status=Enabled
# DynamoDB lock tablosu oluştur
aws dynamodb create-table
--table-name terraform-state-lock
--attribute-definitions AttributeName=LockID,AttributeType=S
--key-schema AttributeName=LockID,KeyType=HASH
--billing-mode PAY_PER_REQUEST
--region eu-west-1
VPC Modülü: Ağ Altyapısını Kur
VPC, tüm AWS altyapısının temeli. Bunu doğru tasarlamadan EC2’ya geçme. Gerçek dünyada çoğu zaman şu senaryo karşıma çıkıyor: geliştirici EC2 instance’ını default VPC’ye atıyor, sonra production’da “neden public subnet’te neden?” sorusu çıkıyor ortaya. Baştan doğru yapalım.
modules/vpc/main.tf:
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-${var.environment}-vpc"
}
}
# Public Subnet'ler - Load Balancer ve NAT Gateway burada olacak
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-public-${var.availability_zones[count.index]}"
Tier = "public"
}
}
# Private Subnet'ler - EC2 instance'ları burada olacak
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = var.availability_zones[count.index]
tags = {
Name = "${var.project_name}-${var.environment}-private-${var.availability_zones[count.index]}"
Tier = "private"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
}
}
# Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? length(var.availability_zones) : 0
domain = "vpc"
tags = {
Name = "${var.project_name}-${var.environment}-nat-eip-${count.index + 1}"
}
}
# NAT Gateway - private subnet'lerin internete çıkması için
resource "aws_nat_gateway" "main" {
count = var.enable_nat_gateway ? length(var.availability_zones) : 0
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = {
Name = "${var.project_name}-${var.environment}-nat-${count.index + 1}"
}
depends_on = [aws_internet_gateway.main]
}
# Public Route Table
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_name}-${var.environment}-public-rt"
}
}
# Private Route Table - her AZ için ayrı
resource "aws_route_table" "private" {
count = var.enable_nat_gateway ? length(var.availability_zones) : 1
vpc_id = aws_vpc.main.id
dynamic "route" {
for_each = var.enable_nat_gateway ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
}
tags = {
Name = "${var.project_name}-${var.environment}-private-rt-${count.index + 1}"
}
}
# Route Table Association'lar
resource "aws_route_table_association" "public" {
count = length(var.availability_zones)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = length(var.availability_zones)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[var.enable_nat_gateway ? count.index : 0].id
}
VPC modülünün değişkenlerini tanımlayalım:
# modules/vpc/variables.tf
variable "project_name" {
description = "Proje adı, tüm resource isimlerinde kullanılır"
type = string
}
variable "environment" {
description = "Ortam adı: prod, staging, dev"
type = string
}
variable "vpc_cidr" {
description = "VPC CIDR bloğu"
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
description = "Kullanılacak AZ listesi"
type = list(string)
}
variable "enable_nat_gateway" {
description = "NAT Gateway oluşturulsun mu? Dev ortamda false yapabilirsin, maliyet azalır"
type = bool
default = true
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
EC2 Modülü: Instance ve Security Group
EC2 modülünde dikkat etmem gereken birkaç konu var: Security Group’ları minimal tutmak, instance’a IAM role atamak ve user_data ile başlangıç yapılandırmasını otomatikleştirmek.
modules/ec2/main.tf:
# modules/ec2/main.tf
# En güncel Amazon Linux 2023 AMI'ı otomatik bul
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# Security Group
resource "aws_security_group" "ec2" {
name_prefix = "${var.project_name}-${var.environment}-ec2-"
vpc_id = var.vpc_id
description = "EC2 instance security group"
# SSH erişimi - sadece belirli IP'lerden
ingress {
description = "SSH from bastion or VPN"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.allowed_ssh_cidrs
}
# Uygulama portu - sadece VPC içinden
ingress {
description = "App port from VPC"
from_port = var.app_port
to_port = var.app_port
protocol = "tcp"
cidr_blocks = [var.vpc_cidr]
}
# Outbound tamamen açık
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
lifecycle {
create_before_destroy = true
}
tags = {
Name = "${var.project_name}-${var.environment}-ec2-sg"
}
}
# IAM Role - EC2 için
resource "aws_iam_role" "ec2" {
name = "${var.project_name}-${var.environment}-ec2-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
}
# SSM için managed policy ekle - SSH yerine SSM Session Manager kullanabilirsin
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.ec2.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ec2" {
name = "${var.project_name}-${var.environment}-ec2-profile"
role = aws_iam_role.ec2.name
}
# EC2 Instance
resource "aws_instance" "main" {
count = var.instance_count
ami = data.aws_ami.amazon_linux_2023.id
instance_type = var.instance_type
subnet_id = var.private_subnet_ids[count.index % length(var.private_subnet_ids)]
vpc_security_group_ids = [aws_security_group.ec2.id]
iam_instance_profile = aws_iam_instance_profile.ec2.name
key_name = var.key_name
root_block_device {
volume_type = "gp3"
volume_size = var.root_volume_size
encrypted = true
delete_on_termination = true
}
user_data = base64encode(templatefile("${path.module}/templates/userdata.sh", {
environment = var.environment
project_name = var.project_name
}))
# Bu instance'ı yanlışlıkla silmekten koru
disable_api_termination = var.environment == "prod" ? true : false
tags = {
Name = "${var.project_name}-${var.environment}-ec2-${count.index + 1}"
}
}
User data template’ini de hazırlayalım:
# modules/ec2/templates/userdata.sh
#!/bin/bash
set -e
# Sistem güncellemelerini yap
dnf update -y
# SSM Agent'ı etkinleştir
systemctl enable amazon-ssm-agent
systemctl start amazon-ssm-agent
# CloudWatch Agent kurulumu
dnf install -y amazon-cloudwatch-agent
# Temel araçları kur
dnf install -y
htop
vim
git
jq
curl
wget
unzip
# Hostname'i düzgün ayarla
hostnamectl set-hostname ${project_name}-${environment}-$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
# CloudWatch log grubu için agent config
cat > /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json << 'EOF'
{
"logs": {
"logs_collected": {
"files": {
"collect_list": [
{
"file_path": "/var/log/messages",
"log_group_name": "/${project_name}/${environment}/system",
"log_stream_name": "{instance_id}"
}
]
}
}
}
}
EOF
/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl
-a fetch-config
-m ec2
-c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json
-s
echo "Userdata tamamlandi: $(date)" >> /var/log/userdata.log
Ana Yapılandırma: Her Şeyi Bir Araya Getir
Şimdi her şeyi main.tf dosyasında birleştirelim. Bu dosya ortam bazlı değişkenleri alıp modüllere dağıtacak:
# main.tf - Kök modül
locals {
project_name = "webapp"
environment = var.environment
aws_region = var.aws_region
availability_zones = [
"${var.aws_region}a",
"${var.aws_region}b",
"${var.aws_region}c"
]
}
module "vpc" {
source = "./modules/vpc"
project_name = local.project_name
environment = local.environment
vpc_cidr = var.vpc_cidr
availability_zones = local.availability_zones
enable_nat_gateway = var.enable_nat_gateway
}
module "ec2" {
source = "./modules/ec2"
project_name = local.project_name
environment = local.environment
vpc_id = module.vpc.vpc_id
vpc_cidr = var.vpc_cidr
private_subnet_ids = module.vpc.private_subnet_ids
instance_type = var.instance_type
instance_count = var.instance_count
root_volume_size = var.root_volume_size
key_name = var.key_name
allowed_ssh_cidrs = var.allowed_ssh_cidrs
app_port = var.app_port
}
# outputs.tf
output "vpc_id" {
description = "VPC ID"
value = module.vpc.vpc_id
}
output "private_subnet_ids" {
description = "Private subnet ID'leri"
value = module.vpc.private_subnet_ids
}
output "ec2_instance_ids" {
description = "EC2 instance ID'leri"
value = module.ec2.instance_ids
}
variables.tf dosyasını da hazırlayalım ve terraform.tfvars ile ortam bazlı değerleri verelim:
# terraform.tfvars - production değerleri
aws_region = "eu-west-1"
environment = "prod"
vpc_cidr = "10.0.0.0/16"
enable_nat_gateway = true
instance_type = "t3.medium"
instance_count = 2
root_volume_size = 50
key_name = "prod-keypair"
allowed_ssh_cidrs = ["10.10.0.0/24"] # Sadece VPN IP bloğu
app_port = 8080
Deployment: Plan, Apply ve Destroy
Her şey hazır olduğunda deployment adımları şunlar:
# Terraform'u başlat, provider'ları indir
terraform init
# Değişkeni dışarıdan geçirerek plan oluştur
terraform plan -var-file="environments/prod/terraform.tfvars" -out=tfplan
# Plan dosyasını gözden geçir
terraform show tfplan
# Uygula
terraform apply tfplan
# Sadece belirli bir modülü güncelle (örneğin EC2 güncelliyorsun VPC'ye dokunmadan)
terraform apply -target=module.ec2 -var-file="environments/prod/terraform.tfvars"
# State'i kontrol et
terraform state list
terraform state show module.ec2.aws_instance.main[0]
Yaygın Hatalar ve Çözümleri
State lock hatası: Birisi terraform apply sırasında CTRL+C yaparsa lock takılı kalabilir. DynamoDB’deki lock kaydını manuel silmek gerekiyor:
# Lock ID'yi bul
terraform force-unlock LOCK_ID
# Ya da DynamoDB'den manuel sil
aws dynamodb delete-item
--table-name terraform-state-lock
--key '{"LockID": {"S": "sirket-terraform-state/prod/vpc-ec2/terraform.tfstate"}}'
--region eu-west-1
AMI değişimi sonrası instance destroy/recreate: data.aws_ami her zaman en güncel AMI’ı getiriyor. Production’da bu tehlikeli olabilir. AMI ID’sini variable olarak sabitleyip controlled bir şekilde güncellemek daha güvenli:
# Mevcut AMI'ı bul ve tfvars'a kaydet
aws ec2 describe-images
--owners amazon
--filters "Name=name,Values=al2023-ami-*-x86_64"
--query 'sort_by(Images, &CreationDate)[-1].ImageId'
--output text
--region eu-west-1
Drift detection: Birisi konsol üzerinden değişiklik yapmışsa Terraform state ile gerçek durum farklılaşır. Bunu tespit etmek için:
# Refresh ile mevcut durumu kontrol et
terraform plan -refresh-only -var-file="environments/prod/terraform.tfvars"
Güvenlik Kontrolleri
Production’a almadan önce birkaç güvenlik kontrolü yapmak iyi pratik. tfsec veya checkov araçlarını pipeline’a dahil edebilirsin:
# tfsec kurulumu ve çalıştırma
curl -s https://raw.githubusercontent.com/aquasecurity/tfsec/master/scripts/install_linux.sh | bash
tfsec . --minimum-severity HIGH
# checkov ile tarama
pip install checkov
checkov -d . --framework terraform
# terraform validate ile sözdizimi kontrolü
terraform validate
# terraform fmt ile kod formatını düzelt
terraform fmt -recursive
Sonuç
Terraform ile AWS VPC ve EC2 kurulumu başta karmaşık gelebilir, ama bir kez doğru şablonu oluşturduktan sonra yeni ortamlar açmak dakikalar alıyor. Burada anlattığım yapı birkaç temel prensip üzerine kuruyor: modüler yapı ile tekrar kullanılabilirlik, remote state ile ekip çalışması, security group minimalizmi ile güvenlik ve tagging standardizasyonu ile maliyet takibi.
Sonraki adım olarak bu yapıyı bir CI/CD pipeline’ına entegre etmeni öneririm. GitLab CI veya GitHub Actions ile pull request açıldığında otomatik terraform plan çalıştırıp output’u PR’a yorum olarak bırakabilirsin. Böylece ekipteki herkes ne değişeceğini görmeden merge olmaz. Bu konuyu da ilerleyen yazılarda ele alacağım.
Bir de şunu söyleyeyim: Terraform’u öğrenmenin en iyi yolu production benzeri bir ortamda denemek. AWS Free Tier hesabı açıp buradaki kodu çalıştır, hata al, debug et. Belgeler ve terraform console komutu en iyi arkadaşın olacak bu süreçte.
