Terraform and CloudPloy - Infrastructure as Code for PHP Applications
Terraform lets you define infrastructure in code: which cloud servers to provision, what network configuration to apply, which DNS records to create. CloudPloy then manages the deployment layer on top of those servers - building Docker images from your code, running containers, handling SSL certificates, and managing databases.
This page covers how to use Terraform to provision cloud servers that CloudPloy can manage, common patterns for multi-environment setups, and when Terraform adds value versus when CloudPloy's dashboard is sufficient.
The Division of Responsibility
Understanding what Terraform manages versus what CloudPloy manages prevents confusion about where to make changes:
| Layer | Managed By | Examples |
|---|---|---|
| Cloud infrastructure | Terraform (or cloud provider console) | EC2 instance, VPC, security groups, DNS records, S3 buckets |
| Server configuration | CloudPloy | Docker installation, Nginx config, firewall rules, SSL certificates |
| Application deployment | CloudPloy | Container builds, deployments, environment variables, databases, cron jobs |
| Application code | Your Git repository | Dockerfile, application code, composer.json |
CloudPloy's "Bring Your Own Server" (BYOS) feature lets you provision a server however you choose - Terraform, a cloud provider console, or manually - and then connect it to CloudPloy via SSH. CloudPloy installs its agent, configures Nginx and Docker, and from that point manages deployments.
Provisioning an AWS EC2 Server for CloudPloy
A Terraform configuration to provision an EC2 instance ready for CloudPloy connection:
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# SSH key for CloudPloy to connect
resource "aws_key_pair" "cloudploy" {
key_name = "cloudploy-${var.environment}"
public_key = var.ssh_public_key
}
# Security group - allow SSH from CloudPloy agent, HTTP/HTTPS from anywhere
resource "aws_security_group" "cloudploy_server" {
name = "cloudploy-${var.environment}"
description = "CloudPloy managed server"
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # Restrict to CloudPloy IPs in production
}
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"]
}
tags = {
Name = "cloudploy-${var.environment}"
Environment = var.environment
ManagedBy = "terraform"
}
}
# EC2 instance
resource "aws_instance" "app_server" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
key_name = aws_key_pair.cloudploy.key_name
vpc_security_group_ids = [aws_security_group.cloudploy_server.id]
root_block_device {
volume_type = "gp3"
volume_size = var.disk_size_gb
encrypted = true
}
tags = {
Name = "cloudploy-app-${var.environment}"
Environment = var.environment
ManagedBy = "terraform"
}
}
# Latest Ubuntu 24.04 LTS AMI (CloudPloy-compatible OS)
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}
# Elastic IP for stable DNS target
resource "aws_eip" "app_server" {
instance = aws_instance.app_server.id
domain = "vpc"
tags = {
Name = "cloudploy-app-${var.environment}"
Environment = var.environment
}
}
output "server_ip" {
value = aws_eip.app_server.public_ip
description = "Public IP to use when adding server to CloudPloy"
} # variables.tf
variable "aws_region" {
default = "us-east-1"
}
variable "environment" {
description = "staging or production"
}
variable "instance_type" {
description = "t3.small for dev, t3.medium+ for production"
default = "t3.medium"
}
variable "disk_size_gb" {
default = 40
}
variable "ssh_public_key" {
description = "Public key to add to the server for CloudPloy SSH access"
sensitive = true
} Connecting the Provisioned Server to CloudPloy
After terraform apply outputs the server IP:
- In CloudPloy dashboard, go to Servers > Add Server > Bring Your Own Server
- Enter the server IP (from
terraform output server_ip) - Enter the SSH username (
ubuntufor Ubuntu,ec2-userfor Amazon Linux) - CloudPloy installs Docker, Nginx, and its deployment agent via SSH
- The server is now ready to deploy applications
Provisioning Amazon Lightsail
Lightsail is simpler and cheaper than EC2 for PHP applications under moderate traffic. Predictable monthly pricing: $5/month for 1GB RAM, $10/month for 2GB RAM.
resource "aws_lightsail_instance" "app_server" {
name = "cloudploy-${var.environment}"
availability_zone = "${var.aws_region}a"
blueprint_id = "ubuntu_22_04"
bundle_id = var.lightsail_bundle # "nano_3_0", "micro_3_0", "small_3_0", etc.
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
# Static IP (equivalent to Elastic IP on Lightsail)
resource "aws_lightsail_static_ip" "app_server" {
name = "cloudploy-${var.environment}-ip"
}
resource "aws_lightsail_static_ip_attachment" "app_server" {
static_ip_name = aws_lightsail_static_ip.app_server.name
instance_name = aws_lightsail_instance.app_server.name
}
# Open HTTP/HTTPS ports (Lightsail has its own firewall)
resource "aws_lightsail_instance_public_ports" "app_server" {
instance_name = aws_lightsail_instance.app_server.name
port_info {
protocol = "tcp"
from_port = 80
to_port = 80
}
port_info {
protocol = "tcp"
from_port = 443
to_port = 443
}
port_info {
protocol = "tcp"
from_port = 22
to_port = 22
}
}
output "server_ip" {
value = aws_lightsail_static_ip.app_server.ip_address
} DigitalOcean Droplets
DigitalOcean's Terraform provider is straightforward. Droplets start at $6/month for 1GB RAM.
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
}
}
}
provider "digitalocean" {
token = var.do_token
}
resource "digitalocean_ssh_key" "cloudploy" {
name = "cloudploy-${var.environment}"
public_key = var.ssh_public_key
}
resource "digitalocean_droplet" "app_server" {
name = "cloudploy-${var.environment}"
size = var.droplet_size # "s-1vcpu-2gb", "s-2vcpu-4gb", etc.
image = "ubuntu-24-04-x64"
region = var.do_region
ssh_keys = [digitalocean_ssh_key.cloudploy.id]
tags = ["cloudploy", var.environment]
}
resource "digitalocean_reserved_ip" "app_server" {
droplet_id = digitalocean_droplet.app_server.id
region = var.do_region
}
output "server_ip" {
value = digitalocean_reserved_ip.app_server.ip_address
} Multi-Environment Setup
A common pattern for agencies and SaaS products: separate Terraform workspaces (or directories) for staging and production environments.
# Directory structure
infrastructure/
modules/
cloudploy-server/
main.tf
variables.tf
outputs.tf
environments/
staging/
main.tf # Uses module, sets staging vars
terraform.tfvars
production/
main.tf # Uses module, sets production vars
terraform.tfvars # modules/cloudploy-server/main.tf
# Reusable module that provisions a server for CloudPloy
variable "environment" { }
variable "instance_type" { default = "t3.medium" }
variable "disk_size_gb" { default = 40 }
variable "aws_region" { default = "us-east-1" }
variable "ssh_public_key" { sensitive = true }
# ... EC2 instance, security group, EIP resources ...
output "server_ip" {
value = aws_eip.app_server.public_ip
} # environments/staging/main.tf
module "staging_server" {
source = "../../modules/cloudploy-server"
environment = "staging"
instance_type = "t3.small" # Cheaper for staging
disk_size_gb = 20
ssh_public_key = var.ssh_public_key
}
output "staging_ip" {
value = module.staging_server.server_ip
}
# environments/production/main.tf
module "production_server" {
source = "../../modules/cloudploy-server"
environment = "production"
instance_type = "t3.xlarge" # Sized for production load
disk_size_gb = 100
ssh_public_key = var.ssh_public_key
} Managing Terraform State
Terraform state tracks the current real-world infrastructure. For team environments, store state remotely so multiple developers and CI pipelines can share it.
S3 Backend (AWS)
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "cloudploy/production/terraform.tfstate"
region = "us-east-1"
encrypt = true
# Optional: state locking to prevent concurrent applies
dynamodb_table = "terraform-state-lock"
}
} # Create the S3 bucket and DynamoDB table first (before configuring backend)
aws s3api create-bucket \
--bucket mycompany-terraform-state \
--region us-east-1
aws s3api put-bucket-versioning \
--bucket mycompany-terraform-state \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
--bucket mycompany-terraform-state \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST Terraform Cloud (Free Tier)
Terraform Cloud provides remote state storage, a UI for reviewing plans, and team access controls for free (up to 5 users):
terraform {
cloud {
organization = "your-org-name"
workspaces {
name = "cloudploy-production"
}
}
} DNS Configuration with Terraform
After CloudPloy provisions SSL and configures Nginx, point DNS to the server IP. Terraform can manage DNS records if you use Route 53, Cloudflare, or DigitalOcean DNS:
# Route 53
resource "aws_route53_record" "app" {
zone_id = data.aws_route53_zone.main.zone_id
name = "myapp.com"
type = "A"
ttl = 60
records = [module.production_server.server_ip]
}
resource "aws_route53_record" "www" {
zone_id = data.aws_route53_zone.main.zone_id
name = "www.myapp.com"
type = "CNAME"
ttl = 60
records = ["myapp.com"]
} # Cloudflare
resource "cloudflare_record" "app" {
zone_id = var.cloudflare_zone_id
name = "myapp.com"
type = "A"
value = module.production_server.server_ip
proxied = false # Don't proxy - let CloudPloy's SSL handle TLS
ttl = 60
} Set proxied = false (or equivalent for your DNS provider) when pointing to a CloudPloy server. CloudPloy manages TLS termination directly; routing through a CDN proxy alongside CloudPloy's SSL can cause certificate validation conflicts.
Automating Server Registration via CloudPloy CLI
After provisioning a server with Terraform, you normally register it with CloudPloy through the dashboard. For fully automated pipelines (useful for spinning up temporary staging environments), use CloudPloy's CLI:
# Install CloudPloy CLI
npm install -g @cloudploy/cli
# Authenticate
cloudploy auth login --token $CLOUDPLOY_API_TOKEN
# Register the server after terraform apply
SERVER_IP=$(terraform output -raw server_ip)
cloudploy server add \
--ip "$SERVER_IP" \
--ssh-user ubuntu \
--ssh-key ~/.ssh/id_rsa \
--name "staging-$(date +%Y%m%d)"
# Wait for server setup to complete (typically 3-5 minutes)
cloudploy server wait --name "staging-$(date +%Y%m%d)" This enables GitOps workflows where a pull request opens triggers a temporary staging environment: Terraform provisions the server, CloudPloy CLI registers it, and the app deploys automatically on merge to the feature branch.
When Terraform Is (and Isn't) Worth It
Terraform adds value in these scenarios:
- Multiple servers across environments: Staging, production, and disaster recovery servers defined consistently in code
- Team-provisioned infrastructure: Any developer can provision a new environment without cloud console access
- Ephemeral staging environments: Create a server for a PR, destroy it after merge
- Audit requirements: Infrastructure changes tracked in git history with reviewer approval
- Additional cloud resources: S3 buckets, CloudFront distributions, RDS instances that supplement CloudPloy
Terraform adds unnecessary complexity for:
- Single-server setups: If you have one production server, creating it through the CloudPloy dashboard or cloud console once is simpler than maintaining Terraform state
- Infrequently changed infrastructure: If you provision a server once and never change it, the overhead of Terraform isn't justified
- Application-level configuration: Environment variables, deployments, cron jobs, and database management belong in CloudPloy's dashboard, not Terraform
Common Terraform Patterns for PHP Applications
S3 Bucket for Media Storage
WordPress and Laravel applications that store user-uploaded media should use S3 rather than server-local storage (which is lost when a container restarts). Terraform provisions the bucket; CloudPloy sets the credentials as environment variables:
resource "aws_s3_bucket" "media" {
bucket = "${var.app_name}-media-${var.environment}"
}
resource "aws_s3_bucket_public_access_block" "media" {
bucket = aws_s3_bucket.media.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
resource "aws_s3_bucket_policy" "media_public_read" {
bucket = aws_s3_bucket.media.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.media.arn}/uploads/*"
}]
})
}
# IAM user for application S3 access
resource "aws_iam_user" "app_s3" {
name = "${var.app_name}-s3-${var.environment}"
}
resource "aws_iam_access_key" "app_s3" {
user = aws_iam_user.app_s3.name
}
resource "aws_iam_user_policy" "app_s3" {
user = aws_iam_user.app_s3.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
Resource = [
aws_s3_bucket.media.arn,
"${aws_s3_bucket.media.arn}/*"
]
}]
})
}
output "s3_bucket_name" { value = aws_s3_bucket.media.bucket }
output "aws_access_key" { value = aws_iam_access_key.app_s3.id }
output "aws_secret_key" { value = aws_iam_access_key.app_s3.secret; sensitive = true } After applying, set the outputs as environment variables in CloudPloy:
AWS_S3_BUCKET=$(terraform output -raw s3_bucket_name)
AWS_ACCESS_KEY_ID=$(terraform output -raw aws_access_key)
AWS_SECRET_ACCESS_KEY=$(terraform output -raw aws_secret_key)
# Set in CloudPloy via CLI
cloudploy env set AWS_S3_BUCKET="$AWS_S3_BUCKET" --app my-wordpress-site
cloudploy env set AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" --app my-wordpress-site
cloudploy env set AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" --app my-wordpress-site CloudFront CDN for Static Assets
resource "aws_cloudfront_distribution" "assets" {
enabled = true
comment = "${var.app_name} assets CDN"
origin {
domain_name = aws_s3_bucket.media.bucket_regional_domain_name
origin_id = "s3-media"
}
default_cache_behavior {
target_origin_id = "s3-media"
viewer_protocol_policy = "redirect-to-https"
cached_methods = ["GET", "HEAD"]
allowed_methods = ["GET", "HEAD"]
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 86400 # 1 day
max_ttl = 31536000 # 1 year
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate { cloudfront_default_certificate = true }
}
output "cdn_domain" {
value = aws_cloudfront_distribution.assets.domain_name
} Need help with server provisioning for CloudPloy? Read the Docker on CloudPloy guide for container configuration details, or contact CloudPloy support to discuss your infrastructure architecture.