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.

Back to Professional Services
1
VM provisioned
3
Resources created
$0
On the free tier
7
Config files

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

Terraform apply creating one AWS VM in the default VPC Running terraform apply from your machine authenticates to AWS with a named CLI profile. Read-only data sources look up the latest Amazon Linux 2023 AMI and the account's default VPC and subnets. Terraform then creates three resources inside the default VPC: an EC2 key pair built from your public SSH key, a security group allowing inbound SSH, HTTP and HTTPS, and a single t3.micro EC2 instance with a public IP that uses both. terraform apply profile: freetier uploads public SSH key data sources (read-only) aws_ami → latest AL2023 aws_vpc / aws_subnets → default AWS · default VPC (internet gateway) aws_key_pair your public SSH key aws_security_group in 22 / 80 / 443 · out all aws_instance · t3.micro Amazon Linux 2023 · gp3 root · public IPv4 exactly one — no count / for_each
One provider, read-only lookups so nothing is hardcoded, and three resources in the account's default VPC — a key pair, a security group, and a single public VM.

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:

FileWhat it's for
versions.tfTerraform + provider version pins, so the config is reproducible
variables.tfThe input variables (the knobs), each with a safe default
main.tfThe provider, the read-only lookups, and the resources
outputs.tfValues printed after an apply (instance id, public IP/DNS, a ready-to-run SSH command)
terraform.tfvars.exampleSample variable values — copy to terraform.tfvars and edit (no secrets)
.gitignoreKeeps state, secrets, and downloaded plugins out of git
README.mdThe 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.

versions.tf
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.

variables.tf
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.

main.tf
# 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.

outputs.tf
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.

terraform.tfvars.example
# 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"
.gitignore
# 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

The terraform init, plan, apply, destroy workflow The workflow runs left to right: terraform init downloads the AWS provider, terraform plan previews that it will create three resources, terraform apply creates them and prints the outputs including the SSH command, and terraform destroy tears everything back down when you are done. terraform init download provider terraform plan preview: 3 to create terraform apply create + print outputs ssh ec2-user@… from ssh_command output terraform destroy destroy when you're done — t3.micro is free-tier only while it's within the allowance
The same four commands every time — and because it's all declarative, re-running plan shows exactly what would change before you apply it.
apply & tear down
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.