Tech Guide Infrastructure

Setting up an AWS VPS with OpenTofu

A repeatable AWS EC2 setup with OpenTofu, a dedicated VPC, restricted SSH access, encrypted storage, and a stable public IP.

A repeatable AWS EC2 setup with a dedicated VPC, restricted SSH access, encrypted storage, and a stable public IP address.

I have created plenty of VPS instances manually through cloud dashboards. That works for a quick test, but it becomes a problem when I need to rebuild the same server months later and cannot remember every network, firewall, and storage option I selected.

For this setup, I am using OpenTofu to define the AWS infrastructure as code. AWS calls the virtual server an EC2 instance, but I will refer to it as a VPS throughout this article because that is the role it is filling here.

OpenTofu will create:

  • A dedicated VPC.
  • A public subnet.
  • An Internet Gateway and route table.
  • A security group that only permits SSH from my IP address.
  • An EC2 key pair using an existing local SSH public key.
  • An Ubuntu EC2 instance with an encrypted root volume.
  • An Elastic IP address that remains stable across instance restarts.

OpenTofu is only responsible for creating the AWS infrastructure here. Server configuration, application deployment, backups, and operating-system hardening are separate concerns that can be handled manually or with a configuration tool such as Ansible.

Prerequisites

Before starting, I need:

  • An AWS account.
  • An AWS CLI profile with sufficient EC2 and VPC permissions.
  • OpenTofu installed locally.
  • AWS CLI v2 installed locally.
  • An SSH key pair.
  • A public IPv4 address from which I will administer the VPS.

I verify that the required commands are available with:

tofu version
aws --version
ssh -V

I do not use AWS root-account credentials for this. I use a named AWS CLI profile backed by an appropriate AWS identity.

Authenticate with AWS

My AWS CLI profile is named personal. Since it uses AWS IAM Identity Center, I authenticate with:

aws sso login --profile personal

I then confirm which identity OpenTofu will use:

aws sts get-caller-identity --profile personal

The command should return the AWS account and identity associated with the profile.

OpenTofu uses this same profile when communicating with AWS.

Generate an SSH key

I prefer to create the SSH key locally and only send the public key to AWS. The private key never needs to leave my machine.

ssh-keygen \
  -t ed25519 \
  -a 100 \
  -f "$HOME/.ssh/aws-vps" \
  -C "AWS personal VPS"

This creates:

~/.ssh/aws-vps
~/.ssh/aws-vps.pub

The first file is the private key. It should not be copied into the OpenTofu project, uploaded to AWS, or committed to Git.

The .pub file is the public key that OpenTofu will import into EC2.

Find the Ubuntu AMI

AMI identifiers are specific to an AWS region. An AMI copied from an example for another region may not exist in Singapore.

I am using Ubuntu Server 24.04 LTS on an x86_64 instance. The official Canonical AWS account ID is 099720109477.

I query AWS for the most recent matching image:

aws ec2 describe-images \
  --profile personal \
  --region ap-southeast-1 \
  --owners 099720109477 \
  --filters \
    "Name=name,Values=ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*" \
    "Name=architecture,Values=x86_64" \
    "Name=root-device-type,Values=ebs" \
    "Name=virtualization-type,Values=hvm" \
    "Name=state,Values=available" \
  --query 'sort_by(Images, &CreationDate)[-1].ImageId' \
  --output text

The result should resemble:

ami-0123456789abcdef0

I record this value for the OpenTofu configuration.

I am deliberately pinning the AMI ID instead of having OpenTofu automatically select the latest image during every plan. Otherwise, the arrival of a newer Ubuntu image could cause a later plan to propose replacing the existing server.

Updating the operating system inside the instance and replacing the instance with a newer image are two separate operations. I want replacement to be an explicit decision.

Find the administrator IP address

The security group will permit SSH from one public IPv4 address rather than exposing port 22 to the entire Internet.

I get my current public IPv4 address with:

curl -4 https://checkip.amazonaws.com

If the result is:

203.0.113.10

I use the CIDR:

203.0.113.10/32

A /32 rule permits exactly one IPv4 address.

This address may change if the Internet connection uses a dynamic public IP. When that happens, I need to update the OpenTofu variable and apply the change before I can connect again.

Create the OpenTofu project

I create a separate directory for the infrastructure definition:

mkdir -p "$HOME/projects/aws-vps"
cd "$HOME/projects/aws-vps"

The project will contain:

aws-vps/
├── .gitignore
├── main.tf
├── outputs.tf
├── terraform.tfvars
├── variables.tf
└── versions.tf

OpenTofu will create a .terraform.lock.hcl file during initialization. That lock file should normally be committed to Git because it records the provider versions selected for the project.

Ignore local state and variables

Create .gitignore with:

.terraform/

*.tfstate
*.tfstate.*
*.tfplan

.terraform.tfstate.lock.info

crash.log
crash.*.log

terraform.tfvars

The OpenTofu state file records the relationship between the configuration and the real AWS resources. It may also contain infrastructure details that should not be published.

For this personal setup, I am keeping the state locally. I need to back it up securely because losing it makes future updates and destruction more difficult.

