Use Terraform for Infrastructure as Code on VPS
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
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.
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"
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.