Tech Guide Infrastructure

Hardening a Fresh Ubuntu VPS After Provisioning It with OpenTofu

A practical follow-up to the OpenTofu VPS setup, covering the first OS checks, updates, nftables, and a small maintenance baseline.

In the previous article, I used OpenTofu to create the AWS VPS and cloud-init to set the first login user, SSH key, and SSH port.

That gets the server online, but I still do not want to deploy anything on it yet. First I want to check the machine, update it, put a local firewall in place, and confirm what is actually listening.

For this setup, the previous article already established the login details I am using here:

  • Admin user: adminuser
  • Admin UID: 1000
  • SSH port: 42422
  • SSH key: ~/.ssh/aws-vps

What this article assumes

The previous OpenTofu article already handled:

  • AWS VPC, subnet, route table, and Internet Gateway
  • AWS security group
  • Elastic IP
  • encrypted root volume
  • IMDSv2
  • cloud-init UID 1000 admin user
  • SSH key installation
  • SSH upper port

I am not creating another admin user here. The default Ubuntu login was already replaced during first boot.

This is a baseline OS pass only. At this point I want to confirm the login state, update the host, add nftables, enable unattended security updates, and check what the box is doing before I install anything else.

Connect to the VPS

If I just want the direct command, I use:

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

If I am still inside the OpenTofu project, I can reuse the outputs:

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

I keep this first session open while I work through the firewall steps later.

Confirm the cloud-init user

Before changing anything, I check the login account and the expected UID:

whoami
id
id -u
groups

The UID should be:

1000

Then I confirm the default Ubuntu user does not exist:

getent passwd ubuntu

Expected result: no output.

I also check the old home path:

ls -ld /home/ubuntu

Expected result: no such file or directory.

I also make sure sudo works before I go any further:

sudo -v

Update the operating system

Before adding more config, I bring the OS up to date:

sudo apt update
sudo apt full-upgrade -y

If the host says it needs a reboot, I check with:

test -f /var/run/reboot-required && cat /var/run/reboot-required

If that file exists, I reboot and reconnect before continuing.

Confirm SSH configuration

The previous article already set the SSH port during first boot. I am only verifying it here, not changing it.

Check the live sshd view:

sudo sshd -T | grep '^port '

Expected result:

port 42422

Then check the cloud-init drop-in:

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

Expected result:

Port 42422

At this point I know I am still using the same login path that OpenTofu and cloud-init set up earlier.

Configure nftables

I use nftables here instead of UFW.

Install the package:

sudo apt install -y nftables

Check the current ruleset first:

sudo nft list ruleset

Then create or replace /etc/nftables.conf:

sudo nano /etc/nftables.conf

Use this baseline:

#!/usr/sbin/nft -f

flush ruleset

define ssh_port = 42422

table inet filter {
  chain input {
    type filter hook input priority filter; policy drop;

    ct state established,related accept
    ct state invalid drop

    iifname "lo" accept

    ip protocol icmp accept
    ip6 nexthdr icmpv6 accept

    tcp dport $ssh_port accept
  }

  chain forward {
    type filter hook forward priority filter; policy drop;
  }

  chain output {
    type filter hook output priority filter; policy accept;
  }
}

The rules are simple on purpose:

  • default drop for incoming traffic
  • allow established connections
  • allow loopback
  • allow ICMP and ICMPv6
  • allow SSH on the existing upper port
  • drop forwarding
  • allow outbound traffic

The AWS security group is already restricting the SSH source CIDR. This local ruleset allows the SSH port, but it does not try to duplicate the source IP restriction that AWS is already enforcing.

Before I rely on the file, I validate it:

sudo nft -c -f /etc/nftables.conf

Then load it:

sudo nft -f /etc/nftables.conf

Enable it on boot:

sudo systemctl enable nftables

Check the service:

systemctl status nftables

Check the active rules again:

sudo nft list ruleset

Test SSH before closing the session

This part matters more than the firewall file itself.

Keep the current SSH session open.

In a second terminal, test a fresh login:

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

Only close the first session after the second login works.

That gives me a quick check that the local firewall, the existing SSH port, and the AWS security group still agree with each other.

Enable automatic security updates

I want security updates to keep moving even if I do not log in for a few days.

Install the package:

sudo apt install -y unattended-upgrades

Run the package configuration:

sudo dpkg-reconfigure --priority=low unattended-upgrades

Then confirm the periodic settings:

cat /etc/apt/apt.conf.d/20auto-upgrades

Expected values should look similar to:

APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";

Check the service and the log:

systemctl status unattended-upgrades
sudo less /var/log/unattended-upgrades/unattended-upgrades.log

This reduces stale security patches, but it is not a full maintenance plan by itself.

Check listening services

Now I want to see what is actually bound on the host:

sudo ss -lntup

When I read this output, I pay attention to the bind address:

  • 127.0.0.1 means the service is only listening locally.
  • 0.0.0.0 means it is listening on all IPv4 interfaces.

For a fresh VPS, I want this list to stay short.

I also check for failed services:

systemctl --failed
sudo journalctl -p 3 -xb

If something is already failing on a clean server, I want to know now rather than after I add more services.

Reboot and reconnect

Once the updates and firewall are in place, I do a clean reboot:

sudo reboot

Then reconnect:

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

After reconnecting, I re-check the basics:

sudo nft list ruleset
systemctl --failed
sudo ss -lntup

This is the point where I trust the box enough to move on to actual service setup.

A small maintenance checklist

These are the commands I tend to rerun on a small VPS:

sudo apt update
sudo apt full-upgrade
test -f /var/run/reboot-required && cat /var/run/reboot-required
sudo nft list ruleset
sudo ss -lntup
systemctl --failed
df -h
free -h
last -a | head

This is not production hardening. It is just a small repeatable checklist that catches the obvious drift.

What this does not cover

This article does not cover:

  • app deployment
  • reverse proxy
  • TLS
  • backups
  • monitoring
  • IDS
  • database hardening
  • secret management
  • disaster recovery

Those need their own notes because they each change the system in different ways.

What comes next

From here, the next useful step is probably either deploying a small web app behind Caddy or adding backups to the VPS. I would keep those as separate articles so this one stays focused on the first hardening pass after provisioning.

End of article

Continue reading

Tags

Guides Aws Linux Security Opentofu