Most VPS hardening guides end at “enable UFW”. On a host that runs Docker, that leaves you with a firewall that does not do what you think it does.
Docker writes its own iptables rules when you publish a port. Those rules are
evaluated before UFW’s, so -p 5432:5432 puts PostgreSQL on the public
internet even though ufw status shows a default-deny policy.
Everything below is on a fresh Ubuntu 24.04 LTS host.
Requirements and a warning
You need console or rescue access from your provider before you start. Two of these steps can lock you out of SSH, and recovering without console access means rebuilding the server.
Open a second SSH session and leave it connected while you work. If a change breaks authentication, the existing session survives and you can undo it.
Create a non-root user and install your key
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deployFrom your laptop, confirm the key works before touching sshd:
ssh deploy@203.0.113.42 'id'uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo)Harden SSH
Ubuntu 24.04 ships an Include /etc/ssh/sshd_config.d/*.conf line at the top of
sshd_config. Put your changes in a drop-in file so a package upgrade cannot
silently revert them.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2Validate the config before restarting. sshd -t catches syntax errors that
would otherwise stop the daemon from coming back:
sudo sshd -t && sudo systemctl restart sshNow test from a new terminal, keeping the old session open:
ssh deploy@203.0.113.42
ssh root@203.0.113.42 # must be refusedSet a default-deny firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verboseufw allow OpenSSH uses the application profile rather than a raw port number,
so it stays correct if you later move the port through the profile.
This is the point where most guides stop. It is not sufficient.
Stop Docker publishing past the firewall
Prove the problem first. Start something on a published port and scan the host from your laptop:
docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=x postgres:17-alpine# from a different machine
nmap -Pn -p 5432 203.0.113.42PORT STATE SERVICE
5432/tcp open postgresqlThere are two fixes and you want both.
Bind published ports to loopback
Anything that only needs to be reachable by other containers or by a reverse
proxy on the same host should never bind 0.0.0.0:
services:
postgres:
image: postgres:17-alpine
ports:
# Not "5432:5432" — that binds every interface.
- "127.0.0.1:5432:5432"Better still, publish nothing at all and let containers reach each other over the Compose network by service name. A port you did not publish cannot be scanned.
Add a DROP rule to DOCKER-USER
DOCKER-USER is the chain Docker guarantees it will not overwrite, and it is
traversed before Docker’s own rules. Put an explicit deny there for traffic
arriving on the public interface.
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -i eth0 -p tcp -m multiport --dports 80,443 -j RETURN
-A DOCKER-USER -i eth0 -j DROP
COMMITReplace eth0 with your actual public interface — check with ip -o link show
— and reload:
sudo ufw reload
sudo iptables -L DOCKER-USER -n --line-numbersRe-run the nmap scan. Port 5432 should now be filtered.
The reason this chain exists is worth a sentence. Traffic destined for a
container is forwarded, not delivered locally, so it traverses the FORWARD
chain rather than INPUT — and UFW’s rules live in the chains it manages off
INPUT. Docker jumps from FORWARD into its own chains, and DOCKER-USER is
the one it promises never to rewrite. That makes it the only stable place to
put a rule that will survive a daemon restart.
Note also that this filters by ingress interface. Traffic from other containers
arrives on the bridge rather than eth0, so container-to-container
communication is unaffected by the DROP.
Harden the containers themselves
A firewall does not help if a compromised process inside a container can escape to the host. Four settings cover most of the gap:
services:
app:
image: ghcr.io/example/app:1.4.2
user: "10001:10001"
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"no-new-privileges blocks setuid escalation inside the container. cap_drop: ALL removes capabilities the process almost certainly does not need; add back
only what fails.
Two rules with no exceptions:
- Never mount
/var/run/docker.sockinto a container you do not fully control. Access to that socket is root on the host. - Never run
--privilegedon an internet-facing service.
Pin image tags to a version, not latest. An unattended docker compose pull
that swaps the running image for whatever was published an hour ago is a supply
chain risk you control for free.
Automatic updates and log discipline
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgradesConfirm the security pocket is enabled in
/etc/apt/apt.conf.d/50unattended-upgrades, and set
Unattended-Upgrade::Automatic-Reboot "false"; unless a reboot at 06:00 is
acceptable. Kernel updates need a reboot to take effect — check
/var/run/reboot-required from your monitoring.
Container logs are the most common cause of a full disk on a small VPS. Set defaults for every container at the daemon level:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
"live-restore": true,
"no-new-privileges": true
}sudo systemctl restart dockerlive-restore keeps containers running while the daemon restarts, which turns
a Docker upgrade into a non-event.
Verify from outside
Checking the host tells you what is listening. Only an external scan tells you what is reachable.
# from another machine
nmap -Pn -p- --min-rate 1000 203.0.113.42PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
443/tcp open httpsAnything else in that list is either something you meant to expose or a container that got past the firewall. Cross-check with:
docker ps --format 'table {{.Names}}\t{{.Ports}}'Look for 0.0.0.0: in the ports column. Every occurrence is a deliberate
decision or a mistake.
Common errors
ufw enable disconnected me. You did not allow OpenSSH first. Recover
through the provider console and run sudo ufw allow OpenSSH.
Permission denied (publickey) after disabling password auth. The key is
not in /home/deploy/.ssh/authorized_keys, or the permissions are wrong. SSH
silently ignores the file unless the directory is 700 and the file is 600,
both owned by the user.
docker: Error response from daemon: driver failed programming external connectivity. Something flushed iptables — often a ufw reload at the wrong
moment, or a manual iptables -F. Restart Docker so it rewrites its chains:
sudo systemctl restart docker.
The DOCKER-USER rules vanish after reboot. They were added with iptables -I on the command line rather than persisted. Put them in
/etc/ufw/after.rules as shown, or use iptables-persistent.
fail2ban starts but bans nothing. On installs without rsyslog there is no
/var/log/auth.log, so the default jail has nothing to read. Set
backend = systemd in /etc/fail2ban/jail.local and restart.
nmap shows a port as filtered that you expected to be closed.
filtered means packets are being dropped — that is the firewall working as
intended.