Terraform is a free, open-source Infrastructure as Code tool that lets you define cloud resources — VMs, databases, networks, firewalls — in human-readable configuration files, then create them with a single command. For small businesses, the overhead is about 2 hours of initial setup and 5 minutes per change, while the alternative — rebuilding manually after a disaster using nothing but someone’s memory of portal clicks from three years ago — can take days and cost thousands.
You’re logging into the Azure portal, clicking through menus, configuring a virtual machine by hand. You pick the region, the size, the networking settings. You set up the firewall rules. You configure the backup schedule. Forty-five minutes later, you have a running VM. Then you need another one for staging. So you do it all again, trying to remember exactly which settings you chose for the first one.
If that sounds familiar, you’re managing infrastructure the hard way. And if you’re honest about it, you probably can’t tell me with certainty that your staging environment is configured identically to production. Because it was set up by hand, six weeks apart, and humans don’t reproduce exact configurations from memory.
This is the problem Terraform solves. Not with enterprise complexity and DevOps jargon — with simple configuration files that describe what you want, and a tool that makes it happen. Every time. Identically.
Terraform is an open-source Infrastructure as Code tool that lets you define cloud resources — virtual machines, databases, networks, firewalls, storage accounts — in human-readable configuration files. Instead of clicking through the Azure portal or AWS console, you write what you want in a file, run a command, and Terraform creates it. Change the file, run the command again, and Terraform updates only what changed. Delete the file, run the command, and Terraform tears everything down cleanly.
For small businesses, this means three things that matter: reproducibility (your environments are identical because they come from the same code), documentation (your infrastructure is self-documenting because it’s written down), and recovery (if something breaks, you can recreate your entire setup from scratch in minutes instead of days).
I’m going to walk you through the entire process — from installation to a working Azure configuration that provisions a real environment. Every configuration in this guide is designed for small business scale, not enterprise complexity. Let’s get into it.
Why Infrastructure as Code Matters for Small Businesses
Let me tell you what I see in small business IT environments across Ormond Beach, Daytona Beach, and the broader Volusia County area every week. Manual infrastructure. Everywhere.
A business has three servers in Azure. They were set up at different times by different people — maybe the original IT guy, then a contractor, then the current admin. Nobody documented the configurations. The networking rules were set up through the portal and the firewall exceptions were added one at a time as things broke. The backup schedule? Somebody configured it once, but nobody has verified it actually works in over a year. Our guide to Is Your Ormond Beach Business Still Running on a Server in the Closet? walks through this in more detail.
This is not unusual. This is normal. And it’s terrifying, because when something goes wrong — and in cloud infrastructure, something always eventually goes wrong — nobody can confidently answer the question “what exactly do we have, and how do we rebuild it?”
Infrastructure as Code changes this fundamentally. Here’s what it gives you:
Version history for infrastructure. When your Terraform files are in Git, you can see exactly who changed what, when, and why. Six months ago, someone opened port 8080 on the firewall. Why? Check the commit message. Want to undo it? Revert the commit and run terraform apply. That’s it.
Identical environments. Your staging environment is a copy-paste of your production configuration with a few variable changes — smaller VM sizes, shorter retention periods, a different naming prefix. They’re identical in structure because they come from the same code.
Disaster recovery that actually works. If your Azure subscription gets compromised or a region goes down, you can stand up your entire infrastructure in a new region with one command. Try doing that when your infrastructure exists only as clicks someone made in a portal three years ago.
Knowledge preservation. When your IT person leaves, the Terraform files document everything about your infrastructure. The next person doesn’t have to reverse-engineer your setup by clicking through portal screens — they read the code and understand exactly what exists and how it’s configured.
The common objection I hear is “we’re too small for Infrastructure as Code.” You’re not. If you have even one cloud resource that matters to your business, you should be managing it with code. The overhead of Terraform for a small environment is maybe two hours of initial setup and five minutes per change going forward. The overhead of not using it is measured in the panic and cost of rebuilding something from scratch when you need to.
Installing Terraform
Terraform is a single binary that runs on Windows, Mac, and Linux. Here’s how to install it on each platform.
Windows (using winget):
winget install HashiCorp.Terraform
Mac (using Homebrew):
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
Linux (Ubuntu/Debian):
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
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
Verify the installation:
terraform --version
# output: Terraform v1.11.x on windows_amd64
You’ll also need the Azure CLI installed and authenticated if you’re following along with the Azure examples:
# Install Azure CLI (Windows)
winget install Microsoft.AzureCLI
# Log in to your Azure account
az login
# Verify your subscription
az account show --output table
That’s it. No Docker containers, no virtual environments, no complex dependency chains. Terraform is refreshingly simple to install and run.
A quick note on the Azure CLI authentication. When you run az login, it opens a browser window where you sign into your Azure account. The CLI stores the authentication token locally, and Terraform uses it automatically. For production environments and CI/CD pipelines, you’d use a service principal instead — a dedicated identity with limited permissions — but for getting started and local development, interactive login is perfectly fine.
One thing that trips people up: make sure your Azure account has the right permissions. You need at least Contributor role on the subscription where you’ll be creating resources. If you’re the subscription owner, you already have this. If you’re working under an IT admin who controls the Azure account, ask them to assign you Contributor access to a specific resource group or subscription.
Your First Terraform Configuration
Let’s start with the simplest possible Azure configuration — a resource group. This is the “Hello World” of Terraform, and it demonstrates the entire workflow.
Create a new directory for your project and add a file called main.tf:
# main.tf — Your first Terraform configuration
# Tell Terraform which cloud provider to use
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
required_version = ">= 1.9.0"
}
# Configure the Azure provider
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
# Define a variable for the subscription ID
variable "subscription_id" {
description = "Azure subscription ID"
type = string
}
# Define a variable for the location
variable "location" {
description = "Azure region for resources"
type = string
default = "eastus2"
}
# Define a variable for the environment name
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
default = "dev"
}
# Create a resource group
resource "azurerm_resource_group" "main" {
name = "rg-mycompany-${var.environment}"
location = var.location
tags = {
Environment = var.environment
ManagedBy = "Terraform"
Company = "MyCompany"
}
}
# Output the resource group ID
output "resource_group_id" {
value = azurerm_resource_group.main.id
}
Let me walk through every section of this file because understanding the structure now saves you hours of confusion later.
The terraform block at the top specifies which providers you need and what versions are compatible. The ~> 4.0 version constraint means “any 4.x version” — it accepts 4.0, 4.1, 4.2, but not 5.0. This prevents Terraform from automatically upgrading to a major version that might have breaking changes.
The provider block configures how Terraform connects to Azure. The features {} block is required by the Azure provider even if empty — it’s where you’d configure provider-level feature flags if needed.
The variable blocks define inputs that you can change without modifying the core configuration. This is how you use the same code for different environments — change the environment variable from “dev” to “prod” and you get a different resource group name.
The resource block is where the actual infrastructure gets defined. azurerm_resource_group is the resource type (an Azure resource group), and "main" is the local name you use to reference this resource elsewhere in your code. The name attribute uses string interpolation (${var.environment}) to build dynamic names.
The tags are worth calling out. Every resource you create in Azure should have tags indicating what environment it belongs to, who manages it, and what project or company it belongs to. When you’re looking at your Azure bill and trying to figure out what’s costing money, tags are how you filter and sort.
The output block tells Terraform to display the resource group ID after creation. Outputs are how you pass information between Terraform configurations and how you verify that resources were created correctly.
Now create a file called terraform.tfvars for your actual values:
# terraform.tfvars — Your actual configuration values
# This file should NOT be committed to git if it contains sensitive data
subscription_id = "your-azure-subscription-id-here"
location = "eastus2"
environment = "dev"
Now run the Terraform workflow — the four commands you’ll use for every infrastructure change:
# Step 1: Initialize - downloads provider plugins
terraform init
# Step 2: Plan - shows what Terraform will do WITHOUT doing it
terraform plan
# Step 3: Apply - creates the actual infrastructure (requires confirmation)
terraform apply
# Step 4: (When you're done) Destroy - removes everything cleanly
terraform destroy
The plan command is your safety net. It shows you exactly what Terraform will create, modify, or destroy before it touches anything. Read the plan output carefully. If it says it’s going to destroy something you didn’t expect, that’s your cue to stop and investigate before pressing yes.
When you run terraform apply, you’ll see the same plan output followed by a confirmation prompt. Type “yes” to proceed. Terraform will create the resource group and display the output value.
Congratulations — you just managed infrastructure as code. That resource group exists because of a file you wrote, and you can recreate it identically on any Azure subscription by running the same commands.
Here’s a hidden-layer insight that most tutorials skip. When you run terraform init, it downloads the Azure provider plugin and stores it in a .terraform directory. This directory can be large — sometimes hundreds of megabytes — and should be added to your .gitignore. It’s regenerated by terraform init whenever you clone the repo, so there’s no reason to version-control it.
Also add *.tfstate, *.tfstate.backup, and *.tfvars (if they contain secrets) to .gitignore. Your .gitignore should look like this:
.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
The .tfvars exclusion depends on your setup. If your variable files only contain non-sensitive values like region names and environment labels, they’re safe to commit. If they contain subscription IDs, IP addresses, or any secrets, keep them out of version control and use a separate secret management solution.
Building a Real-World Small Business Configuration
A resource group by itself isn’t very useful. Let’s build something practical — a configuration that provisions a complete small business environment with a virtual network, a web server VM, a database, and proper security rules.
Create a new file called network.tf:
# network.tf — Virtual network and subnets
resource "azurerm_virtual_network" "main" {
name = "vnet-${var.environment}"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tags = local.common_tags
}
resource "azurerm_subnet" "web" {
name = "snet-web"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.1.0/24"]
}
resource "azurerm_subnet" "db" {
name = "snet-db"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.0.2.0/24"]
}
resource "azurerm_network_security_group" "web" {
name = "nsg-web-${var.environment}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
security_rule {
name = "AllowHTTPS"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowHTTP"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "AllowSSH"
priority = 120
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.admin_ip
destination_address_prefix = "*"
}
tags = local.common_tags
}
resource "azurerm_subnet_network_security_group_association" "web" {
subnet_id = azurerm_subnet.web.id
network_security_group_id = azurerm_network_security_group.web.id
}
Notice the SSH rule restricts access to var.admin_ip — your office IP address. This is a critical security practice. SSH should never be open to the entire internet (*). Define your office IP as a variable and restrict SSH access to that address only. If you need to SSH from other locations, use a VPN or Azure Bastion instead.
The network is split into two subnets — one for web servers (10.0.1.0/24) and one for databases (10.0.2.0/24). This is basic network segmentation. Your database subnet shouldn’t be accessible from the internet at all — only from the web subnet. We enforce this with network security groups.
Now add a locals.tf file for shared values:
# locals.tf — Shared values used across configurations
locals {
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Company = var.company_name
CostCenter = var.cost_center
}
}
variable "company_name" {
description = "Company name for resource tagging"
type = string
default = "MyCompany"
}
variable "cost_center" {
description = "Cost center for billing"
type = string
default = "IT"
}
variable "admin_ip" {
description = "Admin IP address for SSH access (your office IP)"
type = string
}
The locals block defines values that are computed once and reused. Instead of copy-pasting the same tags block into every resource, you reference local.common_tags. Change the tags in one place, and every resource picks up the change on the next apply.
Understanding Terraform State
Here’s the part most Terraform tutorials gloss over, and it’s the part that will bite you if you don’t understand it. Terraform maintains a state file — terraform.tfstate — that maps your configuration to real-world resources. When you run terraform plan, Terraform compares your configuration files against the state file to determine what needs to change.
The state file is critical. If you delete it, Terraform loses track of what it created and tries to create everything from scratch — which fails because the resources already exist. If two people run Terraform simultaneously from different copies of the state file, they’ll step on each other’s changes.
For a single admin managing a small environment, the local state file (the default) works fine. Keep it in Git but add terraform.tfstate to your .gitignore — never commit state files, as they can contain sensitive information like database passwords.
For teams or any production environment, use remote state. Terraform Cloud offers a free tier that handles this for up to 500 managed resources. Or use an Azure Storage Account as a state backend:
# backend.tf — Remote state storage in Azure
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "stterraformstate"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}
You’ll need to create the storage account and container manually first — this is the one piece of infrastructure that has to exist before Terraform can manage it. It’s a bootstrapping problem, and the standard solution is to create the state storage by hand and then manage everything else with Terraform.
Remote state gives you locking — if someone is running terraform apply, the state file is locked and another person can’t run it simultaneously. This prevents the most common source of Terraform-related infrastructure corruption.
There’s another subtlety with state that catches people. If you create a resource in Terraform and then someone goes into the Azure portal and deletes it manually, Terraform doesn’t know the resource is gone until the next terraform plan. At that point, it detects the drift — the state file says the resource should exist, but Azure says it doesn’t — and proposes to recreate it. This is actually a feature, not a bug. It means Terraform will heal your infrastructure back to the desired state automatically.
The flip side is also true. If someone creates a resource manually through the portal, Terraform doesn’t know about it. Terraform only manages resources it created. You can import existing resources into Terraform’s state using terraform import, but it’s a manual process that requires writing the corresponding configuration first. For this reason, the best practice is to adopt Terraform early — before your infrastructure gets complex — and then manage everything through code from that point forward.
For businesses in Volusia County that already have existing Azure infrastructure, I typically recommend a phased approach: start managing new resources with Terraform immediately, and gradually import existing resources over time as you touch them for maintenance or upgrades.
Using Modules for Reusability
As your Terraform code grows, you’ll notice patterns. Every VM needs a network interface, a public IP, and an OS disk. Every database needs backup policies and firewall rules. Modules let you package these patterns into reusable components.
Here’s a simple module for creating a standard VM:
Create a directory structure:
modules/
vm/
main.tf
variables.tf
outputs.tf
modules/vm/variables.tf:
variable "name" {
description = "VM name"
type = string
}
variable "resource_group_name" {
type = string
}
variable "location" {
type = string
}
variable "subnet_id" {
type = string
}
variable "vm_size" {
description = "Azure VM size"
type = string
default = "Standard_B2s"
}
variable "admin_username" {
type = string
default = "azureadmin"
}
variable "tags" {
type = map(string)
default = {}
}
modules/vm/main.tf:
resource "azurerm_public_ip" "vm" {
name = "pip-${var.name}"
resource_group_name = var.resource_group_name
location = var.location
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
resource "azurerm_network_interface" "vm" {
name = "nic-${var.name}"
location = var.location
resource_group_name = var.resource_group_name
ip_configuration {
name = "internal"
subnet_id = var.subnet_id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.vm.id
}
tags = var.tags
}
resource "azurerm_linux_virtual_machine" "vm" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
size = var.vm_size
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.vm.id]
admin_ssh_key {
username = var.admin_username
public_key = file("~/.ssh/id_rsa.pub")
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "ubuntu-24_04-lts"
sku = "server"
version = "latest"
}
tags = var.tags
}
modules/vm/outputs.tf:
output "public_ip" {
value = azurerm_public_ip.vm.ip_address
}
output "private_ip" {
value = azurerm_network_interface.vm.private_ip_address
}
output "vm_id" {
value = azurerm_linux_virtual_machine.vm.id
}
Now you can create VMs with a single module call in your main configuration:
# In your main project's main.tf
module "web_server" {
source = "./modules/vm"
name = "vm-web-${var.environment}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
subnet_id = azurerm_subnet.web.id
vm_size = var.environment == "prod" ? "Standard_B2ms" : "Standard_B2s"
tags = local.common_tags
}
module "app_server" {
source = "./modules/vm"
name = "vm-app-${var.environment}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
subnet_id = azurerm_subnet.web.id
vm_size = "Standard_B2s"
tags = local.common_tags
}
Two VMs, each fully configured, created with ten lines of code each. That’s the power of modules. Need a third VM? Copy the block, change the name. Need to update how all VMs are configured — say, you want to add monitoring — change the module once and every VM picks up the change on the next apply.
Notice the conditional expression in the web server’s vm_size: var.environment == "prod" ? "Standard_B2ms" : "Standard_B2s". Production gets a larger VM, everything else gets the cheaper one. This is how you keep development and staging costs low while ensuring production has the resources it needs.
The Standard_B2s VM size costs roughly $30 per month in Azure East US 2. That’s a 2-vCPU, 4-GB-RAM burstable instance — more than adequate for most small business workloads like web servers, internal tools, and application backends. You can scale up later by changing one line in the module call. The “burstable” designation means the VM accumulates CPU credits during idle periods and can burst above its baseline performance during spikes — ideal for workloads that are mostly idle but occasionally need more power, which describes most small business applications perfectly.
Azure Verified Modules — a Microsoft-maintained library of pre-built Terraform modules — can save you even more time. Instead of writing your own VM module from scratch, you can use the official AVM modules that follow Azure best practices out of the box. They handle edge cases and security configurations that you might not think of on your first pass. Check the Azure Verified Modules documentation for the current catalog of available modules.
Managing Environments with Workspaces or Variables
Most small businesses need at least two environments — production and one for testing. Terraform gives you several ways to handle this. The simplest is variable files.
Create separate .tfvars files for each environment:
dev.tfvars:
environment = "dev"
location = "eastus2"
admin_ip = "203.0.113.50/32"
prod.tfvars:
environment = "prod"
location = "eastus2"
admin_ip = "203.0.113.50/32"
Then apply with the appropriate file:
# Deploy development
terraform apply -var-file="dev.tfvars"
# Deploy production
terraform apply -var-file="prod.tfvars"
Same code, different parameters, different environments. If you want to test a configuration change, deploy it to dev first, verify it works, then apply the same code to production. This is the workflow that eliminates the “it works in staging but not in production” problem, because both environments come from the same source. Our guide to The ‘Good Enough’ Cloud Setup for Businesses Under 20 Employees walks through this in more detail.
Terraform also supports workspaces as an alternative approach. Workspaces let you maintain separate state files for different environments within the same configuration directory. The commands are straightforward: terraform workspace new staging creates a new workspace, and terraform workspace select staging switches to it. Each workspace has its own state, so dev resources and prod resources never overlap.
For small businesses, I typically recommend the variable file approach over workspaces. It’s more explicit — you can see exactly what’s different between environments by comparing the .tfvars files — and it’s harder to accidentally deploy to the wrong environment. With workspaces, a simple terraform apply runs against whichever workspace is currently selected, which can lead to unpleasant surprises if you forgot you had production selected.
Regardless of which approach you choose, the fundamental benefit is the same: your infrastructure is code, and code can be reviewed, tested, version-controlled, and reproduced. That’s a fundamentally different paradigm from clicking through a web portal and hoping you remember which settings you chose.
The Bottom Line
Infrastructure as Code isn’t enterprise-only technology. If you have cloud resources that matter to your business, you should be managing them with Terraform. The initial learning curve is measured in hours, not weeks. The payoff is infrastructure that’s reproducible, documented, version-controlled, and recoverable.
Start with a resource group. Get comfortable with the plan-apply workflow. Then build out your real infrastructure — networking, VMs, databases, security rules — piece by piece. Use modules to eliminate repetition. Use variables to manage environments. Use remote state to enable team collaboration and prevent state corruption.
The businesses I work with across Volusia County that adopt Terraform consistently report the same benefits: fewer “what did we change?” mysteries, faster disaster recovery testing, and infrastructure changes that go through a review process instead of someone clicking buttons in a portal and hoping for the best.
If you want help getting started with Infrastructure as Code for your cloud environment, that’s exactly what we specialize in. And when you’re ready to automate your Terraform deployments, check out our guide on CI/CD for non-software companies.
Frequently Asked Questions
What is Terraform and why should a small business care?
Terraform is an open-source tool that lets you define cloud infrastructure in configuration files instead of clicking through web portals. For small businesses, this means reproducible environments, version-controlled infrastructure, and the ability to tear down and recreate your entire setup from a single command. It’s free to use, and the learning curve is significantly smaller than most people expect.
How much does Terraform cost?
Terraform itself is free and open source. You pay only for the cloud resources it provisions — the same resources you’d pay for if you set them up manually through the Azure portal. Terraform Cloud offers a free tier for up to 500 managed resources, which covers most small business needs. There’s no licensing fee, no per-resource charge, and no subscription required for the core tool.
Do I need a DevOps engineer to use Terraform?
No. The starter configurations in this guide are designed for IT admins and technically minded business owners. If you can edit a configuration file and run a command in a terminal, you can use Terraform. The HCL language is more readable than most programming languages — it reads almost like English. Start simple with a resource group, build confidence, and add complexity only as your needs grow.
Can Terraform manage Azure, AWS, and Google Cloud?
Yes. Terraform is cloud-agnostic — it works with all major cloud providers through plugins called providers. You can even manage multiple clouds from the same configuration, though most small businesses start with one provider and expand later. There are also providers for non-cloud services like GitHub, Cloudflare, Datadog, and hundreds of others.
What happens if I make a mistake in my Terraform configuration?
Terraform shows you exactly what it plans to do before it does it, using the terraform plan command. You review the planned changes — what will be created, modified, or destroyed — and approve them before anything happens. If the plan shows something unexpected, you can fix the configuration and plan again. Because your infrastructure is in version control, you can always revert to a previous configuration and apply it.