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
1000admin 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.1means the service is only listening locally.0.0.0.0means 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.