
August 16, 2026
9 min read
Table of Contents
By Kokil Thapa | Last reviewed: August 2026
Managing server environments manually becomes unsustainable once your application grows beyond a single VPS. Infrastructure as Code with Terraform: A Practical Guide addresses this exact friction point by replacing ad-hoc SSH sessions and GUI clicks with version-controlled, reproducible configuration. For full-stack developers accustomed to Laravel or Symfony workflows, treating infrastructure like application code bridges the gap between development and operations without requiring a dedicated DevOps team. This guide focuses on practical implementation patterns that work for production web systems, avoiding theoretical abstractions in favor of configurations I use daily.
Before writing any HCL (HashiCorp Configuration Language), understand that Terraform is a provisioning tool, not a configuration management tool. It creates servers, databases, and load balancers but does not install PHP or configure Nginx directives inside them. In my experience shipping Laravel applications and legal-tech portals, the most reliable stack pairs Terraform for infrastructure provisioning with a separate tool like Ansible or Deployer for application deployment. Attempting to force application configuration into Terraform leads to slow feedback loops and fragile state files. If you are managing multiple client sites or need consistent staging environments, this separation of concerns is non-negotiable.
How does Infrastructure as Code with Terraform differ from manual provisioning?
Manual provisioning relies on tribal knowledge and browser-based console navigation. You log into AWS, DigitalOcean, or Hetzner, click through menus to create a droplet, attach a volume, and configure networking. This process is error-prone, unrepeatable, and impossible to audit. When a colleague needs to replicate your staging environment next month, they must guess at settings or read outdated wiki pages.
Terraform solves this through declarative state management. You describe the desired end state ("I need one Ubuntu 24.04 server with 4GB RAM and a managed PostgreSQL 16 database"), and Terraform calculates the necessary API calls to achieve it. Crucially, it stores the current reality in a state file. On subsequent runs, it compares your code against this state, generating an execution plan that shows exactly what will change before applying anything.
The practical difference emerges during incidents. When a production database fails at 2 AM, manual recovery depends on whoever originally set it up remembering the exact backup retention policy and replication settings. With Terraform, those parameters exist in code. You can inspect the repository history to see when and why a setting changed. For teams managing client projects with varying budgets, this auditability reduces bus factor risk significantly.
How do you structure Terraform modules for Laravel and PHP applications?
A common mistake among developers new to IaC is writing monolithic root configurations. Putting your VPC, database, app server, DNS, and SSL certificates in a single main.tf file works for tutorials but collapses under production complexity. Module composition is the architectural pattern that makes Terraform maintainable long-term.
Think of modules as reusable functions. A well-designed module encapsulates a logical unit of infrastructure with clear inputs and outputs. For a typical Laravel application running on Ubuntu 24.04 with PHP 8.4, I structure modules around three layers: networking, compute/data, and application glue.
# modules/laravel-app/main.tf
variable "app_name" {
type = string
description = "Application identifier used for resource naming"
}
variable "php_version" {
type = string
default = "8.4"
description = "PHP version for validation against supported matrix"
}
resource "digitalocean_droplet" "app" {
image = "ubuntu-24-04-x64"
name = "${var.app_name}-web"
region = "sgp1"
size = "s-2vcpu-4gb"
ssh_keys = [digitalocean_ssh_key.deployer.id]
user_data = templatefile("${path.module}/templates/cloud-init.yaml", {
php_version = var.php_version
app_env = var.environment
})
}
output "private_ip" {
value = digitalocean_droplet.app.ipv4_address_private
} This module accepts parameters rather than hardcoding values. The php_version variable allows validation blocks to enforce compatibility with Laravel 12's minimum requirements without editing resource definitions. Cloud-init handles base OS configuration, keeping Terraform focused solely on resource lifecycle.
- Networking module: VPC, subnets, firewall rules, NAT gateways. Changes infrequently and affects all downstream resources.
- Data module: Managed databases, Redis clusters, S3-compatible object storage. Encapsulates backup policies and access controls.
- Compute module: App servers, worker nodes, cron instances. Designed for horizontal scaling and immutable replacement.
- Glue module: Load balancers, DNS records, SSL certificates. Wires other modules together at the root level.
Root configurations should only compose these modules and pass environment-specific variables. This separation means updating your database module doesn't risk accidentally modifying firewall rules. When working on automated deployment pipelines, this modularity also enables parallel development where different team members own distinct infrastructure domains.
How should you manage Terraform state securely in production?
State management is where most Terraform implementations fail. The state file contains sensitive data including database passwords, API keys, and private IPs in plaintext. Storing it locally or committing it to Git exposes your entire infrastructure to anyone with repository access. Remote state backends with encryption and locking are mandatory for any production workload.
For AWS-hosted projects, S3 with DynamoDB locking remains the gold standard. The S3 bucket stores encrypted state versions with lifecycle policies for retention, while DynamoDB provides atomic locks preventing simultaneous applies. On DigitalOcean or Hetzner, alternatives include Terraform Cloud's free tier or self-hosted solutions like Terragrunt with PostgreSQL backends.
# backend.tf
terraform {
backend "s3" {
bucket = "tfstate-production-nepal-legaltech"
key = "laravel-app/terraform.tfstate"
region = "ap-south-1"
encrypt = true
dynamodb_table = "terraform-locks"
# Prevent accidental deletion
skip_metadata_api_check = false
}
} Never store secrets directly in state. Use data sources to fetch credentials from HashiCorp Vault, AWS Secrets Manager, or Doppler at runtime. Terraform 1.10+ supports ephemeral values that never persist to state files, solving a longstanding security gap. When integrating payment gateways like eSewa or Khalti for Nepali clients, store API keys externally and inject them via environment variables during deployment, not during infrastructure provisioning.
What are the best practices for handling secrets and sensitive variables?
Sensitive data handling separates hobbyist configurations from production-grade infrastructure. The fundamental rule is simple: if a value would cause damage if leaked, it belongs nowhere near your Terraform code or state file. This includes database passwords, API tokens, SSH private keys, and encryption keys.
| Approach | Security Level | Complexity | Best For |
|---|---|---|---|
| Environment Variables | Medium | Low | Local development, CI runners |
| TF_VAR_ prefixed vars | Low | Low | Non-sensitive configuration only |
| AWS Secrets Manager / SSM | High | Medium | AWS-native applications |
| HashiCorp Vault | Highest | High | Multi-cloud, compliance-heavy |
| Doppler / Infisical | High | Low | Teams wanting managed simplicity |
| SOPS + Git encryption | High | Medium | GitOps workflows, air-gapped |
In practice, I recommend Doppler or Infisical for most Nepal-based projects due to their low operational overhead and generous free tiers. They integrate directly with Terraform via provider blocks, eliminating custom scripting. For larger enterprises or regulated legal-tech platforms handling court documents, HashiCorp Vault provides dynamic secrets that automatically rotate after use.
# secrets.tf
data "doppler_secrets" "app" {
project = "legal-portal"
config = "production"
}
resource "digitalocean_database_cluster" "postgres" {
name = "legal-db-prod"
engine = "pg"
version = "16"
size = "db-s-2vcpu-4gb"
region = "sgp1"
node_count = 2
# Password injected at runtime, never in state
password = data.doppler_secrets.app.map.DB_PASSWORD
} Always mark sensitive outputs and variables with sensitive = true. This prevents Terraform from printing values in CLI output or logs. However, remember this is cosmetic protection only; the value still exists in state. True security requires external secret stores combined with IAM policies restricting who can read state files.
How do you integrate Terraform with existing CI/CD pipelines?
Terraform shines when embedded in automated pipelines rather than run manually from developer laptops. The standard workflow enforces plan-review-apply gates that prevent accidental destruction. For teams already using GitLab CI for deployment automation, adding Terraform stages follows familiar patterns.
The critical detail is saving the plan output as a binary artifact. Running terraform apply without referencing a saved plan risks drift between what was reviewed and what gets applied. Always use terraform apply plan.out in CI, never terraform apply -auto-approve on unprotected branches.
# .gitlab-ci.yml excerpt
terraform_plan:
stage: plan
script:
- terraform init -backend-config=backend.prod.hcl
- terraform plan -out=tfplan.binary -var-file=prod.tfvars
artifacts:
paths:
- tfplan.binary
expire_in: 7 days
terraform_apply:
stage: deploy
when: manual
dependencies:
- terraform_plan
script:
- terraform init -backend-config=backend.prod.hcl
- terraform apply -input=false tfplan.binary
only:
- main For teams managing multiple client environments, consider workspace isolation or separate state files per client. This prevents a misconfigured variable from affecting unrelated projects. On shared EC2 infrastructure used for sister sites like notarykathmandu.com and translationnepal.com, I use distinct state paths keyed by project name to maintain complete isolation while sharing CI runner resources.
Implementing Infrastructure as Code with Terraform Safely
Adopting Infrastructure as Code with Terraform transforms how you build and maintain production web systems. Start small by codifying a single non-critical environment before migrating production workloads. Invest time in proper module boundaries and remote state setup early; retrofitting these later costs exponentially more. Remember that Terraform provisions infrastructure but doesn't replace application deployment tools like Deployer or Ansible for PHP configuration.
The learning curve pays dividends quickly. Reproducible environments eliminate "works on my machine" debugging sessions. Version-controlled infrastructure enables meaningful code reviews for operational changes. Most importantly, documented-as-code systems survive team transitions and client handoffs gracefully. Whether you're building legal-tech portals or eCommerce platforms, treating infrastructure as a first-class engineering discipline reduces operational risk and accelerates delivery.
If you need help designing Terraform architectures for Laravel applications or migrating existing manual setups to IaC, reach out to discuss your infrastructure needs. I regularly help Nepal-based businesses and international clients establish sustainable DevOps practices that balance reliability with realistic budgets.

