Infrastructure as Code: Provisioning Secure Laravel Clusters on Linode Using Terraform
Terraform Provider Configuration for Linode
To begin provisioning infrastructure on Linode using Terraform, we first need to configure the Linode provider. This involves specifying your Linode API token and potentially a default region. The API token grants Terraform the necessary permissions to interact with your Linode account. It’s crucial to manage this token securely, ideally using environment variables or a dedicated secrets management system rather than hardcoding it directly into your Terraform configuration files.
Create a file named main.tf in your Terraform project directory. This file will house our provider configuration and subsequent resource definitions.
Here’s the basic provider block:
# main.tf
terraform {
required_providers {
linode = {
source = "linode/linode"
version = "~> 1.20" # Specify a version constraint for stability
}
}
}
provider "linode" {
# It's highly recommended to use environment variables for sensitive data
# export LINODE_TOKEN="your_linode_api_token"
token = var.linode_api_token
# Optionally, set a default region
# region = "us-east"
}
variable "linode_api_token" {
description = "Linode API Token"
type = string
sensitive = true # Mark as sensitive to prevent accidental exposure
}
# Example of setting a default region via variable
variable "linode_region" {
description = "The Linode region to deploy resources in."
type = string
default = "us-central"
}
provider "linode" {
token = var.linode_api_token
region = var.linode_region
}
To authenticate, you can set the LINODE_TOKEN environment variable before running Terraform commands, or pass the token as a variable. For production environments, using environment variables is the preferred and more secure method.
You can initialize Terraform and set the variable like this:
export LINODE_TOKEN="your_actual_linode_api_token" terraform init terraform apply -var="linode_api_token=$LINODE_TOKEN" -var="linode_region=us-west"
Provisioning a Secure Laravel Cluster: Load Balancer and Web Servers
A typical secure Laravel cluster on Linode will involve at least a load balancer and multiple web server instances. We’ll use Terraform to define these resources. For security, we’ll configure firewalls and ensure SSH access is restricted.
First, let’s define the Linode instances for our web servers. We’ll create a small cluster of two instances, each running Ubuntu and configured with cloud-init for initial setup.
# main.tf (continued)
resource "linode_instance" "web_server" {
count = 2 # Create two web server instances
label = "laravel-web-${count.index + 1}"
image = "linode/ubuntu22.04"
type = "g6-nanode-1" # Or choose an appropriate instance type
region = var.linode_region
root_pass = random_password.root_password[count.index].result # Use a generated password
# Cloud-init for initial setup (installing Nginx, PHP, etc.)
user_data = templatefile("${path.module}/cloud-init/web-server.yaml", {
ssh_public_key = var.ssh_public_key
})
tags = ["laravel", "web"]
lifecycle {
create_before_destroy = true
}
}
resource "random_password" "root_password" {
count = 2
length = 16
special = true
}
variable "ssh_public_key" {
description = "Your SSH public key for accessing instances."
type = string
sensitive = true
}
The user_data script (cloud-init/web-server.yaml) is critical for automating the initial setup of each web server. This script will install Nginx, PHP, and other necessary dependencies for Laravel. It also configures SSH access using your provided public key.
# cloud-init/web-server.yaml
#cloud-config
users:
- name: ubuntu
ssh_authorized_keys:
- ${ssh_public_key}
sudo: ALL=(ALL) NOPASSWD:ALL
groups: sudo
runcmd:
- apt update -y
- apt upgrade -y
- apt install -y nginx php-fpm php-mysql php-mbstring php-xml php-curl php-zip php-bcmath composer
- systemctl enable nginx
- systemctl start nginx
- systemctl enable php8.1-fpm # Adjust PHP version as needed
- systemctl start php8.1-fpm
# Further configuration for Nginx to serve Laravel applications will be done via separate Nginx config files
# or by a configuration management tool like Ansible.
Next, we’ll provision a Linode Load Balancer. This will distribute incoming traffic across our web servers. We’ll configure it to listen on ports 80 and 443 and forward traffic to the web servers on port 80.
# main.tf (continued)
resource "linode_firewall" "web_firewall" {
label = "laravel-web-firewall"
status = "enabled"
inbound_rules {
label = "Allow SSH"
protocol = "TCP"
ports = ["22"]
addresses {
ipv4 = ["0.0.0.0/0"] # Restrict this to your IP in production!
}
}
inbound_rules {
label = "Allow HTTP"
protocol = "TCP"
ports = ["80"]
addresses {
ipv4 = ["0.0.0.0/0"]
}
}
inbound_rules {
label = "Allow HTTPS"
protocol = "TCP"
ports = ["443"]
addresses {
ipv4 = ["0.0.0.0/0"]
}
}
outbound_rules {
label = "Allow All Outbound"
protocol = "ALL"
ports = ["0:65535"]
addresses {
ipv4 = ["0.0.0.0/0"]
}
}
}
resource "linode_instance" "load_balancer" {
label = "laravel-lb"
image = "linode/ubuntu22.04"
type = "g6-nanode-1" # A smaller instance type is usually sufficient for LB
region = var.linode_region
root_pass = random_password.lb_root_password.result
# Cloud-init for initial setup (installing HAProxy)
user_data = templatefile("${path.module}/cloud-init/load-balancer.yaml", {
ssh_public_key = var.ssh_public_key
})
tags = ["laravel", "loadbalancer"]
lifecycle {
create_before_destroy = true
}
}
resource "random_password" "lb_root_password" {
length = 16
special = true
}
# Associate firewall with web servers
resource "linode_firewall_device" "web_firewall_assoc" {
count = length(linode_instance.web_server)
firewall_id = linode_firewall.web_firewall.id
device_id = linode_instance.web_server[count.index].id
}
# Note: Linode Load Balancer service is a managed service and not provisioned via instance.
# We will use HAProxy on a dedicated instance for this example.
# For a managed Load Balancer, you would use the `linode_loadbalancer` resource.
# The following assumes HAProxy on a dedicated instance.
# If using Linode's managed Load Balancer, the configuration would look different.
# Example of using Linode's Managed Load Balancer (commented out for HAProxy example)
/*
resource "linode_loadbalancer" "main" {
label = "laravel-managed-lb"
region = var.linode_region
private_network = false # Set to true if using private networking
listener {
protocol = "http"
port = 80
target_port = 80
}
listener {
protocol = "https"
port = 443
target_port = 80
ssl_certificate_id = linode_certificate.laravel_ssl.id # Assuming SSL cert is provisioned
}
}
# Add web servers to the managed load balancer
resource "linode_loadbalancer_target" "web_servers" {
loadbalancer_id = linode_loadbalancer.main.id
target_pool = "default" # Or a custom pool name
instance_id = linode_instance.web_server[0].id
port = 80
}
resource "linode_loadbalancer_target" "web_servers_2" {
loadbalancer_id = linode_loadbalancer.main.id
target_pool = "default"
instance_id = linode_instance.web_server[1].id
port = 80
}
*/
The cloud-init/load-balancer.yaml script will install HAProxy and configure it to proxy traffic to the web servers. For production, you would typically use Linode’s managed Load Balancer service, which simplifies this considerably. The example above shows how to provision HAProxy on a dedicated instance. If you opt for the managed Load Balancer, you’d use the linode_loadbalancer and linode_loadbalancer_target resources.
# cloud-init/load-balancer.yaml
#cloud-config
users:
- name: ubuntu
ssh_authorized_keys:
- ${ssh_public_key}
sudo: ALL=(ALL) NOPASSWD:ALL
groups: sudo
runcmd:
- apt update -y
- apt upgrade -y
- apt install -y haproxy
- systemctl enable haproxy
- systemctl start haproxy
# HAProxy configuration will be managed separately or via a more advanced cloud-init script.
# For simplicity, we'll assume a basic HAProxy config is applied post-provisioning.
Important Security Note: The firewall rules above allow SSH from any IP address (0.0.0.0/0). In a production environment, you MUST restrict SSH access to only your trusted IP addresses or a bastion host. This can be achieved by changing ipv4 = ["0.0.0.0/0"] to ipv4 = ["YOUR_STATIC_IP/32"].
Database Cluster Provisioning (MySQL/PostgreSQL)
A robust Laravel application requires a reliable database. For this example, we’ll provision a managed MySQL database service on Linode. This offloads the operational burden of managing database servers.
# main.tf (continued)
resource "linode_database" "laravel_db" {
label = "laravel-mysql-db"
engine = "mysql"
version = "8.0" # Specify your desired MySQL version
plan = "db-s1-2gb" # Choose an appropriate database plan
region = var.linode_region
replication = {
enabled = true # Enable read replicas for high availability
# You can specify additional read replica configurations here
}
# For PostgreSQL, change engine to "postgresql" and version accordingly.
}
# Output database credentials (handle with care!)
output "database_host" {
description = "The hostname of the managed database."
value = linode_database.laravel_db.host
sensitive = true
}
output "database_port" {
description = "The port of the managed database."
value = linode_database.laravel_db.port
}
output "database_username" {
description = "The default username for the managed database."
value = linode_database.laravel_db.username
sensitive = true
}
output "database_password" {
description = "The default password for the managed database."
value = linode_database.laravel_db.password
sensitive = true
}
The linode_database resource provisions a managed database instance. It’s crucial to note that the credentials (host, port, username, password) are sensitive and should be handled securely. These outputs can be used to configure your Laravel application’s .env file.
For enhanced security, you’ll want to configure network access to the database. Managed databases on Linode typically have their own firewalling mechanisms. You’ll need to ensure that your web servers can connect to the database host and port. This often involves whitelisting the IP addresses of your web servers within the database’s network access control settings, which can be managed via the Linode Cloud Manager or potentially through additional Terraform resources if available for fine-grained network rules.
Configuring Nginx for Laravel and SSL
While cloud-init handles the initial installation of Nginx, the actual configuration for serving a Laravel application and enabling SSL requires more specific setup. This is often best managed by a configuration management tool like Ansible, Chef, or Puppet, or by using Terraform’s remote-exec provisioner for simpler setups. For this example, we’ll outline the Nginx configuration and how you might apply it.
A typical Nginx configuration for Laravel would involve:
- Setting the
rootdirective to your Laravel public directory (e.g.,/var/www/your-app/public). - Configuring
indexfiles (e.g.,index.php). - Using
try_filesto route requests toindex.php. - Setting up FastCGI to communicate with PHP-FPM.
- Enabling SSL with certificates.
Here’s a sample Nginx virtual host configuration:
# /etc/nginx/sites-available/your-laravel-app.conf
server {
listen 80;
server_name your-domain.com www.your-domain.com;
root /var/www/your-app/public; # Adjust path to your Laravel public directory
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
# Adjust socket path based on your PHP-FPM configuration
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
location ~ /\.ht {
deny all;
}
# Prevent access to hidden files
location ~ /\. {
deny all;
}
}
To apply this configuration using Terraform, you could use the remote-exec provisioner. However, this is generally discouraged for complex configurations as it can become brittle. A more robust approach is to use Ansible or similar tools, triggered by Terraform.
# main.tf (example using remote-exec, use with caution)
resource "null_resource" "configure_nginx" {
# This resource depends on the web servers being created
depends_on = [linode_instance.web_server]
connection {
type = "ssh"
user = "ubuntu" # Or your chosen user
private_key = file("~/.ssh/id_rsa") # Path to your private SSH key
host = linode_instance.web_server[0].ip_address # Connect to the first web server
}
provisioner "remote-exec" {
inline = [
"sudo apt update -y",
"sudo apt install -y nginx", # Ensure Nginx is installed
"sudo systemctl enable nginx",
"sudo systemctl start nginx",
# Upload the Nginx configuration file
"sudo tee /etc/nginx/sites-available/your-laravel-app.conf <<EOF
server {
listen 80;
server_name your-domain.com www.your-domain.com;
root /var/www/your-app/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;
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
include fastcgi_params;
}
}
EOF",
# Enable the site and reload Nginx
"sudo ln -sf /etc/nginx/sites-available/your-laravel-app.conf /etc/nginx/sites-enabled/",
"sudo rm -f /etc/nginx/sites-enabled/default", # Remove default site if necessary
"sudo nginx -t", # Test Nginx configuration
"sudo systemctl reload nginx"
]
}
}
For SSL, you would typically use Certbot to obtain Let’s Encrypt certificates. This can also be automated via cloud-init or a configuration management tool. The Nginx configuration would then be updated to listen on port 443 and include the SSL certificate paths.
Deployment Workflow and CI/CD Integration
With your Terraform infrastructure defined, the deployment workflow becomes streamlined. You’ll typically:
- Initialize Terraform:
terraform init - Plan the changes:
terraform plan - Apply the infrastructure:
terraform apply
For application deployments (your Laravel code), integrate this Terraform workflow into your CI/CD pipeline. When new code is pushed to your repository, the pipeline can trigger Terraform to provision or update infrastructure, and then deploy the application code to the provisioned web servers. Tools like GitLab CI, GitHub Actions, or Jenkins can orchestrate these steps.
A typical CI/CD pipeline might look like this:
# .gitlab-ci.yml (example for GitLab CI)
stages:
- deploy_infra
- deploy_app
variables:
TF_VAR_linode_api_token: $LINODE_API_TOKEN # Use GitLab CI/CD variables
TF_VAR_ssh_public_key: $SSH_PUBLIC_KEY
deploy_infrastructure:
stage: deploy_infra
image: hashicorp/terraform:latest
script:
- terraform init
- terraform plan -out=tfplan
- terraform apply -auto-approve tfplan
only:
- main # Deploy infrastructure only on pushes to the main branch
deploy_application:
stage: deploy_app
image: alpine:latest # Or an image with SSH client and deployment tools
script:
- apk add --no-cache openssh-client rsync # Install necessary tools
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - # Add SSH private key (stored as CI/CD variable)
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts # Add server host key to known_hosts
- rsync -avz --delete ./ your_user@${TF_VAR_web_server_ip}:/var/www/your-app/ # Sync your Laravel code
- ssh your_user@${TF_VAR_web_server_ip} "cd /var/www/your-app && composer install --no-dev --optimize-autoloader && php artisan cache:clear && php artisan config:clear && php artisan route:clear && php artisan view:clear" # Run post-deployment commands
only:
- main # Deploy application on pushes to the main branch
needs:
- deploy_infra # Ensure infrastructure is deployed first
variables:
TF_VAR_web_server_ip: $(terraform output -raw web_server_ip_address) # Assuming you output the web server IP
Remember to set up the necessary CI/CD variables (LINODE_API_TOKEN, SSH_PUBLIC_KEY, SSH_PRIVATE_KEY, SSH_KNOWN_HOSTS) in your CI/CD platform’s settings.