Infrastructure as Code: Provisioning Secure PHP Clusters on AWS Using Terraform
Terraform: The Foundation for Secure PHP Cluster Deployment
Provisioning robust and secure infrastructure for PHP applications on AWS demands a systematic and repeatable approach. Infrastructure as Code (IaC) tools like Terraform are indispensable for achieving this. This post details a production-ready Terraform configuration for deploying a secure, scalable PHP cluster, focusing on best practices for networking, security groups, and resource management.
Core Terraform Configuration: VPC, Subnets, and Security Groups
We begin by defining the foundational network infrastructure. A Virtual Private Cloud (VPC) provides network isolation, and subnets within Availability Zones (AZs) ensure high availability. Crucially, security groups act as virtual firewalls, controlling inbound and outbound traffic to our instances.
Here’s the core Terraform code for setting up the VPC, public and private subnets across multiple AZs, and essential security groups:
VPC and Subnet Definition
# main.tf
# Configure AWS Provider
provider "aws" {
region = "us-east-1" # Replace with your desired region
}
# VPC
resource "aws_vpc" "php_cluster_vpc" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "php-cluster-vpc"
}
}
# Internet Gateway
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.php_cluster_vpc.id
tags = {
Name = "php-cluster-igw"
}
}
# Public Subnet (for NAT Gateways and potentially Bastion Hosts)
resource "aws_subnet" "public_subnet_az1" {
vpc_id = aws_vpc.php_cluster_vpc.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a" # Replace with your AZ
map_public_ip_on_launch = true
tags = {
Name = "php-cluster-public-az1"
}
}
resource "aws_subnet" "public_subnet_az2" {
vpc_id = aws_vpc.php_cluster_vpc.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b" # Replace with your AZ
map_public_ip_on_launch = true
tags = {
Name = "php-cluster-public-az2"
}
}
# Private Subnet (for application servers)
resource "aws_subnet" "private_subnet_az1" {
vpc_id = aws_vpc.php_cluster_vpc.id
cidr_block = "10.0.101.0/24"
availability_zone = "us-east-1a" # Replace with your AZ
tags = {
Name = "php-cluster-private-az1"
}
}
resource "aws_subnet" "private_subnet_az2" {
vpc_id = aws_vpc.php_cluster_vpc.id
cidr_block = "10.0.102.0/24"
availability_zone = "us-east-1b" # Replace with your AZ
tags = {
Name = "php-cluster-private-az2"
}
}
# Route Table for Public Subnets
resource "aws_route_table" "public_rt" {
vpc_id = aws_vpc.php_cluster_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}
tags = {
Name = "php-cluster-public-rt"
}
}
# Associate Public Subnets with Public Route Table
resource "aws_route_table_association" "public_assoc_az1" {
subnet_id = aws_subnet.public_subnet_az1.id
route_table_id = aws_route_table.public_rt.id
}
resource "aws_route_table_association" "public_assoc_az2" {
subnet_id = aws_subnet.public_subnet_az2.id
route_table_id = aws_route_table.public_rt.id
}
# NAT Gateway and EIP for Private Subnet Outbound Access
resource "aws_eip" "nat_eip_az1" {
domain = "vpc"
}
resource "aws_nat_gateway" "nat_gw_az1" {
allocation_id = aws_eip.nat_eip_az1.id
subnet_id = aws_subnet.public_subnet_az1.id
tags = {
Name = "php-cluster-nat-gw-az1"
}
depends_on = [aws_internet_gateway.gw]
}
resource "aws_eip" "nat_eip_az2" {
domain = "vpc"
}
resource "aws_nat_gateway" "nat_gw_az2" {
allocation_id = aws_eip.nat_eip_az2.id
subnet_id = aws_subnet.public_subnet_az2.id
tags = {
Name = "php-cluster-nat-gw-az2"
}
depends_on = [aws_internet_gateway.gw]
}
# Route Table for Private Subnets
resource "aws_route_table" "private_rt_az1" {
vpc_id = aws_vpc.php_cluster_vpc.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat_gw_az1.id
}
tags = {
Name = "php-cluster-private-rt-az1"
}
}
resource "aws_route_table" "private_rt_az2" {
vpc_id = aws_vpc.php_cluster_vpc.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat_gw_az2.id
}
tags = {
Name = "php-cluster-private-rt-az2"
}
}
# Associate Private Subnets with Private Route Tables
resource "aws_route_table_association" "private_assoc_az1" {
subnet_id = aws_subnet.private_subnet_az1.id
route_table_id = aws_route_table.private_rt_az1.id
}
resource "aws_route_table_association" "private_assoc_az2" {
subnet_id = aws_subnet.private_subnet_az2.id
route_table_id = aws_route_table.private_rt_az2.id
}
Security Group Definitions
# security_groups.tf
# Security Group for Load Balancer (e.g., ALB)
resource "aws_security_group" "alb_sg" {
name = "php-cluster-alb-sg"
description = "Allow HTTP/HTTPS inbound traffic to ALB"
vpc_id = aws_vpc.php_cluster_vpc.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS from anywhere"
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"]
}
tags = {
Name = "php-cluster-alb-sg"
}
}
# Security Group for PHP Application Servers
resource "aws_security_group" "app_sg" {
name = "php-cluster-app-sg"
description = "Allow traffic from ALB and internal communication"
vpc_id = aws_vpc.php_cluster_vpc.id
# Allow traffic from the ALB
ingress {
description = "HTTP from ALB"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb_sg.id]
}
# Allow SSH from a specific bastion host or internal network (highly recommended)
# For simplicity, this example allows SSH from a specific CIDR block.
# In production, restrict this to your management network or bastion host SG.
ingress {
description = "SSH from management network"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["YOUR_MANAGEMENT_IP_CIDR"] # e.g., "203.0.113.0/24"
}
# Allow internal communication between app servers (e.g., for clustering, caching)
# Adjust ports as needed for your PHP application's dependencies (e.g., Redis, Memcached)
ingress {
description = "Internal app communication"
from_port = 0 # Or specific ports like 6379 for Redis
to_port = 65535
protocol = "tcp"
self = true # Allows traffic from instances within this security group
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"] # Allow outbound access for updates, external APIs, etc.
}
tags = {
Name = "php-cluster-app-sg"
}
}
# Security Group for Database (e.g., RDS)
resource "aws_security_group" "db_sg" {
name = "php-cluster-db-sg"
description = "Allow traffic from App Servers to Database"
vpc_id = aws_vpc.php_cluster_vpc.id
# Allow database port from application servers
# Replace 3306 with your database's port (e.g., 5432 for PostgreSQL)
ingress {
description = "MySQL/MariaDB from App Servers"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.app_sg.id]
}
# Egress is often not strictly necessary for RDS if it's not initiating outbound connections
# but can be useful for specific scenarios.
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "php-cluster-db-sg"
}
}
Important Security Considerations:
- Replace
YOUR_MANAGEMENT_IP_CIDRwith your actual IP address range for SSH access. Never expose SSH to the entire internet (0.0.0.0/0). - The
self = truerule in the app security group is crucial for inter-instance communication. - Database security group rules should be as restrictive as possible, only allowing access from the application security group on the specific database port.
Provisioning Application Servers (EC2 Instances)
Now, let’s define the EC2 instances that will host our PHP application. We’ll use an Auto Scaling Group (ASG) to manage a fleet of instances, ensuring scalability and high availability. User data scripts will be used to configure the instances upon launch.
EC2 Launch Template and Auto Scaling Group
# ec2.tf
# Data source to get the latest Amazon Linux 2 AMI
data "aws_ami" "amazon_linux_2" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# User Data Script for PHP Application Server Configuration
locals {
user_data_script = <<-EOF
#!/bin/bash
# Update packages
yum update -y
# Install Apache, PHP, and common modules
yum install -y httpd php php-cli php-mysqlnd php-gd php-xml php-mbstring php-curl
# Enable and start Apache
systemctl enable httpd
systemctl start httpd
# Configure Apache to serve from a specific directory (e.g., /var/www/html/myapp)
# You would typically deploy your application code here via other means (e.g., CodeDeploy, S3 sync)
echo "Hello from PHP Cluster Instance
" > /var/www/html/index.html
# Example: Install Composer
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Example: Configure PHP settings (optional)
# sed -i 's/memory_limit = .*/memory_limit = 256M/' /etc/php.ini
# sed -i 's/upload_max_filesize = .*/upload_max_filesize = 64M/' /etc/php.ini
# Add any other necessary configurations here (e.g., database credentials, cache setup)
EOF
}
# EC2 Launch Template
resource "aws_launch_template" "php_app_lt" {
name_prefix = "php-app-lt-"
image_id = data.aws_ami.amazon_linux_2.id
instance_type = "t3.medium" # Choose an appropriate instance type
key_name = "your-ssh-key-pair-name" # Replace with your EC2 key pair name
# Attach the application security group
vpc_security_group_ids = [aws_security_group.app_sg.id]
# Assign to private subnets
subnet_id = aws_subnet.private_subnet_az1.id # ASG will handle distribution across AZs
user_data = base64encode(local.user_data_script)
# IAM instance profile for accessing other AWS services (e.g., S3, Secrets Manager)
# iam_instance_profile {
# name = aws_iam_instance_profile.app_profile.name
# }
# Add EBS volume configuration if needed
# block_device_mappings {
# device_name = "/dev/xvda"
# ebs {
# volume_size = 30
# volume_type = "gp3"
# }
# }
tags = {
Name = "php-app-server"
}
lifecycle {
create_before_destroy = true
}
}
# Auto Scaling Group
resource "aws_autoscaling_group" "php_app_asg" {
name_prefix = "php-app-asg-"
desired_capacity = 2
min_size = 1
max_size = 5
vpc_zone_identifier = [aws_subnet.private_subnet_az1.id, aws_subnet.private_subnet_az2.id] # Distribute across private subnets
launch_template {
id = aws_launch_template.php_app_lt.id
version = "$Latest"
}
# Health check configuration
health_check_type = "ELB" # Or EC2 if not using ELB
health_check_grace_period = 300 # Seconds
# Attach to ALB Target Group (defined later)
# target_group_arns = [aws_lb_target_group.php_app_tg.arn]
# Tags for instances launched by ASG
tags = [
{
key = "Name"
value = "php-app-server"
propagate_at_launch = true
},
{
key = "Environment"
value = "production"
propagate_at_launch = true
}
]
lifecycle {
create_before_destroy = true
}
}
Notes on EC2 Configuration:
- Replace
your-ssh-key-pair-namewith the name of your existing EC2 key pair. - The
user_datascript installs Apache, PHP, and essential modules. For production, consider using a more robust deployment strategy (e.g., AWS CodeDeploy, Ansible, or baking custom AMIs) to manage application code and configurations. - The
iam_instance_profileis commented out but essential if your PHP application needs to interact with other AWS services. You’ll need to define an IAM role and instance profile. - The
health_check_typeshould align with your load balancing strategy. If using an Application Load Balancer (ALB),ELBis appropriate.
Load Balancing with Application Load Balancer (ALB)
An ALB distributes incoming traffic across your EC2 instances, enhancing availability and fault tolerance. It also handles SSL termination, offloading this task from your application servers.
# alb.tf
# Application Load Balancer
resource "aws_lb" "php_alb" {
name = "php-cluster-alb"
internal = false # Set to true for internal-only ALB
load_balancer_type = "application"
security_groups = [aws_security_group.alb_sg.id]
subnets = [aws_subnet.public_subnet_az1.id, aws_subnet.public_subnet_az2.id] # ALB resides in public subnets
enable_deletion_protection = false # Set to true in production
tags = {
Name = "php-cluster-alb"
}
}
# ALB Listener for HTTP (redirect to HTTPS)
resource "aws_lb_listener" "http_listener" {
load_balancer_arn = aws_lb.php_alb.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
# ALB Listener for HTTPS
resource "aws_lb_listener" "https_listener" {
load_balancer_arn = aws_lb.php_alb.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08" # Choose an appropriate SSL policy
certificate_arn = "arn:aws:acm:us-east-1:123456789012:certificate/your-certificate-id" # Replace with your ACM certificate ARN
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.php_app_tg.arn
}
}
# Target Group for PHP Application Servers
resource "aws_lb_target_group" "php_app_tg" {
name = "php-app-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.php_cluster_vpc.id
health_check {
path = "/" # Health check endpoint for your application
protocol = "HTTP"
matcher = "200" # Expected HTTP status code
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 2
}
tags = {
Name = "php-app-tg"
}
}
# Associate ASG with Target Group
resource "aws_autoscaling_attachment" "php_app_asg_attachment" {
autoscaling_group_name = aws_autoscaling_group.php_app_asg.name
lb_target_group_arn = aws_lb_target_group.php_app_tg.arn
}
Key ALB Configurations:
- Replace
arn:aws:acm:us-east-1:123456789012:certificate/your-certificate-idwith your AWS Certificate Manager (ACM) ARN for SSL/TLS. - The
health_check.pathshould point to a URL in your application that returns a 200 OK status code when the application is healthy. - Ensure your
app_sgallows inbound traffic on port 80 from the ALB’s security group.
Database Provisioning (RDS Example)
For a production PHP cluster, a managed database service like AWS RDS is highly recommended. This example shows provisioning a MySQL RDS instance.
# rds.tf
# RDS Subnet Group
resource "aws_db_subnet_group" "php_db_subnet_group" {
name = "php-db-subnet-group"
subnet_ids = [aws_subnet.private_subnet_az1.id, aws_subnet.private_subnet_az2.id] # Place RDS in private subnets
tags = {
Name = "php-db-subnet-group"
}
}
# RDS Instance (MySQL Example)
resource "aws_db_instance" "php_rds_instance" {
allocated_storage = 20
engine = "mysql"
engine_version = "8.0" # Specify your desired MySQL version
instance_class = "db.t3.medium" # Choose an appropriate instance class
db_name = "php_app_db"
username = "admin"
password = "YOUR_SECURE_PASSWORD" # Use AWS Secrets Manager or Parameter Store for production
parameter_group_name = "default.mysql8.0"
db_subnet_group_name = aws_db_subnet_group.php_db_subnet_group.name
vpc_security_group_ids = [aws_security_group.db_sg.id]
skip_final_snapshot = true # Set to false in production
publicly_accessible = false # Must be false for security
tags = {
Name = "php-rds-instance"
}
}
Critical RDS Security:
- Never set
publicly_accessible = true. RDS instances should reside in private subnets and be accessed only by your application servers. - Replace
YOUR_SECURE_PASSWORDwith a strong, unique password. For production, integrate with AWS Secrets Manager or AWS Systems Manager Parameter Store to manage database credentials securely. - Ensure the
db_sgis correctly configured to allow inbound traffic from theapp_sgon the database port.
Deployment Workflow and Best Practices
With the Terraform code defined, the deployment process is straightforward:
- Initialize Terraform: Run
terraform initin your project directory to download necessary providers. - Plan Changes: Execute
terraform planto review the infrastructure changes Terraform will make. - Apply Changes: Run
terraform applyto provision the resources on AWS. Confirm with ‘yes’ when prompted. - Destroy Resources: When no longer needed, use
terraform destroyto tear down all provisioned infrastructure, avoiding ongoing costs.
Security Hardening and Further Enhancements
- IAM Roles: Utilize IAM roles with least-privilege permissions for EC2 instances instead of hardcoding access keys.
- Secrets Management: Integrate with AWS Secrets Manager or Parameter Store for database credentials, API keys, and other sensitive information.
- Logging and Monitoring: Configure CloudWatch Logs and Metrics for your EC2 instances, ALB, and RDS. Set up alarms for critical metrics.
- Patch Management: Implement a robust patch management strategy for your EC2 instances (e.g., using AWS Systems Manager Patch Manager).
- Immutable Infrastructure: Consider building custom AMIs with your application code pre-installed and updated, then deploying new instances from these AMIs rather than configuring existing ones.
- Bastion Host: For enhanced security, deploy a bastion host in a public subnet to act as a secure jump point for SSH access to your private instances.
- WAF Integration: Integrate AWS WAF with your ALB to protect against common web exploits.
This comprehensive Terraform configuration provides a secure, scalable, and highly available foundation for your PHP applications on AWS. By adhering to IaC principles and prioritizing security best practices, you can confidently manage and deploy your infrastructure.