Deploy Laravel to AWS with Terraform — Infrastructure as Code from A to Z
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.tfvarsfile contains sensitive information (DB passwords, secret keys). Add it to.gitignoreimmediately.
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 runapplysimultaneously.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.