Deploy Laravel lên AWS bằng Terraform — Infrastructure as Code từ A đến Z

· 17 min read

Nếu bạn đã theo dõi series Deploy Laravel lên AWS qua ConsoleDeploy qua CLI, bạn đã biết cách dựng hạ tầng bằng tay và bằng lệnh. Nhưng thực tế, khi hạ tầng phức tạp hơn và cần quản lý nhiều môi trường (staging, production, DR), bạn cần một giải pháp có thể lặp lại, kiểm soát phiên bản, và tự động hóa hoàn toàn.

Đó chính là Infrastructure as Code (IaC) — và Terraform là công cụ phổ biến nhất cho việc này.

Tại sao Terraform?

Tiêu chí Console AWS CLI Terraform
Lặp lại được ⚠️ script phức tạp terraform apply
Version control ⚠️ script files .tf files trong Git
Quản lý state ✅ tự động tracking
Multi-environment ❌ phải duplicate script terraform workspace
Phát hiện drift terraform plan
Xóa sạch hạ tầng ❌ click từng cái ⚠️ script riêng terraform destroy

Lý do cốt lõi: Terraform giúp bạn khai báo (declare) hạ tầng mong muốn, thay vì ra lệnh (imperative) từng bước. Bạn nói "Tôi muốn 1 VPC, 2 subnets, 1 RDS, 1 EC2", và Terraform tự tìm ra thứ tự tạo, dependency, và cách đạt được trạng thái đó.

Yêu cầu trước khi bắt đầu

# Cài Terraform
curl -fsSL https://releases.hashicorp.com/terraform/1.7.5/terraform_1.7.5_linux_amd64.zip -o terraform.zip
unzip terraform.zip && sudo mv terraform /usr/local/bin/
terraform --version

# Cấu hình AWS credentials
aws configure
# Hoặc export biến môi trường
export AWS_ACCESS_KEY_ID="your-key"
export AWS_SECRET_ACCESS_KEY="your-secret"
export AWS_DEFAULT_REGION="ap-southeast-1"

Cấu trúc thư mục

terraform/
├── main.tf              # Entry point, provider config
├── variables.tf         # Khai báo biến
├── terraform.tfvars     # Giá trị biến (KHÔNG commit lên Git)
├── outputs.tf           # Output sau khi apply
├── vpc.tf               # VPC, Subnets, Route Tables
├── security-groups.tf   # Security Groups
├── ec2.tf               # EC2 instances
├── rds.tf               # RDS MySQL
├── s3.tf                # S3 bucket
├── elasticache.tf       # ElastiCache Redis
├── alb.tf               # Application Load Balancer
├── cloudfront.tf        # CloudFront CDN
└── modules/             # (tùy chọn) tái sử dụng
    └── laravel-stack/

Lưu ý quan trọng: File terraform.tfvars chứa thông tin nhạy cảm (DB password, secret keys). Thêm nó vào .gitignore ngay lập tức.

1. Provider và Backend Configuration

# main.tf
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
  }

  # Lưu state file trên S3 (khuyến nghị cho team)
  backend "s3" {
    bucket         = "my-laravel-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "ap-southeast-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = "laravel-blog"
      Environment = var.environment
      ManagedBy   = "terraform"
    }
  }
}

Giải thích:

  • backend "s3": State file không nên để local khi làm team. Lưu trên S3 + DynamoDB lock để tránh xung đột khi 2 người cùng apply.
  • default_tags: Mọi resource sẽ tự động được gắn tag, giúp quản lý chi phí dễ dàng.
  • required_version: Đảm bảo mọi người dùng cùng phiên bản Terraform.

2. Khai báo biến

# variables.tf
variable "aws_region" {
  description = "AWS Region"
  type        = string
  default     = "ap-southeast-1"
}

variable "environment" {
  description = "Môi trường: production, staging"
  type        = string
  default     = "production"
}

variable "app_name" {
  description = "Tên ứng dụng"
  type        = string
  default     = "laravel-app"
}

variable "vpc_cidr" {
  description = "CIDR block cho VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "db_username" {
  description = "RDS master username"
  type        = string
  sensitive   = true
}

variable "db_password" {
  description = "RDS master password"
  type        = string
  sensitive   = true
}