A shared or production environment should use a properly protected remote state backend with access control, locking, encryption, and backups.

Configure the OpenTofu and AWS provider versions

Create versions.tf:

terraform {
  required_version = ">= 1.10.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

The OpenTofu version constraint prevents the configuration from being used with an unsupported major version.

The AWS provider constraint allows compatible 6.x releases while preventing an automatic upgrade to a future major release.

Define the input variables

Create variables.tf:

variable "aws_profile" {
  description = "AWS CLI profile used by OpenTofu."
  type        = string
}

variable "aws_region" {
  description = "AWS region where the VPS will be created."
  type        = string
  default     = "ap-southeast-1"
}

variable "project_name" {
  description = "Name used for AWS resource names and tags."
  type        = string
  default     = "personal-vps"
}

variable "instance_type" {
  description = "EC2 instance type."
  type        = string
  default     = "t3.micro"
}

variable "ubuntu_ami_id" {
  description = "Region-specific Ubuntu 24.04 LTS AMI ID."
  type        = string

  validation {
    condition     = startswith(var.ubuntu_ami_id, "ami-")
    error_message = "ubuntu_ami_id must be an EC2 AMI ID beginning with ami-."
  }
}

variable "admin_cidr" {
  description = "Public IPv4 CIDR allowed to connect over SSH, normally a single /32 address."
  type        = string

  validation {
    condition     = can(cidrnetmask(var.admin_cidr))
    error_message = "admin_cidr must be a valid IPv4 CIDR such as 203.0.113.10/32."
  }
}

variable "ssh_public_key_path" {
  description = "Path to the public SSH key imported into EC2."
  type        = string
}

The values that may differ between environments are variables rather than being scattered throughout the resource definitions.

Define the AWS infrastructure

Create main.tf:

provider "aws" {
  region  = var.aws_region
  profile = var.aws_profile

  default_tags {
    tags = {
      Project   = var.project_name
      ManagedBy = "OpenTofu"
    }
  }
}

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.20.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.20.10.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-igw"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-public"
  }
}

resource "aws_route" "internet" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.main.id
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

