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 OpenTofu, cloud-init, restricted SSH access, encrypted storage, IMDSv2, and a stable public IP.

I have built enough throwaway VPS instances in cloud dashboards to know that “I will remember what I clicked” is not a real process. This is the version I want written down so I can rebuild the same box later without guessing.

AWS calls it an EC2 instance, but I am using it like a small VPS so that is what I call it here. The main detail in this build is that I do not keep the default Ubuntu login path. I use cloud-init on first boot to replace the image’s normal ubuntu user with my own admin account, install my SSH public key directly for that user, and move SSH to an upper port from the start.

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 on a custom port.
  • A first-boot UID 1000 admin user created by cloud-init using my existing local SSH public key.
  • An Ubuntu EC2 instance with an encrypted root volume.
  • An Elastic IP address that remains stable across instance restarts.

That is the boundary for this article. I am only covering first boot and infrastructure: the replacement UID 1000 admin user, SSH key install, SSH upper-port config, restricted AWS ingress, encrypted root storage, IMDSv2, and the Elastic IP. I am not covering nftables, UFW, unattended upgrades, application deployment, reverse proxying, TLS, backups, or monitoring here.

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 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 create the SSH key locally and only let cloud-init read the public key. 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 what cloud-init installs into the first-boot admin account.

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 the SSH port to the whole 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.

I also pick the SSH port up front and keep it consistent everywhere in the project. For this article I use 42422.

Moving SSH off 22 is mostly about reducing low-effort discovery, automated scans, and log noise. It is not a substitute for actual security controls. The real controls here are the restricted AWS security-group CIDR, SSH key authentication, IMDSv2, encrypted root storage, and later operating-system hardening.

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 "admin_username" {
  description = "Admin username created as UID 1000 during first boot."
  type        = string
  default     = "adminuser"

  validation {
    condition     = can(regex("^[a-z_][a-z0-9_-]{0,31}$", var.admin_username))
    error_message = "admin_username must be a valid Linux username."
  }
}

variable "ssh_port" {
  description = "SSH port exposed by the VPS."
  type        = number
  default     = 42422

  validation {
    condition     = var.ssh_port >= 1024 && var.ssh_port <= 65535
    error_message = "ssh_port must be between 1024 and 65535."
  }
}

variable "ssh_public_key_path" {
  description = "Path to the public SSH key installed for the admin user."
  type        = string
}

The values that may change between environments stay here instead of being scattered through the resources. The same admin_username and ssh_port need to be used consistently in the variables, cloud-init, outputs, and SSH examples.

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         = var.ssh_port
  to_port           = var.ssh_port
  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_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]

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

  user_data = <<-EOF
#cloud-config
users:
  - name: ${var.admin_username}
    uid: 1000
    groups:
      - adm
      - sudo
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:ALL
    lock_passwd: true
    ssh_authorized_keys:
      - ${trimspace(file(pathexpand(var.ssh_public_key_path)))}

ssh_pwauth: false
disable_root: true

write_files:
  - path: /etc/ssh/sshd_config.d/10-port.conf
    owner: root:root
    permissions: '0644'
    content: |
      Port ${var.ssh_port}

runcmd:
  - /usr/sbin/sshd -t
  - systemctl restart ssh
  EOF

  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 traffic from admin_cidr to var.ssh_port. There are no public HTTP, HTTPS, database, or application ports at this point.

I am not creating an AWS EC2 key pair resource here. The SSH public key is installed directly by cloud-init into the first-boot admin account, so the instance does not need the AWS key_name path at all.

Because the users list does not include default, cloud-init does not create the image’s default ubuntu user. The chosen admin user becomes the first normal user at UID 1000, and the SSH public key is installed directly for that account.

I am setting UID 1000 because this is a single-user personal VPS and I want the main login user to occupy the normal first-user slot. I would not present that as a universal rule for every server.

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 "admin_username" {
  description = "Admin username created as UID 1000 during first boot."
  value       = var.admin_username
}

output "ssh_port" {
  description = "SSH port configured during first boot."
  value       = var.ssh_port
}

output "ssh_host" {
  description = "SSH destination for the admin user."
  value       = "${var.admin_username}@${aws_eip.vps.public_ip}"
}

output "ssh_command" {
  description = "SSH command for the admin user."
  value       = "ssh -i ~/.ssh/aws-vps -p ${var.ssh_port} ${var.admin_username}@${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"
admin_username = "adminuser"
ssh_port       = 42422

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.
  • admin_username if I want something other than adminuser.
  • ssh_port if I want a different upper SSH port.
  • 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 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"
admin_username  = "adminuser"
ssh_port        = 42422
ssh_host        = "adminuser@198.51.100.20"
ssh_command     = "ssh -i ~/.ssh/aws-vps -p 42422 adminuser@198.51.100.20"

Connect over SSH

Connect using the private key created earlier:

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

Or directly:

ssh -i "$HOME/.ssh/aws-vps" -p 42422 adminuser@SERVER_PUBLIC_IP

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.

The Ubuntu cloud image normally uses cloud-init to create the default ubuntu user. In this setup the cloud-init users: list does not include default, so that user is never created. My chosen user replaces that login path on first boot, and I connect directly as the UID 1000 account instead.

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 verify the basics first.

Check the login user:

whoami

Expected result:

adminuser

Check the UID:

id -u

Expected result:

1000

Check sudo:

sudo -v

Check the SSH port seen by sshd:

sudo sshd -T | grep '^port '

Expected result:

port 42422

Check the cloud-init-created SSH port drop-in:

cat /etc/ssh/sshd_config.d/10-port.conf

Expected result:

Port 42422

Confirm the default Ubuntu user is not present:

getent passwd ubuntu

Expected result: no output.

Also check that the home directory was never created:

ls -ld /home/ubuntu

Expected result: No such file or directory.

Then 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"

If I change the admin username or SSH port later, I need to update every place that depends on them and treat that like a deliberate rebuild path. For this setup I keep admin_username = "adminuser" and ssh_port = 42422 stable from the start so the first-boot state, outputs, and login examples all agree.

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 it broadly.

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, 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.

That gives me a clean baseline for an AWS VPS without depending on the console or the default ubuntu login path. From here I can move on to the separate hardening and application setup steps as their own notes.

End of article

Continue reading

Tags

Guides Aws Linux Networking Opentofu