variable "ec2_key_name" {
  description = "SSH key pair name"
  type        = string
}

variable "ec2_instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.small"
}
# terraform.tfvars (KHÔNG commit)
aws_region        = "ap-southeast-1"
environment       = "production"
app_name          = "laravel-blog"
db_username       = "laravel_admin"
db_password       = "SuperSecretP@ss2026!"
ec2_key_name      = "my-laravel-key"
ec2_instance_type = "t3.small"

Giải thích sensitive = true: Khi bạn đánh dấu biến là sensitive, Terraform sẽ ẩn giá trị trong output của terraform planterraform apply. Đây là best practice cho mọi thông tin nhạy cảm.

3. VPC — Nền tảng mạng

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

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

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

# Public subnets (cho EC2, ALB)
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 = true

  tags = {
    Name = "${var.app_name}-public-${count.index + 1}"
    Type = "public"
  }
}

# Private subnets (cho RDS, ElastiCache)
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.app_name}-private-${count.index + 1}"
    Type = "private"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.app_name}-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.app_name}-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
}

Giải thích cidrsubnet(): Đây là hàm built-in của Terraform giúp tự động chia nhỏ CIDR. Ví dụ cidrsubnet("10.0.0.0/16", 8, 0) = 10.0.0.0/24, cidrsubnet("10.0.0.0/16", 8, 1) = 10.0.1.0/24. Không cần tính tay!

Giải thích count = 2: Terraform sẽ tạo 2 subnets tự động, mỗi cái trong một AZ khác nhau. Sử dụng count.index để tham chiếu.

4. Security Groups

# security-groups.tf
resource "aws_security_group" "alb" {
  name_prefix = "${var.app_name}-alb-"
  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"]
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "ec2" {
  name_prefix = "${var.app_name}-ec2-"
  vpc_id      = aws_vpc.main.id

  # Cho phép traffic từ ALB
  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  # SSH (hạn chế IP)
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["your-ip/32"] # Thay bằng IP của bạn
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "rds" {
  name_prefix = "${var.app_name}-rds-"
  vpc_id      = aws_vpc.main.id

  # Chỉ cho phép EC2 kết nối MySQL
  ingress {
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.ec2.id]
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "redis" {
  name_prefix = "${var.app_name}-redis-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 6379
    to_port         = 6379
    protocol        = "tcp"
    security_groups = [aws_security_group.ec2.id]
  }

  lifecycle {
    create_before_destroy = true
  }
}

Giải thích lifecycle { create_before_destroy = true }: Khi thay đổi Security Group, Terraform sẽ tạo cái mới trước khi xóa cái cũ. Điều này tránh downtime do resource bị xóa trước khi resource thay thế sẵn sàng.

5. EC2 Instance

# ec2.tf
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

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

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "app" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = var.ec2_instance_type
  key_name               = var.ec2_key_name
  subnet_id              = aws_subnet.public[0].id
  vpc_security_group_ids = [aws_security_group.ec2.id]

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

  user_data = base64encode(templatefile("${path.module}/scripts/user-data.sh", {
    db_host     = aws_db_instance.mysql.address
    db_name     = "laravel"
    db_username = var.db_username
    db_password = var.db_password
    redis_host  = aws_elasticache_cluster.redis.cache_nodes[0].address
    s3_bucket   = aws_s3_bucket.storage.id
    app_name    = var.app_name
  }))

  tags = {
    Name = "${var.app_name}-server"
  }

  depends_on = [
    aws_db_instance.mysql,
    aws_elasticache_cluster.redis,
  ]
}

Giải thích data "aws_ami": Thay vì hardcode AMI ID (sẽ khác nhau theo region và thay đổi theo thời gian), chúng ta dùng data source để tự động lấy AMI mới nhất của Amazon Linux 2023.

Giải thích templatefile(): Hàm này cho phép truyền biến Terraform vào file script. Ví dụ trong user-data.sh, bạn dùng ${db_host} và nó sẽ tự động được thay bằng địa chỉ RDS thực tế.

Giải thích depends_on: Rõ ràng khai báo rằng EC2 phải đợi RDS và Redis tạo xong trước. Terraform thường tự detect dependency qua reference, nhưng user_data script cần address nên ta khai báo tường minh.

User Data Script

#!/bin/bash
# scripts/user-data.sh
set -euo pipefail

# Cài đặt Nginx + PHP 8.3
dnf update -y
dnf install -y nginx git unzip

# Cài PHP 8.3 từ Amazon Linux Extras
dnf install -y php8.3-fpm php8.3-cli php8.3-mbstring php8.3-xml \
    php8.3-curl php8.3-mysql php8.3-redis php8.3-zip php8.3-bcmath

# Cài Composer
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

# Cấu hình .env
cat > /var/www/laravel/.env << 'ENVFILE'
APP_NAME="${app_name}"
APP_ENV=production
APP_DEBUG=false
DB_HOST=${db_host}
DB_DATABASE=${db_name}
DB_USERNAME=${db_username}
DB_PASSWORD=${db_password}
CACHE_DRIVER=redis
REDIS_HOST=${redis_host}
AWS_BUCKET=${s3_bucket}
ENVFILE

# Start services
systemctl enable --now nginx php8.3-fpm

6. RDS MySQL

# rds.tf
resource "aws_db_subnet_group" "mysql" {
  name       = "${var.app_name}-db-subnet"
  subnet_ids = aws_subnet.private[*].id

  tags = {
    Name = "${var.app_name}-db-subnet"
  }
}

resource "aws_db_instance" "mysql" {
  identifier = "${var.app_name}-mysql"

  engine         = "mysql"
  engine_version = "8.0"
  instance_class = "db.t3.micro"

  allocated_storage     = 20
  max_allocated_storage = 100
  storage_type          = "gp3"
  storage_encrypted     = true

  db_name  = "laravel"
  username = var.db_username
  password = var.db_password

  db_subnet_group_name   = aws_db_subnet_group.mysql.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  multi_az            = var.environment == "production" ? true : false
  skip_final_snapshot = var.environment != "production"

  backup_retention_period = 7
  backup_window           = "03:00-04:00"
  maintenance_window      = "Mon:04:00-Mon:05:00"

  tags = {
    Name = "${var.app_name}-mysql"
  }
}

Giải thích max_allocated_storage = 100: Bật tính năng autoscaling cho storage. Khi ổ đĩa đầy 90%, RDS sẽ tự động mở rộng lên tối đa 100GB mà không cần downtime.

Giải thích biểu thức ternary: var.environment == "production" ? true : false — ở Production thì bật Multi-AZ (2 bản sao trong 2 AZ), còn Staging thì không (tiết kiệm tiền).

7. S3 và ElastiCache

# s3.tf
resource "aws_s3_bucket" "storage" {
  bucket = "${var.app_name}-storage-${var.environment}"

  tags = {
    Name = "${var.app_name}-storage"
  }
}

resource "aws_s3_bucket_versioning" "storage" {
  bucket = aws_s3_bucket.storage.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "storage" {
  bucket = aws_s3_bucket.storage.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

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

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
# elasticache.tf
resource "aws_elasticache_subnet_group" "redis" {
  name       = "${var.app_name}-redis-subnet"
  subnet_ids = aws_subnet.private[*].id
}

resource "aws_elasticache_cluster" "redis" {
  cluster_id           = "${var.app_name}-redis"
  engine               = "redis"
  engine_version       = "7.0"
  node_type            = "cache.t3.micro"
  num_cache_nodes      = 1
  parameter_group_name = "default.redis7"
  subnet_group_name    = aws_elasticache_subnet_group.redis.name
  security_group_ids   = [aws_security_group.redis.id]

  tags = {
    Name = "${var.app_name}-redis"
  }
}

8. ALB — Application Load Balancer

# 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[*].id

  tags = {
    Name = "${var.app_name}-alb"
  }
}

resource "aws_lb_target_group" "app" {
  name     = "${var.app_name}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    enabled             = true
    path                = "/up"
    port                = "traffic-port"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    matcher             = "200"
  }
}

resource "aws_lb_target_group_attachment" "app" {
  target_group_arn = aws_lb_target_group.app.arn
  target_id        = aws_instance.app.id
  port             = 80
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate.main.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

9. Outputs

# outputs.tf
output "alb_dns_name" {
  description = "DNS name của ALB"
  value       = aws_lb.main.dns_name
}

output "rds_endpoint" {
  description = "RDS endpoint"
  value       = aws_db_instance.mysql.address
}

output "redis_endpoint" {
  description = "ElastiCache endpoint"
  value       = aws_elasticache_cluster.redis.cache_nodes[0].address
}

output "s3_bucket_name" {
  description = "Tên S3 bucket"
  value       = aws_s3_bucket.storage.id
}

output "ec2_public_ip" {
  description = "Public IP của EC2"
  value       = aws_instance.app.public_ip
}

10. Chạy Terraform

# Khởi tạo (tải provider, cấu hình backend)
terraform init

# Xem trước những gì sẽ thay đổi
terraform plan

# Áp dụng
terraform apply

# Xem outputs
terraform output

# Xóa toàn bộ hạ tầng (cẩn thận!)
terraform destroy

Workflow thực tế

# 1. Tạo workspace cho mỗi môi trường
terraform workspace new staging
terraform workspace new production

# 2. Chuyển sang staging
terraform workspace select staging

# 3. Apply với file biến riêng
terraform apply -var-file="staging.tfvars"

# 4. Chuyển sang production
terraform workspace select production
terraform apply -var-file="production.tfvars"

Multi-Environment với Workspaces

Thay vì duplicate code, bạn dùng terraform.workspace để điều chỉnh:

locals {
  env_config = {
    staging = {
      ec2_type  = "t3.micro"
      rds_class = "db.t3.micro"
      multi_az  = false
    }
    production = {
      ec2_type  = "t3.small"
      rds_class = "db.t3.small"
      multi_az  = true
    }
  }

  config = local.env_config[terraform.workspace]
}

resource "aws_instance" "app" {
  instance_type = local.config.ec2_type
  # ...
}

resource "aws_db_instance" "mysql" {
  instance_class = local.config.rds_class
  multi_az       = local.config.multi_az
  # ...
}

Best Practices

1. State file an toàn

# Tạo S3 bucket và DynamoDB table cho state (chạy 1 lần)
resource "aws_s3_bucket" "tf_state" {
  bucket = "my-terraform-state-bucket"

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_dynamodb_table" "tf_locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

2. Sử dụng Modules cho tái sử dụng

# modules/laravel-stack/main.tf
module "laravel_production" {
  source = "./modules/laravel-stack"

  environment    = "production"
  instance_type  = "t3.small"
  db_instance    = "db.t3.small"
  # ...
}

module "laravel_staging" {
  source = "./modules/laravel-stack"

  environment    = "staging"
  instance_type  = "t3.micro"
  db_instance    = "db.t3.micro"
  # ...
}

3. Tích hợp CI/CD

# .github/workflows/terraform.yml
name: Terraform

on:
  push:
    branches: [main]
    paths: ['terraform/**']
  pull_request:
    branches: [main]
    paths: ['terraform/**']

jobs:
  terraform:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.5

      - name: Terraform Init
        run: terraform init
        working-directory: terraform/

      - name: Terraform Plan
        run: terraform plan -no-color
        working-directory: terraform/

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve
        working-directory: terraform/
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

So sánh 3 phương pháp Deploy

Console CLI Terraform
Thời gian setup lần đầu 2-4h 1-2h 3-5h
Thời gian lặp lại 2-4h 30 phút 5 phút
Dễ mắc lỗi Cao Trung bình Thấp
Team collaboration ⚠️
Phù hợp cho Học/test Script nhỏ Production

Kết luận

Terraform là bước tiến tự nhiên khi bạn đã quen với AWS Console và CLI. Lợi ích lớn nhất không phải là tự động hóa — mà là khả năng tái tạo hoàn toàn giống nhau mỗi lần deploy.

Khi server gặp sự cố, thay vì dành hàng giờ click Console để dựng lại, bạn chỉ cần terraform apply và đi pha cà phê.

Series tiếp theo: Nếu bạn muốn đi xa hơn nữa, hãy theo dõi bài viết về Laravel trên Serverless (AWS Lambda + Bref) — nơi bạn không cần quản lý server nào cả.

Bình luận