Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Infrastructure as Code with Terraform: A Practical Guide

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.

Manual ProvisioningGUI / SSHCloud Provider❌ No Audit Trail • ❌ Drift Prone • ❌ SlowTerraform IaC WorkflowHCL CodeTerraform CoreState FileCloud API✅ Versioned • ✅ Reproducible • ✅ Auditable
Manual provisioning lacks feedback loops, while Infrastructure as Code with Terraform uses state to ensure desired configuration matches reality.

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.

Developer Aterraform applyCI PipelineGitLab CI JobRemote BackendEncrypted State StoreDynamoDB Lock TableCloud Provider APIsComputeDatabaseNetworkStorage
Secure remote state architecture prevents concurrent modifications and encrypts sensitive infrastructure metadata at rest.

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.

ApproachSecurity LevelComplexityBest For
Environment VariablesMediumLowLocal development, CI runners
TF_VAR_ prefixed varsLowLowNon-sensitive configuration only
AWS Secrets Manager / SSMHighMediumAWS-native applications
HashiCorp VaultHighestHighMulti-cloud, compliance-heavy
Doppler / InfisicalHighLowTeams wanting managed simplicity
SOPS + Git encryptionHighMediumGitOps 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.

Git PushMerge Requestor Main Branchterraform fmtterraform validateterraform planSave plan artifactManual ReviewApprove Planin MR Commentsterraform apply-input=falseplan.outUpdate StateLiveInfra⚠️ Never auto-apply on merge to main without explicit approval gate
Production CI/CD pipeline enforces human review between Terraform plan generation and infrastructure modification.

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.

Frequently Asked Questions

Terraform defines cloud infrastructure using declarative HCL configuration files instead of manual console clicks. It provisions, modifies, and versions resources like servers, databases, and networks through code, enabling reproducible environments and eliminating configuration drift across development, staging, and production systems.

Terraform provisions immutable infrastructure like VPCs, load balancers, and managed databases via cloud APIs. Ansible and Puppet configure software on existing servers. In my experience deploying Laravel applications, I use Terraform to create the Ubuntu EC2 instance and RDS database, then hand off to Ansible or Deployer 7 for PHP-FPM configuration and application deployment.

The open-source CLI is completely free. HashiCorp Cloud Platform offers a free tier for state management up to 500 resources. For most Nepal-based SMB projects I have worked on, the free CLI with S3 backend storage costing roughly NPR 500 per month is sufficient without requiring paid enterprise features or cloud platform subscriptions.

Organize by environment and component rather than one monolithic file. Use separate directories for networking, compute, and database layers. Store shared modules in a dedicated folder. Keep terraform.tfvars out of version control. On production Laravel projects, I separate VPC, EC2, and RDS into distinct state files so a database change never risks accidentally destroying the web server layer.

Never commit tfstate files to Git. Use remote backends like AWS S3 with DynamoDB locking or GCS with object versioning. Enable encryption at rest and restrict bucket access via IAM policies. For client projects hosted on shared EC2 infrastructure, I configure S3 backends with server-side encryption and least-privilege IAM roles to prevent accidental state corruption or unauthorized infrastructure modifications during team deployments.

Yes, using terraform import or the newer import block in configuration. Map each existing resource ID to a Terraform resource address, then run plan to verify alignment. This is essential when adopting IaC for legacy systems. I have used this approach to bring manually provisioned WordPress hosting environments under Terraform management without downtime, carefully matching security groups and RDS parameters before applying changes.

Never hardcode credentials in HCL files. Use environment variables, AWS Secrets Manager, HashiCorp Vault, or encrypted tfvars files with git-crypt. Mark sensitive variables with the sensitive attribute to redact them from logs and plan output. For payment gateway integrations on eCommerce platforms, I pass API keys through CI/CD pipeline variables injected at runtime, keeping them entirely out of the repository and Terraform state.

Running apply without reviewing plan output, ignoring state locking, creating circular dependencies between modules, and skipping lifecycle rules for stateful resources. Another frequent issue is modifying resources outside Terraform after provisioning. On real deployments, I enforce mandatory plan review in GitLab CI pipelines and use prevent_destroy lifecycle blocks on RDS instances to catch accidental deletions before they reach production.

Terraform provisions the underlying infrastructure—VPC, subnets, EC2 instances, RDS, ElastiCache Redis, and ALB. Application deployment remains separate. In my workflow, Terraform creates the Ubuntu 24 server and MySQL 8.4 RDS instance, then Deployer 7 handles PHP 8.4 installation, Composer dependencies, and zero-downtime symlinked releases. This separation keeps infrastructure stable while allowing frequent application updates without touching cloud resources.

Separate state files per environment are safer for production systems. Workspaces share configuration and increase blast radius if someone targets the wrong workspace. I use distinct directories with independent backends for dev, staging, and production. On legal-tech portals handling sensitive client data, this isolation ensures a staging misconfiguration cannot cascade into production state, and each environment maintains its own encryption keys and access policies.

Use terraform validate for syntax checking, tflint for best-practice linting, and terratest for integration testing. Run terraform plan with detailed output review before every apply. For critical infrastructure, deploy to an isolated test account first. On eCommerce projects processing payments, I validate security group rules and RDS parameter groups in a sandbox VPC before promoting changes, catching misconfigurations that could expose customer data or break checkout flows.

Terraform itself is free; costs depend on provisioned cloud resources. A typical Laravel production stack on AWS with t3.medium EC2, db.t3.small RDS, and S3 storage runs approximately USD 80–120 monthly (NPR 10,000–16,000). Local Nepal hosting options may reduce costs but limit Terraform provider support. Budget for NAT Gateways and data transfer, which often surprise teams unfamiliar with AWS pricing models.

Pin exact provider versions in required_providers blocks. Test upgrades in non-production first, reviewing changelogs for breaking changes. Run terraform plan after updating to detect unexpected resource replacements. When upgrading the AWS provider on active client projects, I have encountered deprecated attributes requiring configuration rewrites. Always upgrade incrementally—one minor version at a time—and maintain rollback capability through version-controlled provider constraints.

Yes, Terraform supports over 3,000 providers including AWS, Azure, GCP, Cloudflare, and on-premise VMware. However, mixing providers increases complexity and testing burden. For Nepal-based clients needing local redundancy alongside global CDN, I typically use Terraform for AWS primary infrastructure and Cloudflare DNS/CDN configuration in separate state files. True multi-cloud failover adds significant operational overhead that rarely justifies the cost for SMB-scale applications.

Configure pipeline stages for init, validate, plan, and apply with manual approval gates before production applies. Store backend credentials in CI variables, cache plugins between runs, and use OIDC for cloud authentication instead of long-lived access keys. On sister sites sharing deployment infrastructure, I use GitLab CI with Deployer 7 for application code and separate Terraform pipelines for infrastructure changes, ensuring app deploys never trigger unintended cloud resource modifications.

Share this article

Quick Contact Options
Choose how you want to connect me: