Deploy Laravel to AWS with Terraform — Infrastructure as Code from A to Z

· 15 min read

If you've followed the Deploy Laravel to AWS via Console and Deploy via CLI series, you already know how to build infrastructure manually and with commands. But in practice, as infrastructure grows more complex and you need to manage multiple environments (staging, production, DR), you need a solution that is repeatable, version-controlled, and fully automated.

That's Infrastructure as Code (IaC) — and Terraform is the most popular tool for the job.

Why Terraform?

Criteria Console AWS CLI Terraform
Repeatable ⚠️ complex scripts terraform apply
Version control ⚠️ script files .tf files in Git
State management ✅ automatic tracking
Multi-environment ❌ must duplicate scripts terraform workspace
Drift detection terraform plan
Tear down infra ❌ click each one ⚠️ separate script terraform destroy

The core reason: Terraform lets you declare the desired infrastructure, instead of imperatively commanding each step. You say "I want 1 VPC, 2 subnets, 1 RDS, 1 EC2", and Terraform figures out the creation order, dependencies, and how to reach that state.

Prerequisites

# Install 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

# Configure AWS credentials
aws configure
# Or export environment variables
export AWS_ACCESS_KEY_ID="your-key"
export AWS_SECRET_ACCESS_KEY="your-secret"
export AWS_DEFAULT_REGION="ap-southeast-1"

Directory Structure

terraform/
├── main.tf              # Entry point, provider config
├── variables.tf         # Variable declarations
├── terraform.tfvars     # Variable values (DO NOT commit to Git)
├── outputs.tf           # Outputs after 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/             # (optional) reusable modules
    └── laravel-stack/

Important: The terraform.tfvars file contains sensitive information (DB passwords, secret keys). Add it to .gitignore immediately.

1. Provider and Backend Configuration

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

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

  # Store state file on S3 (recommended for teams)
  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"
    }
  }
}

Explanation:

  • backend "s3": State files shouldn't be stored locally when working in a team. Store on S3 + DynamoDB lock to prevent conflicts when two people run apply simultaneously.
  • default_tags: All resources will automatically be tagged, making cost management easier.
  • required_version: Ensures everyone uses the same Terraform version.

2. Variable Declarations

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

variable "environment" {
  description = "Environment: production, staging"
  type        = string
  default     = "production"
}

variable "app_name" {
  description = "Application name"
  type        = string
  default     = "laravel-app"
}

variable "vpc_cidr" {
  description = "CIDR block for 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 (DO NOT 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"

Explanation of sensitive = true: When you mark a variable as sensitive, Terraform will hide its value in the output of terraform plan and terraform apply. This is a best practice for all sensitive information.

3. VPC — The Network Foundation

# 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 (for 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 (for 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
}

Explanation of cidrsubnet(): This is a Terraform built-in function that automatically subdivides a CIDR block. For example, 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. No manual calculation needed!

Explanation of count = 2: Terraform will automatically create 2 subnets, each in a different AZ. Use count.index to reference them.

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

  # Allow traffic from ALB
  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  # SSH (restrict by IP)
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["your-ip/32"] # Replace with your IP
  }

  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

  # Only allow EC2 to connect to 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
  }
}

Explanation of lifecycle { create_before_destroy = true }: When modifying a Security Group, Terraform will create the new one before deleting the old one. This prevents downtime caused by a resource being deleted before its replacement is ready.

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,
  ]
}

Explanation of data "aws_ami": Instead of hardcoding an AMI ID (which varies by region and changes over time), we use a data source to automatically fetch the latest Amazon Linux 2023 AMI.

Explanation of templatefile(): This function passes Terraform variables into a script file. For example, in user-data.sh, you use ${db_host} and it will automatically be replaced with the actual RDS address.

Explanation of depends_on: Explicitly declares that EC2 must wait for RDS and Redis to be created first. Terraform usually auto-detects dependencies via references, but since the user_data script needs the addresses, we declare it explicitly.

User Data Script

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

# Install Nginx + PHP 8.3
dnf update -y
dnf install -y nginx git unzip

# Install PHP 8.3 from 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

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

# Configure .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"
  }
}

Explanation of max_allocated_storage = 100: Enables storage autoscaling. When the disk reaches 90% capacity, RDS will automatically expand up to 100GB with zero downtime.

Explanation of the ternary expression: var.environment == "production" ? true : false — in Production, Multi-AZ is enabled (2 replicas in 2 AZs), while Staging doesn't need it (saves money).

7. S3 and 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 = "ALB DNS name"
  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 = "S3 bucket name"
  value       = aws_s3_bucket.storage.id
}

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

10. Running Terraform

# Initialize (download providers, configure backend)
terraform init

# Preview what will change
terraform plan

# Apply changes
terraform apply

# View outputs
terraform output

# Destroy all infrastructure (be careful!)
terraform destroy

Real-world Workflow

# 1. Create a workspace for each environment
terraform workspace new staging
terraform workspace new production

# 2. Switch to staging
terraform workspace select staging

# 3. Apply with environment-specific vars file
terraform apply -var-file="staging.tfvars"

# 4. Switch to production
terraform workspace select production
terraform apply -var-file="production.tfvars"

Multi-Environment with Workspaces

Instead of duplicating code, use terraform.workspace to adjust configuration:

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. Secure State Files

# Create S3 bucket and DynamoDB table for state (run once)
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. Use Modules for Reusability

# 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. CI/CD Integration

# .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 }}

Comparing the 3 Deployment Methods

Console CLI Terraform
Initial setup time 2-4h 1-2h 3-5h
Repeat deployment time 2-4h 30 min 5 min
Error-prone High Medium Low
Team collaboration ⚠️
Best for Learning/testing Small scripts Production

Conclusion

Terraform is the natural next step once you're comfortable with AWS Console and CLI. The biggest benefit isn't automation — it's the ability to perfectly reproduce your infrastructure every single time.

When your server has an issue, instead of spending hours clicking through the Console to rebuild it, you just run terraform apply and go grab a coffee.

Next up: If you want to go even further, check out our post on Laravel on Serverless (AWS Lambda + Bref) — where you don't need to manage any servers at all.

Comments