Use Terraform for Infrastructure as Code on VPS

Use Terraform for Infrastructure as Code on VPS

June 2026 · 8 min read

Terraform is an Infrastructure as Code (IaC) tool that lets you declare VPS instances, networks, DNS records, and cloud resources in text-based .tf files, then use terraform plan, apply, and destroy commands to create, update, and remove infrastructure consistently. Terraform tracks the state of all resources in a terraform.tfstate file, so it always knows what currently exists on your cloud provider. The benefits are clear: version control, easy team collaboration, repeatable deployments, and the ability to tear down and rebuild entire environments in seconds.

Installing Terraform

Terraform provides binaries for Linux, macOS, and Windows available at terraform.io. To install on Ubuntu/Debian:

wget -O- https://apt.releases.hashicorp.com/gpg | \
  gpg --dearmor | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg > /dev/null

echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
  https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/hashicorp.list

sudo apt update && sudo apt install terraform

For CentOS/RHEL:

sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo yum install terraform

Verify the installation:

terraform version
Tip: Always keep Terraform updated to the latest version for new syntax features and bug fixes.

Creating .tf Files to Declare VPS Resources

Create a new project directory and set up main.tf, variables.tf, and outputs.tf files. Here's an example for creating a VPS on Linode as your cloud provider:

# main.tf
terraform {
  required_version = ">= 1.0"
  required_providers {
    linode = {
      source  = "linode/linode"
      version = "~> 2.0"
    }
  }
}

provider "linode" {
  token = var.linode_token
}

resource "linode_instance" "web_server" {
  label             = "web-server-01"
  image             = "linode/ubuntu22.04"
  region            = "ap-south"
  type              = "g6-standard-2"
  authorized_keys   = [var.ssh_public_key]
  root_pass         = var.root_password
  swap_size         = 512

  tags = ["web", "production"]

  lifecycle {
    ignore_changes = [authorized_keys, root_pass]
  }
}

resource "linode_firewall" "web_firewall" {
  label = "web-firewall"

  inbound {
    label    = "allow-https"
    action   = "ACCEPT"
    protocol = "TCP"
    ports    = "443"
    ipv4     = ["0.0.0.0/0"]
  }

  inbound {
    label    = "allow-http"
    action   = "ACCEPT"
    protocol = "TCP"
    ports    = "80"
    ipv4     = ["0.0.0.0/0"]
  }

  inbound {
    label    = "allow-ssh"
    action   = "ACCEPT"
    protocol = "TCP"
    ports    = "22"
    ipv4     = ["0.0.0.0/0"]
  }

  outbound {
    label      = "allow-outbound"
    action     = "ACCEPT"
    protocol   = "ALL"
    ipv4       = ["0.0.0.0/0"]
    ipv6       = ["::/0"]
  }

  linodes = [linode_instance.web_server.id]
}

resource "linode_domain" "example_domain" {
  domain      = "example.com"
  type        = "master"
  soa_email   = "[email protected]"
  description = "Managed by Terraform"
}

resource "linode_domain_record" "web_a_record" {
  domain_id   = linode_domain.example_domain.id
  name        = "web"
  record_type = "A"
  target      = linode_instance.web_server.ip_address
  ttl         = 3600
}

The variables.tf file stores input values:

variable "linode_token" {
  type        = string
  description = "Linode API token for authentication"
  sensitive   = true
}

variable "ssh_public_key" {
  type        = string
  description = "SSH public key content (from ~/.ssh/id_rsa.pub)"
  sensitive   = false
}

variable "root_password" {
  type        = string
  description = "Root password for VPS instance"
  sensitive   = true
}

The outputs.tf file exports important values:

output "instance_ip" {
  value       = linode_instance.web_server.ip_address
  description = "The public IP address of the web server"
}

output "domain_name" {
  value       = linode_domain.example_domain.domain
  description = "The managed domain name"
}

Terraform Workflow: Init, Plan, Apply, Destroy

Terraform follows a four-step workflow:

1. Init — Initialize the project:

terraform init

This command downloads provider plugins (linode, aws, etc.) and creates the .terraform directory. It also generates .terraform.lock.hcl to lock provider versions for reproducible builds.

2. Plan — Preview changes without executing:

terraform plan -var-file="terraform.tfvars" > tfplan.txt

This shows exactly what Terraform will create, update, or delete before you apply. Use -out=tfplan to save the plan for later execution.

3. Apply — Create or update infrastructure:

terraform apply "tfplan.txt"

This creates the resources on your cloud provider based on the saved plan. After completion, Terraform writes the state to terraform.tfstate.

4. Destroy — Delete all infrastructure:

terraform destroy -var-file="terraform.tfvars" -auto-approve

This deletes all resources managed by Terraform. Without -auto-approve, it will ask for confirmation first.

Managing State Files

The state file (terraform.tfstate) is critical because Terraform reads it, compares the declared resources in your .tf files, and decides what changes to make. Never edit the state file manually as it will confuse Terraform.

Store state in a remote backend (S3, Consul, Terraform Cloud) instead of locally so your team can share it. Example using S3:

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
  }
}

Before using a remote backend, create the S3 bucket and DynamoDB table, then run terraform init to upload your state to S3.

Tip: Use terraform state list, terraform state show, and terraform state rm for state management. Avoid terraform state mv unless you really know what you're doing.

Terraform Modules for Code Organization

