Security Deployment guide Intermediate

How to Secure a VPS That Runs Docker Containers

SSH hardening, a default-deny firewall, and the fix for the problem that catches almost everyone — Docker publishing container ports straight past UFW into the public internet.

A firewall rule list beside a Docker container port mapping diagram

Tested with

OperatingSystem
Ubuntu 24.04 LTS
Docker
27.x
Ufw
0.36.2
Openssh
9.6p1
Fail2ban
1.0.2

Before you start

  • A fresh Ubuntu 24.04 VPS you can reach over SSH
  • An SSH key pair on your local machine
  • Console or rescue access from your provider, in case you lock yourself out
On this page

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

bash
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

From your laptop, confirm the key works before touching sshd:

bash
ssh deploy@203.0.113.42 'id'
Expected output
bash
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.

/etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2

Validate the config before restarting. sshd -t catches syntax errors that would otherwise stop the daemon from coming back:

bash
sudo sshd -t && sudo systemctl restart ssh

Now test from a new terminal, keeping the old session open:

bash
ssh deploy@203.0.113.42
ssh root@203.0.113.42     # must be refused

Set a default-deny firewall

bash
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 verbose

ufw 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:

bash
docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=x postgres:17-alpine
bash
# from a different machine
nmap -Pn -p 5432 203.0.113.42
Expected output — the port is open despite UFW
bash
PORT     STATE SERVICE
5432/tcp open  postgresql

There 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:

compose.yaml
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.

/etc/ufw/after.rules — append at the end, before COMMIT
*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
COMMIT

Replace eth0 with your actual public interface — check with ip -o link show — and reload:

bash
sudo ufw reload
sudo iptables -L DOCKER-USER -n --line-numbers

Re-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:

compose.yaml
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.sock into a container you do not fully control. Access to that socket is root on the host.
  • Never run --privileged on 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

bash
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Confirm 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:

/etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" },
  "live-restore": true,
  "no-new-privileges": true
}
bash
sudo systemctl restart docker

live-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.

bash
# from another machine
nmap -Pn -p- --min-rate 1000 203.0.113.42
Expected output
bash
PORT    STATE SERVICE
22/tcp  open  ssh
80/tcp  open  http
443/tcp open  https

Anything else in that list is either something you meant to expose or a container that got past the firewall. Cross-check with:

bash
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.

Frequently asked questions

Why does UFW not block my Docker container ports?

Docker inserts its own DNAT and FORWARD rules into iptables when you publish a port. Those rules sit in the `DOCKER` and `DOCKER-USER` chains, which are traversed before the chains UFW manages, so a published port is reachable regardless of what `ufw status` says.

Is it safe to disable Docker's iptables management in daemon.json?

No, not as a firewall fix. That stops Docker managing NAT at all, which breaks container-to-internet traffic and port publishing until you write every rule yourself. Bind published ports to localhost instead.

Should I change the SSH port to something other than 22?

It removes most automated scan noise from your logs, and that is the whole benefit — it is not a security control. Key-only authentication is. On Ubuntu 24.04 changing the port also means editing the socket unit, not just sshd_config.

Do I need fail2ban if password authentication is disabled?

It stops adding much for SSH once passwords are off. It is still useful for anything else you expose that does accept passwords, and it keeps the auth log readable.

How do I check what is actually exposed from outside?

Scan from a different machine — `nmap -Pn -p- your.server.ip`. Checking `ss -tlnp` on the host tells you what is listening, which is not the same question as what the internet can reach.

Sources

  1. Docker and iptables Docker Primary source Accessed 8 Jul 2026
  2. Docker daemon configuration file Docker Primary source Accessed 8 Jul 2026
  3. Ubuntu Server security documentation Canonical Primary source Accessed 8 Jul 2026
  4. sshd_config manual page OpenBSD Primary source Accessed 8 Jul 2026
  5. Fail2Ban documentation Fail2Ban project Primary source Accessed 8 Jul 2026
4 min read

Part of a learning path

  • n8n intermediate

    How to Deploy n8n on an Ubuntu VPS with Docker

    A production n8n install on Ubuntu 24.04 using Docker Compose, PostgreSQL and Caddy for automatic TLS — with the encryption key, webhook URL and backup steps most quickstarts leave out.

    5 min
  • Cloudflare beginner

    How to Point a Cloudflare Domain at Your VPS

    Create the A record, pick the right proxy status, set the SSL mode to Full (strict) and lock the origin so only Cloudflare can reach it — plus what errors 521, 522, 525 and 526 actually mean.

    4 min