Infrastructure as Code: Provisioning Secure Laravel Clusters on DigitalOcean Using Terraform
Terraform Project Structure and Provider Configuration
We’ll begin by establishing a robust Terraform project structure. This organization is crucial for managing complexity as your infrastructure grows. Our core configuration will reside in main.tf, while variables and outputs will be managed in separate files for clarity and reusability.
First, let’s define the DigitalOcean provider. This block tells Terraform that we intend to manage resources on DigitalOcean and specifies the authentication mechanism. For production environments, it’s highly recommended to use environment variables or a DigitalOcean Spaces credential file for your API token rather than hardcoding it directly in your Terraform files.
main.tf – Provider and Backend Configuration
# main.tf
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
}
}
# Remote state backend for collaboration and state locking
backend "s3" {
bucket = "your-terraform-state-bucket-name" # Replace with your S3 bucket name
key = "digitalocean/laravel-cluster/terraform.tfstate"
region = "us-east-1" # Replace with your S3 bucket region
endpoint = "s3.amazonaws.com" # Or your DigitalOcean Spaces endpoint if applicable
skip_region_validation = true
skip_credentials_validation = true
# Ensure AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables are set
# or use a shared credentials file.
}
}
provider "digitalocean" {
# It's best practice to set the token via the DIGITALOCEAN_ACCESS_TOKEN environment variable.
# token = "YOUR_DIGITALOCEAN_ACCESS_TOKEN"
}
variable "region" {
description = "DigitalOcean region for resources"
type = string
default = "nyc3"
}
variable "ssh_key_name" {
description = "Name of the SSH key to use for Droplets"
type = string
default = "my-terraform-ssh-key" # Ensure this key exists in your DO account
}
variable "droplet_size" {
description = "Size of the Droplets"
type = string
default = "s-2vcpu-4gb"
}
variable "droplet_image" {
description = "Droplet image slug"
type = string
default = "ubuntu-22-04-x64"
}
variable "droplet_count" {
description = "Number of web server Droplets"
type = number
default = 2
}
output "web_server_ips" {
description = "Public IP addresses of the web server Droplets"
value = digitalocean_droplet.web.*.ipv4_address
}
output "load_balancer_ip" {
description = "Public IP address of the Load Balancer"
value = digitalocean_loadbalancer.laravel_lb.ip
}
In this setup, we’ve configured Terraform to use the DigitalOcean provider and specified a remote state backend using AWS S3. This is crucial for team collaboration and for storing your infrastructure state securely and reliably. Remember to replace your-terraform-state-bucket-name with your actual S3 bucket name and ensure your AWS credentials are set as environment variables (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY).
We’ve also defined several input variables for region, SSH key name, Droplet size, image, and the number of web server instances. The ssh_key_name variable assumes you have an SSH key with that name already uploaded to your DigitalOcean account. If not, you’ll need to create one or adjust the variable. The outputs section exposes the public IP addresses of the web servers and the load balancer, which will be essential for accessing your application and for further configuration.
Provisioning Droplets for Web Servers and Database
Next, we’ll define the Droplets that will host our Laravel application. We’ll create a set of identical web server Droplets and a separate Droplet for the database. For simplicity in this example, we’ll use a managed PostgreSQL database on a separate Droplet. In a production scenario, consider using DigitalOcean’s managed PostgreSQL service for enhanced reliability and scalability.
main.tf – Droplet Resources
# main.tf (continued)
resource "digitalocean_droplet" "web" {
count = var.droplet_count
name = "laravel-web-${count.index + 1}"
region = var.region
size = var.droplet_size
image = var.droplet_image
ssh_keys = [data.digitalocean_ssh_key.default.id]
monitoring = true
ipv6 = true
private_networking = true
tags = ["laravel", "web", "terraform"]
connection {
type = "ssh"
user = "root"
private_key = file("~/.ssh/id_rsa") # Ensure this path is correct or use a variable
timeout = "2m"
}
provisioner "remote-exec" {
inline = [
"apt-get update -y",
"apt-get install -y software-properties-common",
"add-apt-repository ppa:ondrej/php -y",
"apt-get update -y",
"apt-get install -y php8.2 php8.2-cli php8.2-common php8.2-mysql php8.2-zip php8.2-gd php8.2-mbstring php8.2-curl php8.2-xml php8.2-bcmath php8.2-fpm nginx composer git",
"systemctl enable nginx",
"systemctl start nginx",
# Basic Nginx configuration for PHP-FPM (will be refined by load balancer config)
"echo '' > /var/www/html/info.php",
"chown www-data:www-data /var/www/html/info.php"
]
}
}
resource "digitalocean_droplet" "db" {
name = "laravel-db-1"
region = var.region
size = "s-2vcpu-4gb" # Consider a larger size for production DB
image = var.droplet_image
ssh_keys = [data.digitalocean_ssh_key.default.id]
monitoring = true
ipv6 = true
private_networking = true
tags = ["laravel", "database", "terraform"]
# Basic PostgreSQL installation (production requires more robust setup)
connection {
type = "ssh"
user = "root"
private_key = file("~/.ssh/id_rsa") # Ensure this path is correct or use a variable
timeout = "2m"
}
provisioner "remote-exec" {
inline = [
"apt-get update -y",
"apt-get install -y postgresql postgresql-contrib",
"systemctl enable postgresql",
"systemctl start postgresql",
# Basic user and database creation (DO NOT use in production without security hardening)
"sudo -u postgres psql -c \"CREATE USER laravel_user WITH PASSWORD 'secure_password';\"",
"sudo -u postgres psql -c \"CREATE DATABASE laravel_db OWNER laravel_user;\"",
"echo \"listen_addresses = '*'\" | sudo tee -a /etc/postgresql/14/main/postgresql.conf", # Adjust version if needed
"echo \"host all all 0.0.0.0/0 md5\" | sudo tee -a /etc/postgresql/14/main/pg_hba.conf", # Adjust version if needed
"systemctl restart postgresql"
]
}
}
# Data source to fetch the ID of an existing SSH key
data "digitalocean_ssh_key" "default" {
name = var.ssh_key_name
}
We’re using the digitalocean_droplet resource to create our instances. The count meta-argument on the web Droplet resource allows us to provision multiple identical web servers. Each Droplet is configured with a specific region, size, image, and importantly, the SSH key for access. Private networking is enabled for secure communication between Droplets.
The connection block within each Droplet resource defines how Terraform will connect to the instance via SSH. The provisioner "remote-exec" block is used to run initial setup commands. For the web servers, this includes updating packages, installing PHP 8.2, Nginx, Composer, and Git. For the database Droplet, it installs PostgreSQL and performs basic user and database creation. **Crucially, the database provisioning here is highly insecure and intended only for demonstration. In production, you must implement robust security measures for your database, including strong passwords, restricted access, and potentially using managed database services.**
The data "digitalocean_ssh_key" "default" block is a data source that retrieves the ID of an existing SSH key from your DigitalOcean account based on its name. This ID is then used when creating the Droplets.
Implementing a Load Balancer for High Availability
To ensure high availability and distribute traffic across our web servers, we’ll deploy a DigitalOcean Load Balancer. This will also handle SSL termination if configured.
main.tf – Load Balancer Resource
# main.tf (continued)
resource "digitalocean_loadbalancer" "laravel_lb" {
name = "laravel-lb"
region = var.region
droplet_tag = "laravel,web,terraform" # Targets Droplets with all these tags
vpc_uuid = digitalocean_vpc.main.id # Associate with our VPC
forwarding_rule {
entry_protocol = "http"
entry_port = 80
target_protocol = "http"
target_port = 80
target_backend = true
}
# Optional: Add HTTPS forwarding rule if you have SSL certificates managed by DO
# forwarding_rule {
# entry_protocol = "https"
# entry_port = 443
# target_protocol = "http" # Or "https" if your backend servers handle SSL
# target_port = 80
# certificate_id = "your-do-certificate-id" # Replace with your certificate ID
# ssl_passthrough = false
# target_backend = true
# }
healthcheck {
port = 80
protocol = "http"
path = "/" # Or a specific health check endpoint like /health
}
tags = ["laravel", "loadbalancer", "terraform"]
}
resource "digitalocean_vpc" "main" {
name = "laravel-vpc"
region = var.region
ip_range = "10.10.0.0/16"
}
resource "digitalocean_loadbalancer_droplet_mapping" "web_mapping" {
droplet_ids = digitalocean_droplet.web.*.id
loadbalancer_id = digitalocean_loadbalancer.laravel_lb.id
}
The digitalocean_loadbalancer resource creates a managed load balancer. We associate it with our Droplets using the droplet_tag argument, ensuring it only directs traffic to Droplets tagged with “laravel”, “web”, and “terraform”. A vpc_uuid is specified to tie the load balancer to a specific VPC. We define forwarding rules for HTTP traffic, directing it to port 80 on the backend servers. A health check is configured to monitor the status of the web servers.
We also define a digitalocean_vpc resource to create a Virtual Private Cloud for our infrastructure, ensuring private networking between Droplets. The digitalocean_loadbalancer_droplet_mapping resource explicitly links the created Droplets to the load balancer, which is a more explicit way to manage the association than relying solely on tags, especially in complex environments.
Configuring Nginx for Reverse Proxy and SSL
While the DigitalOcean Load Balancer can handle SSL termination, it’s often beneficial to configure Nginx on the web servers to act as a reverse proxy, forwarding requests to the Laravel application. This section outlines how to use Terraform to push a more refined Nginx configuration to the web servers.
main.tf – Nginx Configuration Provisioner
# main.tf (continued)
# Create a local Nginx configuration file
resource "local_file" "nginx_conf" {
content = <<-EOT
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html/public; # Assuming your Laravel public directory is here
index index.php index.html index.htm;
server_name _; # Catch-all server name
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; # Adjust PHP version if needed
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;
}
}
EOT
filename = "${path.module}/nginx.conf"
}
# Apply the Nginx configuration to each web server
resource "null_resource" "configure_nginx" {
count = var.droplet_count
triggers = {
always_run = timestamp() # Ensure this runs on every apply
}
connection {
type = "ssh"
user = "root"
host = digitalocean_droplet.web[count.index].ipv4_address
private_key = file("~/.ssh/id_rsa") # Ensure this path is correct or use a variable
timeout = "2m"
}
provisioner "file" {
source = local_file.nginx_conf.filename
destination = "/tmp/nginx.conf"
}
provisioner "remote-exec" {
inline = [
"mv /tmp/nginx.conf /etc/nginx/sites-available/default",
"systemctl restart nginx",
"systemctl restart php8.2-fpm" # Adjust PHP version if needed
]
}
depends_on = [
digitalocean_droplet.web,
digitalocean_loadbalancer.laravel_lb # Ensure LB is up before configuring Nginx
]
}
We use the local_file resource to create a local Nginx configuration file. This file defines a basic server block that listens on port 80, sets the document root to where your Laravel application's public directory will reside, and configures PHP-FPM for processing PHP requests. It also includes a standard block to deny access to hidden files.
The null_resource with remote-exec and file provisioners is then used to copy this configuration file to each web server and restart Nginx and PHP-FPM. The count meta-argument ensures this process is repeated for every web server Droplet. The depends_on block is critical here, ensuring that the Droplets are provisioned and the load balancer is available before attempting to configure Nginx on the servers.
Deploying the Laravel Application
With the infrastructure in place, the next step is to deploy your Laravel application. This typically involves cloning the repository, installing dependencies, and configuring environment variables. We can automate this using Terraform's provisioners.
main.tf - Application Deployment Provisioner
# main.tf (continued)
# Assuming your Laravel app is in a Git repository
resource "null_resource" "deploy_laravel" {
count = var.droplet_count
triggers = {
always_run = timestamp()
}
connection {
type = "ssh"
user = "root"
host = digitalocean_droplet.web[count.index].ipv4_address
private_key = file("~/.ssh/id_rsa") # Ensure this path is correct or use a variable
timeout = "5m" # Increased timeout for deployment
}
provisioner "remote-exec" {
inline = [
"apt-get update -y",
"apt-get install -y git", # Ensure git is installed
"rm -rf /var/www/html/*", # Clean up existing content
"git clone https://github.com/your-username/your-laravel-repo.git /var/www/html", # Replace with your repo URL
"cd /var/www/html",
"composer install --no-dev --optimize-autoloader",
"cp .env.example .env", # Create .env file
# IMPORTANT: You need a secure way to manage your .env file secrets.
# This example is insecure. Consider using environment variables on the server
# or a secrets management tool.
"sed -i 's/APP_NAME=.*/APP_NAME=\"My Laravel App\"/' .env",
"sed -i 's/APP_ENV=.*/APP_ENV=production/' .env",
"sed -i 's/APP_DEBUG=.*/APP_DEBUG=false/' .env",
"sed -i 's/APP_URL=.*/APP_URL=http://${digitalocean_loadbalancer.laravel_lb.ip}/' .env", # Set APP_URL to LB IP
"php artisan key:generate --force",
"php artisan cache:clear",
"php artisan config:clear",
"php artisan route:clear",
"chown -R www-data:www-data /var/www/html", # Ensure web server user has ownership
"chmod -R 755 /var/www/html/storage /var/www/html/bootstrap/cache" # Set correct permissions
]
}
depends_on = [
digitalocean_droplet.web,
null_resource.configure_nginx # Ensure Nginx is configured before deploying app
]
}
This null_resource is responsible for cloning your Laravel application from a Git repository, installing Composer dependencies, creating and configuring the .env file, and running necessary Artisan commands. **The handling of the .env file in this example is a significant security risk. In a production environment, you should never hardcode sensitive information like database credentials or API keys directly in your Terraform code or within the .env file provisioned this way. Instead, consider using environment variables set on the Droplets themselves, or integrate with a dedicated secrets management solution like HashiCorp Vault, AWS Secrets Manager, or DigitalOcean's own secrets management capabilities.**
We also set the APP_URL to the IP address of the load balancer, which is crucial for Laravel's URL generation. Permissions for storage and cache directories are set to ensure the web server can write to them. The depends_on block ensures that the Nginx configuration is applied before attempting to deploy the application.
Database Connection and Security Considerations
The Laravel application needs to connect to the PostgreSQL database. The connection details are typically stored in the .env file. As mentioned, managing these secrets securely is paramount.
.env File Security
# Example .env file content (generated by Terraform provisioner) APP_NAME=My Laravel App APP_ENV=production APP_DEBUG=false APP_URL=http://YOUR_LOAD_BALANCER_IP LOG_CHANNEL=stack LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=error DB_CONNECTION=pgsql DB_HOST=10.10.0.2 # Private IP of the DB Droplet DB_PORT=5432 DB_DATABASE=laravel_db DB_USERNAME=laravel_user DB_PASSWORD=secure_password # THIS IS INSECURE - DO NOT USE IN PRODUCTION
The example provisioner attempts to set the database credentials. However, directly embedding passwords like secure_password is a major security vulnerability. For a production setup:
- Use Environment Variables on Droplets: Instead of writing the password to
.env, configure the database credentials as environment variables directly on the web server Droplets. Your Laravel application can then read these variables usingenv('DB_PASSWORD'). - Secrets Management Tools: Integrate with tools like HashiCorp Vault, AWS Secrets Manager, or DigitalOcean's Secrets Manager. Terraform can fetch secrets from these services and inject them into the environment or the
.envfile securely. - Managed Database Services: Utilize DigitalOcean's Managed PostgreSQL. This service handles much of the security and operational overhead for you. You would then configure your application to connect to the managed database endpoint.
- Network Security: Ensure your database Droplet is only accessible from your web server Droplets via private networking. Avoid exposing the database directly to the public internet.
Terraform Workflow and Execution
To provision this infrastructure, follow these steps:
- Initialize Terraform: Navigate to your Terraform project directory in your terminal and run:
terraform init
This command downloads the necessary provider plugins and sets up the backend configuration. - Review the Plan: Before applying any changes, always review the execution plan:
terraform plan
This will show you exactly which resources Terraform will create, modify, or destroy. - Apply the Infrastructure: Once you are satisfied with the plan, apply the changes:
terraform apply
Terraform will prompt you to confirm the action. Typeyesto proceed. - Destroy Infrastructure: When you no longer need the infrastructure, you can destroy all provisioned resources:
terraform destroy
This command will also prompt for confirmation.
This comprehensive Terraform setup provides a foundation for a secure and scalable Laravel cluster on DigitalOcean. Remember to adapt the security configurations, especially for database credentials and sensitive application settings, to meet your specific production requirements.