Hardening an Ubuntu EC2 Server: SSH, UFW, fail2ban and Automatic Updates
A fresh EC2 instance with a public IP starts receiving automated SSH login attempts within minutes. They are not targeted at you — they are broad scans working through the IPv4 space. None of what follows is exotic, and all of it is the difference between a server that shrugs those off and one that eventually does not.
This is the baseline for a public-facing Ubuntu 24.04 instance. It takes about twenty minutes and should be done before the machine serves anything.
SSH: keys only, and no root
Ubuntu AMIs on EC2 already disable password authentication, but it is worth making the configuration explicit rather than relying on a default that a later package upgrade or a copied config could change. Use a drop-in file, which survives upgrades cleanly.
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf > /dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
MaxAuthTries 3
LoginGraceTime 20
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding yes
AllowUsers ubuntu
EOF
sudo sshd -t && sudo systemctl reload sshRun sshd -t before reloading, and keep your existing session open while you test a new one from a second terminal. A syntax error or a mistaken AllowUsers line that locks you out of a cloud instance means recovering through the serial console or detaching the root volume.
Prefer Ed25519 keys over RSA. They are shorter, faster to verify, and have no key-size decision to get wrong.
ssh-keygen -t ed25519 -a 100 -C "you@example.com"Better still, remove inbound SSH entirely and use Session Manager. If the instance has the SSM agent and an instance profile with AmazonSSMManagedInstanceCore, you can open a shell with no open port, no key, and full CloudTrail logging of who connected when:
aws ssm start-session --target i-0123456789abcdef0A default-deny firewall
The EC2 security group is your first firewall and the more important one, because it filters before traffic reaches the instance at all. UFW on the host is defence in depth — it catches the case where a security group is loosened by mistake, and it constrains outbound traffic, which security groups rarely do.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw limit 22/tcp comment 'SSH with rate limiting'
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
sudo ufw --force enable
sudo ufw status verboseufw limit rather than allow on port 22 rejects a source IP that attempts more than six connections in thirty seconds. It is a small thing that eliminates the bulk of low-effort brute forcing on its own.
The Docker firewall gap
This deserves its own section because it surprises almost everyone. Docker manipulates iptables directly, inserting its rules into the DOCKER chain which is evaluated before the chains UFW manages. The consequence is that a published container port is reachable from the internet even when UFW is configured to deny it.
# UFW says this port is blocked...
sudo ufw status | grep 5432
# ...but this is reachable from anywhere.
docker run -d -p 5432:5432 postgres:17There are two correct fixes, and one non-fix that people try first.
| Approach | Works? | Notes |
|---|---|---|
| Bind to 127.0.0.1 in the ports mapping | Yes | Simplest and most reliable — do this |
| Do not publish the port at all | Yes | Use an internal Docker network between containers |
| Set iptables: false in daemon.json | Partly | Breaks container networking unless you write the rules yourself |
| Add a UFW deny rule for the port | No | Docker rules are evaluated first and win |
# Safe: only reachable from the host itself
ports:
- "127.0.0.1:5432:5432"Verify what is actually listening on a public interface, rather than trusting the firewall configuration to describe reality:
sudo ss -tulpn | grep -v '127.0.0.1\|::1'Anything in that output bound to 0.0.0.0 is exposed to whatever your security group permits. That command is worth running after every deployment change.
fail2ban
fail2ban watches log files for repeated failures and adds temporary firewall bans. With key-only SSH it is less critical than it used to be, but it also protects Nginx from credential stuffing and request floods, which is where it earns its place now.
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
destemail = ops@example.com
ignoreip = 127.0.0.1/8 10.0.0.0/8
[sshd]
enabled = true
maxretry = 3
bantime = 24h
[nginx-http-auth]
enabled = true
[nginx-limit-req]
enabled = true
maxretry = 10
findtime = 1m
bantime = 30m
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshdThe nginx-limit-req jail pairs with the Nginx rate limiting configuration: Nginx returns 429 to requests over the limit and logs them, and fail2ban bans a client that keeps hitting the limit rather than backing off. Add your own address to ignoreip before you lock yourself out during testing.
Automatic security updates
An unpatched server is the most common way a well-configured one is compromised. unattended-upgrades applies security updates automatically, and on Ubuntu it is installed but not always configured to actually do anything.
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
sudo tee /etc/apt/apt.conf.d/52-custom-upgrades > /dev/null <<'EOF'
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Mail "ops@example.com";
Unattended-Upgrade::MailReport "on-change";
EOF
sudo unattended-upgrades --dry-run --debugNote Automatic-Reboot set to false. Security updates apply without a reboot, but a kernel update only takes effect after one. Rebooting unattended at 06:00 is a reasonable choice for a redundant fleet and a poor one for a single instance serving traffic. Check whether a reboot is pending and schedule it deliberately:
[ -f /var/run/reboot-required ] && cat /var/run/reboot-required.pkgsUbuntu Pro, free for personal use on up to five machines, extends security patching to the much larger universe repository and enables livepatch for kernel fixes without reboots. On a single-instance deployment that is a meaningful upgrade.
Instance metadata: require IMDSv2
The EC2 instance metadata service hands out temporary credentials for the instance role. Under IMDSv1 it responds to any plain GET from the instance, which means a server-side request forgery bug in your application can be used to read those credentials. IMDSv2 requires a PUT to obtain a token first, and sets a low IP TTL so the request cannot be proxied through most SSRF vectors.
aws ec2 modify-instance-metadata-options \
--instance-id i-0123456789abcdef0 \
--http-tokens required \
--http-endpoint enabled \
--http-put-response-hop-limit 1A hop limit of 1 stops a container on a bridge network from reaching the metadata service at all, since the extra network hop exceeds the limit. If your workload runs in Docker and genuinely needs instance credentials, use 2 — and be aware you have re-opened the path.
Give the instance role the narrowest policy that works. The most common finding in a small-team AWS account is an instance profile with a broad managed policy attached because it was quicker than writing the specific one.
Baseline checklist
- Security group opens only 80, 443, and SSH from a known address — or no SSH at all
- Key-only SSH, root login disabled, verified with a second session before closing the first
- UFW default-deny inbound, with ufw limit on SSH
- No container port published to 0.0.0.0 — confirmed with ss, not assumed
- fail2ban running with the sshd and nginx jails enabled
- unattended-upgrades applying security patches, with reboots scheduled deliberately
- IMDSv2 required, hop limit 1, instance role scoped to what the app actually calls
- CloudTrail on, and instance logs shipped off the box
- Root volume encrypted, and EBS snapshots on a schedule
None of this is a substitute for keeping application dependencies patched, which is where most real compromises begin. But it means a vulnerability in one component stays one component, rather than becoming the whole machine.
Do I still need fail2ban if password authentication is disabled?
For SSH specifically it adds little, since brute forcing a key is not feasible. It remains useful for the application layer — banning clients that hammer a login endpoint or repeatedly trip Nginx rate limits — and for keeping authentication log noise down.
Why does UFW not block my Docker container ports?
Docker writes its own iptables rules into a chain that is evaluated before the chain UFW controls, so a published port bypasses UFW entirely. Bind the port to 127.0.0.1 in the ports mapping, or do not publish it and use an internal Docker network instead.
Should I change the SSH port from 22?
It reduces log noise from untargeted scanners and does nothing against a determined attacker, who will find the port in seconds. It is not harmful, but it is not security either. Spend the effort on key-only authentication and closing the port entirely with SSM.
Is unattended-upgrades safe to enable in production?
For the security pocket specifically, yes — those updates are narrowly scoped and heavily tested. Leave automatic reboots off on a single instance so a kernel update does not restart your only server at 06:00, and check for the reboot-required flag as part of routine maintenance.
What is the single highest-value item on this list?
Not exposing services you did not mean to expose. Almost every incident on a small deployment traces back to a database, admin panel or debug endpoint reachable from the internet — usually through a published container port. Run ss -tulpn after every change and confirm what is actually listening.