A fresh Ubuntu instance on EC2 is better than most defaults: SSH accepts keys only, root can't log in with a password, AppArmor is on and security updates install themselves. It's still not ready for production. The launch wizard happily opens port 22 to the whole internet, nothing reboots the server after a kernel update, the instance may still answer IMDSv1, and the moment you run Docker, your firewall rules stop applying to the containers.
This is the hardening checklist I run on every new Ubuntu Server LTS instance (written against 24.04, and the same on newer LTS releases) before it serves a single request. Each step has the commands and a way to check it worked. Do them in order: some later steps assume earlier ones.
Part of my guide to AWS infrastructure for small SaaS teams.
TL;DR#
- On the AWS side: no SSH open to the world, IMDSv2 required, EBS encryption on, a least-privilege instance role, and SSM Session Manager for shell access.
- Patch, and keep patching: full upgrade now, unattended security upgrades with a planned reboot window.
- Lock down SSH with a drop-in file: keys only, no root, named users. On Ubuntu the first value set wins.
- Security groups are the firewall; UFW is a second layer. Docker-published ports bypass UFW, so bind containers to
127.0.0.1. - Tighten the kernel with a few
sysctlsettings, remove what you don't run, and audit changes to users, sudo and SSH. - Run the app as its own unprivileged user, and check your work with
lynis.
Before you log in: the EC2 side#
Half of hardening an EC2 instance happens outside it.
Security group. Allow 80 and 443 (or only the load balancer's security group, if there is one). Don't allow 22 from 0.0.0.0/0. Either allow it from your own IP as a /32, or don't open it at all and use Session Manager.
SSM Session Manager instead of SSH. Attach an instance profile with the AmazonSSMManagedInstanceCore policy. Ubuntu's AMIs ship the SSM Agent preinstalled (as a snap), so you can open a shell with no inbound port, no keys to manage, and every session logged:
aws ssm start-session --target i-0abc123def4567890
(Locally that needs the AWS CLI and the Session Manager plugin.)
Require IMDSv2. The instance metadata service hands out the instance role's credentials. IMDSv2 requires a session token first, which stops the classic server-side request forgery attack where a bug in your app is tricked into fetching http://169.254.169.254/... and leaking them:
# A hop limit of 1 keeps containers from reaching the metadata service at all;
# use 2 if containers need the instance role's credentials.
aws ec2 modify-instance-metadata-options \
--instance-id i-0abc123def4567890 \
--http-tokens required \
--http-put-response-hop-limit 1 \
--http-endpoint enabled
Encrypt disks by default for the whole Region, so no volume is ever created unencrypted:
aws ec2 enable-ebs-encryption-by-default
A least-privilege instance role. Only the permissions the app and agents use: reading its own parameters, writing its own log group, pulling from its own ECR repository. Never AdministratorAccess "for now".
AWS's own EC2 security best practices cover these in more depth.
1. Update everything, and keep it updated#
Start from a fully patched system:
sudo apt update && sudo apt full-upgrade -y
[ -f /var/run/reboot-required ] && cat /var/run/reboot-required.pkgs && sudo reboot
Ubuntu cloud images already install security updates every day with unattended-upgrades. Make sure it's on, and decide what happens when an update needs a reboot. By default nothing does, so a patched kernel waits forever:
sudo apt install -y unattended-upgrades
sudo tee /etc/apt/apt.conf.d/20auto-upgrades > /dev/null <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
# Local overrides, read after the package's own 50unattended-upgrades file.
sudo tee /etc/apt/apt.conf.d/52unattended-upgrades-local > /dev/null <<'EOF'
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
EOF
sudo unattended-upgrade --dry-run --debug
The reboot time is the server's local time, which is UTC on EC2 unless you changed it. On a single server, that's about a minute of downtime at 4 a.m. If that isn't acceptable, set Automatic-Reboot to false and reboot in a maintenance window you choose, or run two instances behind a load balancer. Ubuntu Pro's Livepatch applies most kernel security fixes without a reboot at all.
Ubuntu also ships needrestart, which restarts services still running old versions of updated libraries after each upgrade.
2. Create your own admin user#
Shared accounts make audit logs useless. Give each administrator their own user with their own key:
sudo useradd --create-home --shell /bin/bash --groups sudo huzaifa
sudo passwd huzaifa # used only by sudo; SSH won't accept passwords
sudo install -d -m 700 -o huzaifa -g huzaifa /home/huzaifa/.ssh
echo 'ssh-ed25519 AAAAC3Nza... huzaifa@laptop' | sudo tee /home/huzaifa/.ssh/authorized_keys > /dev/null
sudo chown huzaifa:huzaifa /home/huzaifa/.ssh/authorized_keys
sudo chmod 600 /home/huzaifa/.ssh/authorized_keys
Use Ed25519 keys (ssh-keygen -t ed25519). A sudo password is a useful second factor: a stolen SSH key alone can't become root. Once your own user works, retire the shared ubuntu user's key.
3. Lock down SSH#
On Ubuntu, /etc/ssh/sshd_config includes every file in /etc/ssh/sshd_config.d/ at the very top, and for each setting the first value sshd reads wins. EC2 images already ship a 60-cloudimg-settings.conf there. Name your file so it sorts first, and nothing later can undo it:
sudo tee /etc/ssh/sshd_config.d/01-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers huzaifa deploy
MaxAuthTries 3
LoginGraceTime 20
X11Forwarding no
AllowAgentForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
sudo sshd -t && sudo systemctl restart ssh
Keep your current session open and test a new login from a second terminal before you close it. AllowUsers must list every user you log in as, including ubuntu if you still use it.
Then confirm what sshd actually uses, which also shows anything another file overrides:
sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|allowusers|maxauthtries)'
If you tunnel to a private database over SSH, keep AllowTcpForwarding at its default; otherwise set it to no as well.
Changing the port cuts log noise from scanners but isn't real protection. If you do it, note that Ubuntu 24.04 starts SSH through a systemd socket, so a new Port needs sudo systemctl daemon-reload && sudo systemctl restart ssh.socket, and the security group must allow the new port first.
4. Firewall: security groups first, UFW second#
On EC2 the security group is the real firewall: it filters traffic before it reaches the instance. UFW on the host adds a second layer, which helps if someone loosens a security group by mistake.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 203.0.113.10 to any port 22 proto tcp # your IP, if SSH is open at all
sudo ufw allow 80,443/tcp
sudo ufw enable
sudo ufw status verbose
Allow SSH before ufw enable, or you'll lock yourself out (Session Manager still gets you back in).
Docker bypasses UFW. When you publish a container port with -p 5432:5432, Docker writes its own iptables rules, and traffic to that port is routed to the container before UFW's rules see it. Docker's documentation on Docker and ufw says so plainly. So a database container published that way is reachable from anywhere the security group allows, whatever ufw status says. Two rules:
- Publish only to localhost:
-p 127.0.0.1:3000:3000, with Nginx on the host in front. - Keep the security group tight, since it's the layer Docker can't route around.
5. Fail2ban, if SSH is reachable#
If port 22 isn't open to the internet, skip this. If it is, even to a range of addresses, fail2ban bans addresses after repeated failed logins:
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
backend = systemd reads sshd's messages from the journal rather than a log file.
6. Kernel and network settings#
A few sysctl settings close off behaviour a web server never needs. Put them in their own file so package upgrades leave them alone:
sudo tee /etc/sysctl.d/99-hardening.conf > /dev/null <<'EOF'
# This isn't a router: ignore ICMP redirects and source-routed packets, and don't send redirects.
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
# Log packets with impossible source addresses, and keep SYN flood protection on.
net.ipv4.conf.all.log_martians = 1
net.ipv4.tcp_syncookies = 1
# Hide kernel addresses and the kernel log from unprivileged users; no core dumps from setuid programs.
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
fs.suid_dumpable = 0
EOF
sudo sysctl --system
Leave net.ipv4.ip_forward alone if you run Docker: container networking needs it on.
7. Remove what you don't run#
Every listening service is attack surface. See what's listening and on which address:
sudo ss -tulpn
systemctl list-units --type=service --state=running
On a typical app server you should see SSH, Nginx on 80 and 443, your app on 127.0.0.1, and local resolvers and time sync on loopback addresses. Anything else listening on 0.0.0.0 or [::] needs a reason to exist: stop and disable it (sudo systemctl disable --now <name>) or purge the package.
Don't remove snapd on EC2: the SSM Agent is installed as a snap.
8. Time, logs and auditing#
Time. Certificates, logs and signed AWS requests all depend on an accurate clock. Check it with timedatectl, and make sure time sync points at the Amazon Time Sync Service at 169.254.169.123, which every instance can reach.
Logs. Keep the journal from filling the disk:
sudo mkdir -p /etc/systemd/journald.conf.d
printf '[Journal]\nSystemMaxUse=500M\n' | sudo tee /etc/systemd/journald.conf.d/size.conf > /dev/null
sudo systemctl restart systemd-journald
Then ship the logs that matter off the box, so they survive the instance and an intruder can't quietly edit them. CloudWatch logs, metrics and alarms for a Node.js API sets that up.
Auditing. auditd records who changed the files that control access:
sudo apt install -y auditd
sudo tee /etc/audit/rules.d/hardening.rules > /dev/null <<'EOF'
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers
-w /etc/ssh/sshd_config -p wa -k sshd
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
EOF
sudo augenrules --load
sudo ausearch -k sudoers --start today
9. Run the app with the least privilege#
Hardening the host matters less if the app runs as root. Give it its own user with no login shell, and keep its secrets readable only by that user:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin api
sudo chown root:api /srv/api/.env && sudo chmod 640 /srv/api/.env
If systemd runs the app, its sandboxing options cost one line each:
[Service]
User=api
Group=api
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/api/uploads
systemd-analyze security api.service scores the unit and lists what else you could restrict. In Docker, the equivalent is a non-root USER in the image (as in this Dockerfile) plus --read-only, --cap-drop ALL and --security-opt no-new-privileges on docker run.
10. Check your work#
# IMDSv1 refused: expect 401
curl -s -o /dev/null -w '%{http_code}\n' http://169.254.169.254/latest/meta-data/
# What sshd and UFW actually enforce
sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication)'
sudo ufw status verbose
# A full audit with a hardening score and suggestions
sudo apt install -y lynis && sudo lynis audit system
Lynis won't score 100, and shouldn't: some suggestions don't fit a single-purpose server. Read its warnings, fix the ones that apply, and re-run it after changes. If you need a formal benchmark, the Ubuntu Security Guide (part of Ubuntu Pro) applies and audits the CIS profiles.
The checklist#
- Security group allows 80/443; SSH closed or limited to your IP
- Session Manager works through an instance profile with
AmazonSSMManagedInstanceCore - IMDSv2 required, with a hop limit of 1 unless containers need the role
- EBS encryption by default is on for the Region
- The instance role has only the permissions the app uses
- Fully upgraded; unattended security upgrades on, with a reboot plan
- Named admin users with Ed25519 keys and a sudo password
-
01-hardening.conffor SSH: keys only, no root,AllowUsers, verified withsshd -T - UFW on, default deny incoming
- Containers publish ports to
127.0.0.1only - Fail2ban on, if SSH is reachable from the internet
-
sysctlhardening applied - Only expected services are listening
- Time syncs with the Amazon Time Sync Service; the journal is capped; logs ship off the box
-
auditdwatches users, sudo and SSH config - The app runs as its own unprivileged user; its secrets are mode 640
-
lynis audit systemreviewed
FAQ#
How do I harden an Ubuntu server?#
Patch it fully and turn on unattended security upgrades, create named admin users with SSH keys, disable root and password logins in a sshd_config.d drop-in, enable UFW with a default-deny policy, apply a few sysctl network settings, remove services you don't use, audit changes with auditd, and run your app as an unprivileged user. Then check the result with lynis audit system.
Is Ubuntu on EC2 secure by default?#
Mostly, but not completely. Ubuntu's EC2 images allow only key-based SSH, disable root password logins, enable AppArmor and install security updates automatically. What's missing is outside the defaults: security groups often open SSH to the world, IMDSv1 may still be allowed, nothing reboots for kernel updates, and Docker-published ports bypass UFW.
Do I need UFW if I have AWS security groups?#
Security groups are the main firewall on EC2 and filter traffic before it reaches the instance, so they matter most. UFW is a useful second layer that protects you if a security group is loosened by mistake. It doesn't protect ports published by Docker, which bypass UFW's rules.
Does Docker bypass UFW?#
Yes. When you publish a container port, Docker adds its own iptables rules that route the traffic to the container before UFW's rules are checked, so the port is reachable even if UFW denies it. Publish ports to 127.0.0.1 only and put Nginx in front, and rely on security groups as the outer firewall.
Should I change the default SSH port?#
It reduces noise from automated scanners in your logs, but it isn't real security: a port scan finds the new port quickly. Key-only authentication, no root login, AllowUsers, and limiting port 22 to known IPs in the security group protect you far more. Better still, close SSH and use SSM Session Manager.
Related reading#
- AWS infrastructure for small SaaS teams: what you actually need
- VPCs, subnets and security groups explained for developers
- GitHub Actions CI/CD: build, test and deploy to EC2, releasing over SSM with no SSH port
- Nginx reverse proxy for Node.js with free SSL
- CloudWatch logs, metrics and alarms for a Node.js API