resource "aws_security_group" "vps" {
  name        = "${var.project_name}-sg"
  description = "Restricted access to the VPS"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.vps.id
  description       = "SSH from the administrator address"
  cidr_ipv4         = var.admin_cidr
  from_port         = 22
  to_port           = 22
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "all" {
  security_group_id = aws_security_group.vps.id
  description       = "Allow outbound traffic"
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

resource "aws_key_pair" "admin" {
  key_name = "${var.project_name}-admin"
  public_key = trimspace(
    file(pathexpand(var.ssh_public_key_path))
  )
}

resource "aws_instance" "vps" {
  ami                    = var.ubuntu_ami_id
  instance_type          = var.instance_type
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.vps.id]
  key_name               = aws_key_pair.admin.key_name

  # The Elastic IP is associated separately below.
  associate_public_ip_address = false

  root_block_device {
    encrypted             = true
    volume_type           = "gp3"
    volume_size           = 20
    delete_on_termination = true
  }

  metadata_options {
    http_endpoint = "enabled"
    http_tokens   = "required"
  }

  tags = {
    Name = var.project_name
  }

  depends_on = [
    aws_route.internet
  ]
}

resource "aws_eip" "vps" {
  domain = "vpc"

  tags = {
    Name = "${var.project_name}-public-ip"
  }
}

resource "aws_eip_association" "vps" {
  instance_id   = aws_instance.vps.id
  allocation_id = aws_eip.vps.id
}

The VPC uses the private range:

10.20.0.0/16

The public subnet uses:

10.20.10.0/24

The route table sends Internet-bound traffic through the Internet Gateway.

The security group only permits incoming TCP port 22 traffic from admin_cidr. There are no public HTTP, HTTPS, database, or application ports at this point.

The root EBS volume is encrypted and deleted when the EC2 instance is destroyed. If the server will hold data that must survive instance replacement, that data should be stored on a separate volume or external storage with a deliberate backup and retention plan.

The EC2 metadata configuration requires IMDSv2 session tokens instead of allowing unrestricted IMDSv1 access.

Define the outputs

Create outputs.tf:

output "instance_id" {
  description = "EC2 instance ID."
  value       = aws_instance.vps.id
}

output "public_ip" {
  description = "Stable public IPv4 address assigned to the VPS."
  value       = aws_eip.vps.public_ip
}

output "ssh_host" {
  description = "Ubuntu SSH destination."
  value       = "ubuntu@${aws_eip.vps.public_ip}"
}

These outputs provide the values I normally need after creating the instance.

Set the environment values

Create terraform.tfvars:

aws_profile = "personal"
aws_region  = "ap-southeast-1"

project_name  = "personal-vps"
instance_type = "t3.micro"

ubuntu_ami_id = "ami-0123456789abcdef0"

admin_cidr = "203.0.113.10/32"

ssh_public_key_path = "~/.ssh/aws-vps.pub"

Replace:

  • ubuntu_ami_id with the AMI returned by the AWS CLI query.
  • admin_cidr with the current administrator public IP followed by /32.
  • aws_profile with the correct local AWS CLI profile.
  • ssh_public_key_path if the key is stored elsewhere.

The terraform.tfvars file should not contain AWS access keys, passwords, private SSH keys, application secrets, or API tokens.

Initialize the project

Initialize OpenTofu:

tofu init

This downloads the AWS provider and creates .terraform.lock.hcl.

Format the configuration:

tofu fmt -recursive

Validate it:

tofu validate

A valid configuration should return:

Success! The configuration is valid.

Review the execution plan

Create a saved plan:

tofu plan -out=apply.tfplan

I review the plan before applying it:

tofu show apply.tfplan

The first plan should contain resources for:

  • The VPC and subnet.
  • The Internet Gateway and route table.
  • The security group and its rules.
  • The EC2 key pair.
  • The EC2 instance.
  • The Elastic IP and its association.

I also verify that the plan is using the expected AWS account, region, instance type, AMI, and SSH CIDR.

A successful plan does not prove that the configuration is safe. It only shows what OpenTofu intends to change. The actual resource names, permissions, network exposure, and cost implications still need to be reviewed.

Create the VPS

Apply the saved plan:

tofu apply apply.tfplan

Once the operation completes, view the outputs:

tofu output

The result should resemble:

instance_id = "i-0123456789abcdef0"
public_ip   = "198.51.100.20"
ssh_host    = "ubuntu@198.51.100.20"

Connect over SSH

Connect using the private key created earlier:

ssh \
  -i "$HOME/.ssh/aws-vps" \
  "$(tofu output -raw ssh_host)"

Ubuntu EC2 images use the ubuntu user by default.

On the first connection, SSH will ask whether the host key should be trusted. I verify that I am connecting to the expected Elastic IP before accepting it.

After logging in, I update the operating system:

sudo apt update
sudo apt full-upgrade -y

If the upgrade installs a new kernel, reboot the instance:

sudo reboot

The Elastic IP remains associated with the instance, so I reconnect using the same address after it starts again.

Verify the instance

After reconnecting, I check the operating system:

cat /etc/os-release

Check the block devices:

lsblk

Check listening services:

sudo ss -lntup

Confirm the public IPv4 address:

curl -4 https://checkip.amazonaws.com

The result should match:

tofu output -raw public_ip

I can also inspect the current OpenTofu-managed resources with:

tofu state list

Updating the administrator IP

If my public IP changes, SSH will stop working because the old address is still the only permitted source.

I obtain the new address:

curl -4 https://checkip.amazonaws.com

Then update admin_cidr in terraform.tfvars:

admin_cidr = "NEW_PUBLIC_IP/32"

Review and apply the change:

tofu plan -out=update.tfplan
tofu show update.tfplan
tofu apply update.tfplan

This changes the security-group rule without replacing the EC2 instance.

Opening application ports

I do not open application ports until the application is installed and ready to receive traffic.

For example, a web server may eventually require TCP ports 80 and 443. Those should be added as explicit security-group rules rather than replacing the restricted SSH rule with a broad rule.

SSH should remain restricted to a known address, VPN subnet, or management network. There is rarely a good reason to expose port 22 to 0.0.0.0/0.

Making infrastructure changes

When changing the instance type, storage size, network rules, or other resources, I use the same workflow:

tofu fmt -recursive
tofu validate
tofu plan -out=update.tfplan
tofu show update.tfplan
tofu apply update.tfplan

The plan is especially important when changing fields such as the AMI, subnet, or root storage configuration because some changes can replace the entire EC2 instance.

I do not run tofu apply blindly after editing the files.

Cost considerations

This setup is small, but it is not free by definition.

AWS may charge for:

  • EC2 compute time.
  • EBS storage.
  • The public IPv4 address.
  • Outbound data transfer.
  • Snapshots and backups added later.

A stopped EC2 instance can still incur storage and public-address-related charges. Stopping a server is not the same as deleting all of its billable resources.

AWS is also not necessarily the cheapest provider for a basic VPS. I am using it here because the purpose of this setup is to document a repeatable AWS deployment rather than identify the lowest-cost VPS provider.

I review the current AWS pricing before leaving resources running.

Destroying the VPS

When I no longer need the environment, I create a destruction plan:

tofu plan -destroy -out=destroy.tfplan

Review it:

tofu show destroy.tfplan

Then apply it:

tofu apply destroy.tfplan

This removes the EC2 instance, Elastic IP, security group, route table, subnet, Internet Gateway, key pair, and VPC managed by this project.

I verify that the operation completed before deleting the local project or state files:

tofu state list

The command should return no managed resources.

I also check the AWS console or CLI afterward to confirm that no unexpected EC2 volumes, snapshots, public IP addresses, or other chargeable resources remain.

This gives me a clean baseline for creating an AWS VPS without relying on the console or trying to remember which options I selected the last time. The infrastructure can now be reviewed, versioned, rebuilt, and extended without turning the initial server setup into a manual process.

End of article

Continue reading

Tags

Guides Aws Linux Networking Opentofu