CloudPloy

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:

  1. In CloudPloy dashboard, go to Servers > Add Server > Bring Your Own Server
  2. Enter the server IP (from terraform output server_ip)
  3. Enter the SSH username (ubuntu for Ubuntu, ec2-user for Amazon Linux)
  4. CloudPloy installs Docker, Nginx, and its deployment agent via SSH
  5. 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.