As your infrastructure grows, organize code into reusable modules. A module is a folder containing main.tf, variables.tf, and outputs.tf. Example:

# modules/web_server/main.tf
resource "linode_instance" "web" {
  label             = var.server_label
  image             = var.image_id
  region            = var.region
  type              = var.instance_type
  authorized_keys   = var.authorized_keys
  tags              = var.tags
}

# modules/web_server/variables.tf
variable "server_label" {
  type = string
}

variable "image_id" {
  type = string
}

# ... more variables

# modules/web_server/outputs.tf
output "instance_id" {
  value = linode_instance.web.id
}

output "ip_address" {
  value = linode_instance.web.ip_address
}

Call the module from your main configuration:

module "web_server" {
  source = "./modules/web_server"

  server_label    = "prod-web-01"
  image_id        = "linode/ubuntu22.04"
  region          = "ap-south"
  instance_type   = "g6-standard-2"
  authorized_keys = [var.ssh_public_key]
  tags            = ["web", "production"]
}

This approach lets you create multiple VPS instances from one module or reuse modules across projects.

Verify Terraform is Working

After writing your .tf files, verify the configuration syntax and setup:

# 1. Check syntax
terraform validate

# 2. Format code to standard
terraform fmt -recursive

# 3. Show the plan without executing
terraform plan -var-file="terraform.tfvars" | head -50

# 4. List current state resources
terraform state list

# 5. Display output values
terraform output

Example output:

$ terraform validate
Success! The configuration is valid.

$ terraform fmt -check -diff
- root_pass         = var.root_password
+ root_pass = var.root_password

$ terraform plan
Terraform will perform the following actions:

  # linode_instance.web_server will be created
  + resource "linode_instance" "web_server" {
      + id              = (known after apply)
      + ip_address      = (known after apply)
      + label           = "web-server-01"
      + region          = "ap-south"
      + type            = "g6-standard-2"
    }

Plan: 3 to add, 0 to change, 0 to destroy.

$ terraform state list
linode_instance.web_server
linode_firewall.web_firewall
linode_domain.example_domain
linode_domain_record.web_a_record

$ terraform output
instance_ip = "139.162.123.45"
domain_name = "example.com"
Tip: Set up CI/CD pipelines (GitHub Actions, GitLab CI) to run terraform validate and terraform plan automatically on every push.

Troubleshooting Common Issues

Error: "Invalid or missing token"

Cause: The API token for your cloud provider is missing, incorrect, or expired. Terraform can't authenticate with your cloud account.

Fix: Make sure terraform.tfvars has the correct token:

# terraform.tfvars
linode_token        = "your-api-token-here"
ssh_public_key      = file("~/.ssh/id_rsa.pub")
root_password       = "SecurePassword123!"

Or set it as an environment variable:

export TF_VAR_linode_token="your-api-token-here"

Error: "Instance already exists" (Import conflict)

Cause: A resource already exists on your cloud provider but Terraform doesn't know about it (it's not in the state file). This happens when you create resources manually via the console then try to manage them with Terraform.

Fix: Import the existing resource into state first:

terraform import linode_instance.web_server 123456789

The number 123456789 is the instance ID from your cloud console. After import, Terraform will manage that resource alongside your declared .tf code.

Error: "Backend initialization failed"

Cause: The remote backend (S3 bucket, Consul, Terraform Cloud) is not accessible or credentials are incorrect. Terraform can't read or write state remotely.

Fix: Verify your backend.tf configuration and ensure the backend infrastructure exists:

# Create S3 bucket first
aws s3 mb s3://my-terraform-state

# Enable versioning for recovery
aws s3api put-bucket-versioning \
  --bucket my-terraform-state \
  --versioning-configuration Status=Enabled

# Set AWS credentials
export AWS_ACCESS_KEY_ID="your-key"
export AWS_SECRET_ACCESS_KEY="your-secret"

terraform init

Error: "Argument or block type not found"

Cause: The attribute name is incorrect or your provider version doesn't support that attribute. This often happens when examples use newer or older Terraform syntax than your installed version.

Fix: Check the provider schema and update provider versions:

# View available provider attributes
terraform providers schema -json | jq '.provider_schemas."registry.terraform.io/linode/linode"' | less

# Update to the latest provider version
terraform init -upgrade

Frequently Asked Questions

1. I have an existing VPS created manually. Can I import it into Terraform?

Yes. Write .tf resource blocks that match your existing VPS, then use terraform import to pull it into the state file. For example: terraform import linode_instance.old_server 987654321 where 987654321 is the instance ID from your cloud console. After import, Terraform becomes responsible for that resource and all future changes go through Terraform.

2. The state file contains passwords and secrets. Should I commit it to Git?

No. The state file contains sensitive data and should never be committed to version control. Always use a remote backend (S3, Terraform Cloud, Consul) with encryption and access controls. Add terraform.tfstate to .gitignore on your local machine. For team collaboration, use Terraform Cloud so each team member logs in securely and accesses state without exposing it to Git.

3. Does Terraform take a long time during apply? Can I speed it up?

Yes, apply and destroy can take several minutes depending on your infrastructure size and cloud provider response times. Let the process complete — never press Ctrl+C during apply as this will desynchronize your state file from actual resources. If you're worried progress has stalled, open another terminal and run terraform show to check if the operation finished. State is automatically updated once complete.