Professional Services Infrastructure as Code
Server provisioning with Terraform & AWS
One free-tier-eligible AWS virtual machine — the latest Amazon Linux 2023, a public IP, and a
security group open for SSH, HTTP and HTTPS — declared entirely in version-controlled
.tf files. AWS-only, scoped to exactly one VM, with no credentials in the code.
terraform apply to create it, terraform destroy to make it disappear.
What this is
This is the write-up that accompanies the studio's
Terraform guide — a step-by-step walkthrough of an AWS-only configuration
(terraform/aws-freetier-vm) that provisions exactly one virtual
machine: the smallest free-tier-eligible EC2 instance, a t3.micro running the
latest Amazon Linux 2023, with a public IPv4 and a security group open for SSH (22), HTTP (80)
and HTTPS (443). The guide explains what every file does, what every entry inside each file is
for, how the configuration was built step by step, and the exact commands used to apply it.
It is deliberately the smallest thing that works: it declares only the
hashicorp/aws provider and a single aws_instance (no
count / for_each), and it stores no credentials — AWS auth comes from
a named CLI profile and only your public SSH key is ever uploaded. The example
.tf files below are the real configuration from the guide.
Terraform in 30 seconds
Terraform is infrastructure as code: you declare the resources you
want in .tf files and Terraform works out the API calls to create, change, or
delete them to match. The pieces you'll see below:
- Provider — a plugin that talks to one platform's API (here,
aws). - Resource — a thing Terraform creates and manages (an instance, a security group, a key pair).
- Data source — a read-only lookup of something that already exists (the default VPC, the latest AMI).
- Variable — a typed input, so the same code is reusable per run.
- Output — a value Terraform prints after an apply (e.g. the instance's public IP).
- State — Terraform's record of what it created (
terraform.tfstate), kept out of git because it can hold sensitive values.
What it provisions
The configuration, file by file
Terraform reads all .tf files in a directory as one configuration, so the
split below is purely for human organization. The directory contains:
| File | What it's for |
|---|---|
versions.tf | Terraform + provider version pins, so the config is reproducible |
variables.tf | The input variables (the knobs), each with a safe default |
main.tf | The provider, the read-only lookups, and the resources |
outputs.tf | Values printed after an apply (instance id, public IP/DNS, a ready-to-run SSH command) |
terraform.tfvars.example | Sample variable values — copy to terraform.tfvars and edit (no secrets) |
.gitignore | Keeps state, secrets, and downloaded plugins out of git |
README.md | The module's own quick-start (this page is the deeper companion) |
versions.tf — version constraints
Refuse to run on a Terraform CLI older than 1.5, and pin the AWS provider to any 5.x (but not 6.0) so a surprise breaking upgrade can't sneak in.
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
variables.tf — the inputs
Every variable has a description (documentation), a type (validation),
and a default — so the config runs with zero required input, while any value can
still be overridden per run with -var or a terraform.tfvars file.
variable "region" {
description = "AWS region to deploy into. Free tier applies per-account regardless of region."
type = string
default = "us-west-2"
}
variable "profile" {
description = "Named AWS CLI profile used for credentials (see ~/.aws/credentials). No keys are stored in Terraform."
type = string
default = "freetier"
}
variable "name" {
description = "Name tag / prefix applied to the created resources."
type = string
default = "freetier-vm"
}
variable "instance_type" {
description = "EC2 instance type. t3.micro is the smallest x86_64 free-tier-eligible type for this account (2 vCPU, 1 GiB)."
type = string
default = "t3.micro"
}
variable "public_key_path" {
description = "Path to the SSH PUBLIC key uploaded to the instance. The matching PRIVATE key stays on your machine and is never sent to AWS or committed."
type = string
default = "~/.ssh/id_rsa.pub"
}
variable "ssh_ingress_cidr" {
description = "CIDR allowed to reach SSH (port 22). Defaults to the whole internet; tighten to your IP for better security."
type = string
default = "0.0.0.0/0"
}
variable "root_volume_gb" {
description = "Root EBS volume size in GiB. Free tier includes up to 30 GiB of gp3/gp2 EBS."
type = number
default = 8
}
main.tf — the provider, lookups, and resources
The heart of the configuration, read top to bottom: configure the aws provider from
the variables; look up the latest AL2023 AMI and the default VPC/subnets (so nothing is
hardcoded); then declare the key pair, the security group, and the single EC2 instance.
# SCOPE: this configuration is AWS-only and provisions exactly ONE VM.
provider "aws" {
region = var.region
profile = var.profile
}
# Latest Amazon Linux 2023 AMI, resolved at plan time so we never hardcode
# (and never go stale on) an AMI id. Uses only EC2 read permission.
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "state"
values = ["available"]
}
}
# Use the account's default VPC + subnets so the instance gets public internet
# access (the default VPC already has an internet gateway + public route table).
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
}
# Upload our SSH PUBLIC key as an EC2 key pair. The private key never touches AWS.
resource "aws_key_pair" "this" {
key_name = "${var.name}-key"
public_key = file(pathexpand(var.public_key_path))
}
# Security group: inbound SSH (restrictable), HTTP and HTTPS; all outbound.
resource "aws_security_group" "this" {
name = "${var.name}-sg"
description = "Allow SSH, HTTP, HTTPS inbound; all outbound"
vpc_id = data.aws_vpc.default.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_ingress_cidr]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.name}-sg"
Project = "multi-cloud"
}
}
# Exactly one VM: a single resource with no count/for_each.
resource "aws_instance" "this" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = element(data.aws_subnets.default.ids, 0)
key_name = aws_key_pair.this.key_name
vpc_security_group_ids = [aws_security_group.this.id]
associate_public_ip_address = true
root_block_device {
volume_size = var.root_volume_gb
volume_type = "gp3"
}
tags = {
Name = var.name
Project = "multi-cloud"
}
}
outputs.tf — what you get back
After an apply, Terraform prints these (and terraform output <name> re-prints
any one). The ssh_command builds the private-key path from the public-key path and
uses ec2-user, the default login on Amazon Linux 2023.
output "instance_id" {
description = "EC2 instance id."
value = aws_instance.this.id
}
output "public_ip" {
description = "Public IPv4 address of the instance."
value = aws_instance.this.public_ip
}
output "public_dns" {
description = "Public DNS name of the instance."
value = aws_instance.this.public_dns
}
output "ssh_command" {
description = "Ready-to-run SSH command (ec2-user is the default login on Amazon Linux 2023)."
value = "ssh -i ${replace(pathexpand(var.public_key_path), ".pub", "")} ec2-user@${aws_instance.this.public_ip}"
}
terraform.tfvars.example & .gitignore — safe by default
The example tfvars is a copy-me template containing no secrets — only a region,
the CLI profile name, and the path to your public key. The real
terraform.tfvars and all Terraform state are gitignored, because state can contain
sensitive values and should never be committed.
# Copy to terraform.tfvars and adjust. Contains NO secrets — only a region,
# a CLI profile name, and the path to your PUBLIC SSH key.
region = "us-west-2"
profile = "freetier"
name = "freetier-vm"
instance_type = "t3.micro"
public_key_path = "~/.ssh/id_rsa.pub"
# Tighten this to your own IP for a smaller attack surface, e.g.:
# ssh_ingress_cidr = "203.0.113.4/32"
ssh_ingress_cidr = "0.0.0.0/0"
# Never commit state (sensitive values), local plugins, crash/plan files,
# or your local variable choices.
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
crash.log
*.tfplan
terraform.tfvars
*.auto.tfvars
No secrets in the repo. AWS credentials come from a named CLI profile, never the code; only the public SSH key is uploaded; and state plus local variable files are gitignored. Every change to the infrastructure is a reviewable diff in git.
The apply workflow
plan shows exactly what would change before you apply it.cd terraform/aws-freetier-vm
# optional: set your own values (else the defaults are used)
cp terraform.tfvars.example terraform.tfvars # then edit
terraform init # downloads the AWS provider into .terraform/
terraform plan # preview: shows it will CREATE 3 resources, $0 on free tier
terraform apply # type 'yes' to actually create them
terraform output ssh_command # the exact command to connect
terraform destroy # type 'yes' — removes the instance, SG and key pair
Free, until it isn't. A t3.micro with an 8 GiB gp3 root volume sits inside the AWS 12-month free tier (750 instance-hours/month, 30 GiB EBS). Running beyond that allowance, or after the 12 months, incurs charges — so terraform destroy when you're finished.
Once the box is up, the same fleet is patched and hardened with the
Ansible playbooks, and a push to main
becomes a multi-OS build and release through the
CI/CD pipeline. Terraform provisions it; Ansible
configures it; Jenkins ships to it.
Want your cloud infrastructure described in code, not clicked together in a console?
Reproducible, reviewable provisioning you can stand up and tear down on demand — let's talk.