Infrastructure as Code: Provisioning Secure Laravel Clusters on AWS Using Terraform
Terraform Project Structure and Provider Configuration
We’ll start by establishing a robust Terraform project structure. This promotes maintainability and scalability. Our core configuration will reside in the main.tf file, defining resources and modules. The variables.tf file will house all input variables, ensuring flexibility and preventing hardcoding of sensitive information. Finally, outputs.tf will expose essential information about the provisioned infrastructure, such as IP addresses and ARNs.
The AWS provider configuration is paramount. We’ll specify the region and potentially the profile for authentication. For production environments, it’s highly recommended to use IAM roles attached to the EC2 instance running Terraform or leverage AWS Secrets Manager for credentials, rather than hardcoding access keys.
main.tf
# AWS Provider Configuration
provider "aws" {
region = var.aws_region
# profile = var.aws_profile # Uncomment and configure if using named profiles
}
# Terraform State Management (S3 Backend)
terraform {
backend "s3" {
bucket = "my-secure-laravel-tfstate-bucket" # Replace with your unique bucket name
key = "production/laravel-cluster/terraform.tfstate"
region = "us-east-1" # Must match your desired state bucket region
dynamodb_table = "my-secure-laravel-tfstate-lock" # Replace with your DynamoDB table name for state locking
encrypt = true
}
}
# VPC and Subnet Configuration
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "3.14.2" # Pin to a specific version for stability
name = "laravel-cluster-vpc"
cidr = "10.0.0.0/16"
azs = ["${var.aws_region}a", "${var.aws_region}b", "${var.aws_region}c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = true # For cost-effectiveness in smaller deployments
public_subnet_tags = {
"kubernetes.io/cluster/laravel-cluster" = "shared"
"k8s.io/cluster-autoscaler/enabled" = "true"
}
private_subnet_tags = {
"kubernetes.io/cluster/laravel-cluster" = "shared"
"k8s.io/cluster-autoscaler/enabled" = "true"
}
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
# Security Group for Load Balancer
module "lb_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "4.7.0"
name = "laravel-lb-sg"
description = "Security group for the ALB"
vpc_id = module.vpc.vpc_id
ingress_rules = [
{
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
]
egress_rules = [
{
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
]
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
# Security Group for Application Instances
module "app_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "4.7.0"
name = "laravel-app-sg"
description = "Security group for Laravel application instances"
vpc_id = module.vpc.vpc_id
ingress_rules = [
{
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [module.lb_sg.this_security_group_id] # Allow traffic from ALB
},
{
from_port = 22 # For SSH access (restrict to specific IPs in production)
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # WARNING: Restrict this in production!
}
]
egress_rules = [
{
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
]
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
# Application Load Balancer (ALB)
resource "aws_lb" "laravel_alb" {
name = "laravel-alb-${var.environment}"
internal = false
load_balancer_type = "application"
security_groups = [module.lb_sg.this_security_group_id]
subnets = module.vpc.public_subnets
enable_deletion_protection = false # Set to true for production
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_lb_listener" "http_listener" {
load_balancer_arn = aws_lb.laravel_alb.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.laravel_tg.arn
}
}
# Optional: HTTPS Listener with ACM Certificate
/*
resource "aws_lb_listener" "https_listener" {
load_balancer_arn = aws_lb.laravel_alb.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08" # Or a more recent policy
certificate_arn = var.acm_certificate_arn # ARN of your ACM certificate
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.laravel_tg.arn
}
}
*/
# Target Group for ALB
resource "aws_lb_target_group" "laravel_tg" {
name = "laravel-tg-${var.environment}"
port = 80
protocol = "HTTP"
vpc_id = module.vpc.vpc_id
target_type = "instance"
health_check {
path = "/" # Adjust to a health check endpoint in your Laravel app
protocol = "HTTP"
matcher = "200"
interval = 30
timeout = 5
healthy_threshold = 3
unhealthy_threshold = 3
}
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
# Auto Scaling Group for Laravel Instances
resource "aws_autoscaling_group" "laravel_asg" {
name = "laravel-asg-${var.environment}"
min_size = var.min_instances
max_size = var.max_instances
desired_capacity = var.desired_instances
vpc_zone_identifier = module.vpc.private_subnets # Launch instances in private subnets
launch_template {
id = aws_launch_template.laravel_lt.id
version = "$Latest"
}
target_group_arns = [aws_lb_target_group.laravel_tg.arn]
# Ensure instances are terminated gracefully
lifecycle {
create_before_destroy = true
}
tags = [
{
key = "Name"
value = "laravel-instance-${var.environment}"
propagate_at_launch = true
},
{
key = "Environment"
value = var.environment
propagate_at_launch = true
}
]
}
# Launch Template for EC2 Instances
resource "aws_launch_template" "laravel_lt" {
name_prefix = "laravel-lt-${var.environment}-"
image_id = data.aws_ami.ubuntu.id # Or your preferred AMI
instance_type = var.instance_type
key_name = var.ec2_key_pair_name # Ensure this key pair exists in your AWS account
network_interfaces {
associate_public_ip_address = false # Instances are in private subnets
security_groups = [module.app_sg.this_security_group_id]
}
user_data = base64encode(templatefile("${path.module}/scripts/user-data.sh", {
app_env = var.environment
# Add other variables needed by user-data script
}))
# IAM Instance Profile for EC2 instances to access other AWS services (e.g., S3, RDS)
iam_instance_profile {
arn = aws_iam_instance_profile.laravel_ec2_profile.arn
}
# Block device mappings (e.g., for root volume)
block_device_mappings {
device_name = "/dev/sda1" # Or /dev/xvda depending on AMI
ebs {
volume_size = 30 # Adjust as needed
volume_type = "gp3"
encrypted = true
}
}
tags {
Environment = var.environment
ManagedBy = "Terraform"
}
lifecycle {
create_before_destroy = true
}
}
# AMI Data Source
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical's owner ID for Ubuntu
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# IAM Role for EC2 Instances
resource "aws_iam_role" "laravel_ec2_role" {
name = "laravel-ec2-role-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
}
# IAM Policy for EC2 instances (Example: S3 access)
resource "aws_iam_policy" "laravel_ec2_s3_policy" {
name = "laravel-ec2-s3-policy-${var.environment}"
description = "Policy for EC2 instances to access S3 buckets"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
]
Effect = "Allow"
Resource = [
"arn:aws:s3:::your-laravel-storage-bucket", # Replace with your S3 bucket ARN
"arn:aws:s3:::your-laravel-storage-bucket/*"
]
}
]
})
}
# Attach S3 policy to the EC2 role
resource "aws_iam_role_policy_attachment" "attach_s3_policy" {
role = aws_iam_role.laravel_ec2_role.name
policy_arn = aws_iam_policy.laravel_ec2_s3_policy.arn
}
# IAM Instance Profile
resource "aws_iam_instance_profile" "laravel_ec2_profile" {
name = "laravel-ec2-profile-${var.environment}"
role = aws_iam_role.laravel_ec2_role.name
}
# RDS Database (Example: PostgreSQL)
resource "aws_db_instance" "laravel_db" {
allocated_storage = 20
engine = "postgres"
engine_version = "13.4"
instance_class = "db.t3.micro" # Choose appropriate instance class
identifier = "laravel-db-${var.environment}"
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.laravel_db_subnet_group.name
vpc_security_group_ids = [aws_security_group.laravel_db_sg.id]
skip_final_snapshot = true # Set to false for production
publicly_accessible = false
storage_encrypted = true
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
# RDS Subnet Group
resource "aws_db_subnet_group" "laravel_db_subnet_group" {
name = "laravel-db-subnet-group-${var.environment}"
subnet_ids = module.vpc.private_subnets
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
# Security Group for RDS
resource "aws_security_group" "laravel_db_sg" {
name = "laravel-db-sg-${var.environment}"
description = "Allow inbound traffic from application instances to RDS"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 5432 # PostgreSQL default port
to_port = 5432
protocol = "tcp"
security_groups = [module.app_sg.this_security_group_id] # Allow traffic from app instances
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
# ElastiCache Redis (Optional but recommended for performance)
resource "aws_elasticache_cluster" "laravel_cache" {
cluster_id = "laravel-cache-${var.environment}"
engine = "redis"
node_type_general = "cache.t3.micro" # Choose appropriate node type
num_cache_nodes = 1
parameter_group_name = "default.redis3.2"
engine_version = "3.2.10"
port = 6379
# Subnet group for ElastiCache
subnet_group_name = aws_elasticache_subnet_group.laravel_cache_subnet_group.name
# Security group for ElastiCache
security_group_ids = [aws_security_group.laravel_cache_sg.id]
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_elasticache_subnet_group" "laravel_cache_subnet_group" {
name = "laravel-cache-subnet-group-${var.environment}"
subnet_ids = module.vpc.private_subnets
}
resource "aws_security_group" "laravel_cache_sg" {
name = "laravel-cache-sg-${var.environment}"
description = "Allow inbound traffic from application instances to ElastiCache"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 6379 # Redis default port
to_port = 6379
protocol = "tcp"
security_groups = [module.app_sg.this_security_group_id] # Allow traffic from app instances
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
# SQS Queue for background jobs
resource "aws_sqs_queue" "laravel_queue" {
name = "laravel-jobs-${var.environment}"
# Enable FIFO if order is critical, otherwise standard is fine
# fifo_queue = true
# content_based_deduplication = true
tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
variables.tf
variable "aws_region" {
description = "The AWS region to deploy resources in."
type = string
default = "us-east-1"
}
variable "environment" {
description = "The deployment environment (e.g., dev, staging, prod)."
type = string
default = "dev"
}
variable "min_instances" {
description = "Minimum number of EC2 instances in the Auto Scaling Group."
type = number
default = 1
}
variable "max_instances" {
description = "Maximum number of EC2 instances in the Auto Scaling Group."
type = number
default = 3
}
variable "desired_instances" {
description = "Desired number of EC2 instances in the Auto Scaling Group."
type = number
default = 2
}
variable "instance_type" {
description = "EC2 instance type for the application servers."
type = string
default = "t3.micro"
}
variable "ec2_key_pair_name" {
description = "Name of the EC2 key pair for SSH access."
type = string
# default = "my-ssh-key" # Uncomment and set your key pair name
}
variable "db_username" {
description = "Username for the RDS database."
type = string
sensitive = true
default = "laravel_user"
}
variable "db_password" {
description = "Password for the RDS database."
type = string
sensitive = true
default = "supersecretpassword" # CHANGE THIS IN PRODUCTION
}
variable "acm_certificate_arn" {
description = "ARN of the ACM certificate for HTTPS."
type = string
default = null # Set this if you enable the HTTPS listener
}
# variable "aws_profile" {
# description = "AWS profile to use for authentication."
# type = string
# default = null
# }
outputs.tf
output "alb_dns_name" {
description = "The DNS name of the Application Load Balancer."
value = aws_lb.laravel_alb.dns_name
}
output "alb_zone_id" {
description = "The Zone ID of the Application Load Balancer."
value = aws_lb.laravel_alb.zone_id
}
output "app_security_group_id" {
description = "The ID of the application security group."
value = module.app_sg.this_security_group_id
}
output "db_endpoint" {
description = "The endpoint of the RDS database."
value = aws_db_instance.laravel_db.endpoint
}
output "db_name" {
description = "The name of the RDS database."
value = aws_db_instance.laravel_db.db_name
}
output "cache_endpoint" {
description = "The endpoint of the ElastiCache cluster."
value = aws_elasticache_cluster.laravel_cache.cache_nodes[0].address
}
output "cache_port" {
description = "The port of the ElastiCache cluster."
value = aws_elasticache_cluster.laravel_cache.cache_nodes[0].port
}
output "sqs_queue_url" {
description = "The URL of the SQS queue for background jobs."
value = aws_sqs_queue.laravel_queue.url
}
User Data Script for Instance Bootstrapping
The user-data.sh script is crucial for automating the setup of your EC2 instances. This script will be executed upon instance launch, installing necessary software, configuring the environment, and deploying your Laravel application. It’s designed to be idempotent, meaning it can be run multiple times without causing unintended side effects.
scripts/user-data.sh
#!/bin/bash
set -euxo pipefail
# --- Configuration Variables (passed from Terraform) ---
APP_ENV="${app_env}" # e.g., dev, staging, prod
# Add other variables as needed, e.g., DB_HOST, CACHE_HOST, SQS_QUEUE_URL
# --- System Updates and Package Installation ---
sudo apt-get update -y
sudo apt-get upgrade -y
# Install Nginx
sudo apt-get install -y nginx
# Install PHP and common extensions
sudo apt-get install -y php-fpm php-mysql php-pgsql php-mbstring php-xml php-curl php-zip php-bcmath php-intl php-redis php-opcache
# Install Composer
EXPECTED_CHECKSUM="$(wget -q -O - https://composer.github.io/installer.sig)"
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === '${EXPECTED_CHECKSUM}') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
php -r "unlink('composer-setup.php');"
# Install Supervisor for process management
sudo apt-get install -y supervisor
# --- Application Deployment ---
# Define application directory
APP_DIR="/var/www/laravel-app"
sudo mkdir -p ${APP_DIR}
sudo chown -R www-data:www-data ${APP_DIR}
cd ${APP_DIR}
# Clone your Laravel application from a repository (e.g., Git)
# IMPORTANT: Securely manage your repository credentials. Use SSH keys or tokens.
# Example using SSH key (ensure the key is added to the EC2 instance's SSH agent or authorized_keys)
# sudo git clone [email protected]:your-username/your-laravel-repo.git .
# Or using HTTPS with a token (less secure, consider alternatives)
# sudo git clone https://[email protected]/your-username/your-laravel-repo.git .
# For demonstration, we'll assume the code is already present or will be deployed via other means (e.g., CodeDeploy, Elastic Beanstalk).
# If cloning, ensure you handle the .git directory appropriately for production.
# Install Composer dependencies
sudo -u www-data composer install --no-dev --optimize-autoloader
# Create .env file from template and set environment variables
# IMPORTANT: Use AWS Secrets Manager or Parameter Store for sensitive data in production.
# For simplicity here, we're using placeholders.
sudo cp .env.example .env
# Dynamically set .env variables based on Terraform outputs or environment variables
# Example: Fetching DB_HOST, CACHE_HOST, SQS_QUEUE_URL from Terraform outputs or environment variables
# In a real scenario, you'd likely pass these as part of the user_data or use a configuration management tool.
# For this example, we'll use hardcoded placeholders that you MUST replace.
DB_HOST="your_rds_endpoint" # Replace with actual RDS endpoint from Terraform output
DB_PORT="5432"
DB_DATABASE="your_db_name" # Replace with actual DB name from Terraform output
DB_USERNAME="your_db_username" # Replace with actual DB username from Terraform output
DB_PASSWORD="your_db_password" # Replace with actual DB password from Terraform output
CACHE_HOST="your_redis_endpoint" # Replace with actual ElastiCache endpoint from Terraform output
CACHE_PORT="6379"
SQS_QUEUE_URL="your_sqs_queue_url" # Replace with actual SQS queue URL from Terraform output
# Update .env file
sudo sed -i "s/^APP_ENV=.*/APP_ENV=${APP_ENV}/" .env
sudo sed -i "s/^APP_URL=.*/APP_URL=http:\/\/localhost/" .env # Adjust if using a specific domain
sudo sed -i "s/^DB_HOST=.*/DB_HOST=${DB_HOST}/" .env
sudo sed -i "s/^DB_PORT=.*/DB_PORT=${DB_PORT}/" .env
sudo sed -i "s/^DB_DATABASE=.*/DB_DATABASE=${DB_DATABASE}/" .env
sudo sed -i "s/^DB_USERNAME=.*/DB_USERNAME=${DB_USERNAME}/" .env
sudo sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=${DB_PASSWORD}/" .env
sudo sed -i "s/^REDIS_HOST=.*/REDIS_HOST=${CACHE_HOST}/" .env
sudo sed -i "s/^REDIS_PORT=.*/REDIS_PORT=${CACHE_PORT}/" .env
sudo sed -i "s/^QUEUE_CONNECTION=.*/QUEUE_CONNECTION=sqs/" .env
sudo sed -i "s/^AWS_SQS_QUEUE=.*/AWS_SQS_QUEUE=${SQS_QUEUE_URL}/" .env
# Add other necessary .env configurations
# Generate application key
sudo -u www-data php artisan key:generate --force
# Run database migrations (ensure DB is accessible)
# sudo -u www-data php artisan migrate --force
# Clear cache
sudo -u www-data php artisan cache:clear
sudo -u www-data php artisan config:clear
sudo -u www-data php artisan route:clear
sudo -u www-data php artisan view:clear
# Set correct permissions
sudo chown -R www-data:www-data ${APP_DIR}
sudo chmod -R 775 ${APP_DIR}/storage ${APP_DIR}/bootstrap/cache
# --- Nginx Configuration ---
sudo rm /etc/nginx/sites-available/default
sudo tee /etc/nginx/sites-available/laravel.conf <<< EOF
server {
listen 80;
server_name _; # Listen on all hostnames
root ${APP_DIR}/public;
index index.php index.html index.htm;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; # Adjust PHP version if necessary
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
# Add logging and other configurations as needed
access_log /var/log/nginx/laravel.access.log;
error_log /var/log/nginx/laravel.error.log;
}
EOF
sudo ln -s /etc/nginx/sites-available/laravel.conf /etc/nginx/sites-enabled/laravel.conf
sudo nginx -t
sudo systemctl restart nginx
# --- Supervisor Configuration ---
sudo tee /etc/supervisor/conf.d/laravel-queue.conf <<< EOF
[program:laravel-queue]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/laravel-app/artisan queue:work --tries=3
autostart=true
autorestart=true
user=www-data
numprocs=4 ; Adjust based on your instance's CPU cores and workload
redirect_stderr=true
stdout_logfile=/var/log/supervisor/laravel-queue.log
EOF
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-queue:*
Terraform Workflow and Security Considerations
With the Terraform configuration in place, the deployment workflow is straightforward:
- Initialize: Run
terraform init. This downloads the necessary provider plugins and configures the backend for state management. Ensure your S3 bucket and DynamoDB table for state locking are created beforehand or managed by a separate Terraform configuration. - Plan: Execute
terraform plan. This command shows you a preview of the infrastructure changes Terraform will make, allowing you to review and verify before applying. - Apply: Run
terraform apply. This provisions the resources defined in your configuration. Terraform will prompt for confirmation after showing the execution plan. - Destroy: Use
terraform destroyto tear down all provisioned resources when no longer needed.
Security Best Practices:
- State File Security: The S3 backend for Terraform state is configured with
encrypt = true. Ensure the S3 bucket has appropriate bucket policies to restrict access, and the DynamoDB table is used for state locking to prevent concurrent modifications. - IAM Roles: Instead of using access keys directly, attach IAM roles to the EC2 instances running Terraform and to the application EC2 instances. This follows the principle of least privilege.
- Secrets Management: Sensitive data like database passwords and API keys should not be hardcoded in Terraform variables or scripts. Utilize AWS Secrets Manager or AWS Systems Manager Parameter Store and retrieve these values dynamically within your Terraform configuration or user-data scripts.
- Security Groups: The provided security groups are examples. In production, restrict ingress rules to the absolute minimum required. For SSH access (port 22), limit the CIDR blocks to your trusted IP ranges.
- EC2 Key Pairs: Ensure your EC2 key pair is managed securely and only accessible by authorized personnel.
- Database Encryption: The RDS instance is configured with
storage_encrypted = true. - HTTPS: For production, enable the HTTPS listener on the ALB and use a valid ACM certificate.
- Instance User Data: Be cautious about what information is embedded in the user data script. Sensitive credentials should be fetched securely at runtime.
- VPC Configuration: Deploying application instances in private subnets and using a NAT Gateway for outbound internet access enhances security.
By adhering to these practices and leveraging Terraform, you can provision a secure, scalable, and reproducible Laravel cluster on AWS.