Skip to content
Bytearium

No ads or affiliate links active

IT security

Docker on a VPS: firewall and hardening

Docker on a VPS bypasses ufw for published ports. Secure it with a provider firewall, loopback binding and DOCKER-USER rules, backed by Docker docs.

You switched on ufw, denied everything and slept well. Meanwhile your database answered a stranger on port 5432. Docker is polite about many things, but not about your firewall.

The short answer

Does ufw protect Docker containers on a VPS? No. Ports you publish with Docker, through -p 8080:80 or a ports: line in Compose, bypass ufw rules, because Docker diverts that traffic in the nat table before it reaches the chains ufw uses [1]. Protect the server in layers instead: a firewall at your VPS provider, published ports bound to 127.0.0.1 with a reverse proxy in front, and DOCKER-USER rules for whatever must stay public but restricted. Take a snapshot before you change anything.

How it works

  1. What Docker does to your firewall. On Linux, Docker creates its own firewall rules for bridge networks: isolation, port publishing and NAT [1]. By default the daemon blocks access to ports that were not published. Ports of containers on a bridge network are reachable from the host and from other containers in the same network, not from outside [2]. Publishing with -p 8080:80 maps port 8080 on any address of the host to port 80 in the container [2]. Docker's own documentation says: "Publishing container ports is insecure by default." The port becomes available to the outside world [2].
  2. Why ufw does not see it. ufw is a front end for the chains INPUT and OUTPUT. Docker routes container traffic in the nat table, so packets are diverted before they reach those chains. Docker's documentation says the packets are routed "before the firewall rules can be applied, effectively ignoring your firewall configuration" [1]. A Docker maintainer gave the background: ufw only supports rules for the current host, but packets addressed to a container are routed, so they never pass through the chain where ufw rules live [7]. This is documented behaviour, not a mistake in your setup.
  3. Compose does the same. A line like - "8080:80" without a host IP binds to all interfaces, and the Compose reference warns that this can expose the container directly to the internet when the host has a public IP, bypassing host firewall rules [5]. Docker also publishes on IPv6 by default: without a host address, a port is published on 0.0.0.0 and [::] [2].
  4. Layer 1: the provider firewall. Firewalls at the VPS provider filter traffic before it reaches your instance. Contabo describes its firewall as filtering at network level, with all inbound traffic dropped from the moment a firewall is activated [9]. Hostinger says packet filtering happens before incoming traffic reaches the server, an empty ruleset blocks everything, and changes apply within two minutes or less [10]. Hetzner's Cloud Firewalls are stateful and drop new inbound traffic unless a rule allows it [11]. Because these firewalls sit outside your virtual machine, Docker's rules cannot bypass them (our assessment). Allow only what you serve: SSH (ideally from your own IP address), port 80 and port 443. Know the limits: Contabo's firewall covers inbound traffic only [9], and Hostinger says its firewall is not a replacement for DDoS protection [10].
  5. Layer 2: bind published ports to 127.0.0.1. Put the host address into every port mapping: -p 127.0.0.1:8080:80 on the command line, or "127.0.0.1:8080:80" in Compose. Then only the Docker host can reach the port [2][5]. A reverse proxy on the host forwards public traffic to it. One version note: in releases older than 28.0.0, hosts in the same layer 2 segment can reach ports published to localhost, so check docker version [2]. Containers that only talk to each other, a database for example, get no ports: line at all [2]. As a safety net you can change the default binding in the daemon configuration, shown in the example below [2]. An explicit host IP in a mapping still wins.
  6. Layer 3: the DOCKER-USER chain. Docker creates a chain called DOCKER-USER that is processed before Docker's own rules. To filter packets for published ports, add rules there. Rules appended to the FORWARD chain are processed after Docker's rules [3]. Two catches: packets have already been through address translation when they arrive, so ordinary flags match the container's internal address and port, and matching the original port needs the conntrack extension, which the documentation says may cost performance [3][8]. A second catch: rules you add by hand are not persistent, iptables rules are flushed at reboot [17]. Docker's documentation does not cover persistence. Saving the whole ruleset would also save Docker's own rules, so keep your DOCKER-USER lines in a small script that runs after Docker has started (our assessment, not tested at Bytearium).
  7. What to leave alone. Do not set "iptables": false in the daemon configuration. It is likely to break container networking, and the ports of your containers become reachable to hosts on the local network [1]. Do not edit Docker's own rules [1]. The nftables backend introduced with Docker 29.0.0 is marked experimental, it has no DOCKER-USER chain, and your old DOCKER-USER rules are ignored unless a jump to the chain still exists [4]. The Ubuntu installation page still says that Docker is compatible with iptables-nft and iptables-legacy only, and that rules created with nft are not supported (as of 25 Oct 2026) [6].
  8. Host networking is different. Docker creates no firewall rules for host, ipvlan and macvlan networks [1]. Our assessment: a container on the host network behaves like a process on the host, so ufw applies to it. Test it instead of trusting us.

A worked example

We have not recorded a Bytearium VPS setup (as of 25 Oct 2026), so this is a generic walkthrough for your own server, not a report from ours. Start with a snapshot in your provider's panel. A snapshot is a checkpoint, not a backup. Contabo deletes snapshots after 30 days and limits their number by plan [12]. Hostinger keeps one snapshot, which expires after 1 day and disappears when you reinstall the operating system [13]. Then open the provider firewall: allow TCP 22 from your own IP address, TCP 80 and TCP 443, nothing else. Keep your current SSH session open, and open a second one to prove you can still log in. If a rule locks you out, Hostinger offers a firewall reset in the dashboard [21].

Next, ufw for the services of the host itself. Enabling ufw closes all ports including SSH, so allow SSH first [14][15]:

sudo ufw allow 22/tcp
sudo ufw enable
sudo ufw status verbose

Now look at what Docker has already published. Listening is not the same as reachable, because a port can show LISTEN although a firewall blocks it [15]:

docker ps
sudo ss -tlnp

Entries like 0.0.0.0:5432 or [::]:5432 are the ones to fix. A safe Compose file publishes only the web service, only on the loopback address, and gives the database no ports. The image tags below were listed on Docker Hub on 25 Oct 2026 [19][20]; the volumes are left out to keep the example short, and DB_PASSWORD comes from an .env file next to the Compose file that stays out of Git:

services:
  web:
    image: nginx:1.30.6
    ports:
      - "127.0.0.1:8080:80"
  db:
    image: postgres:18.6
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}

Your reverse proxy on the host listens on 80 and 443 and forwards to 127.0.0.1:8080. The optional safety net goes into /etc/docker/daemon.json; restart the daemon afterwards. It applies to user-defined bridge networks, which Compose creates, and it does not affect Swarm services [2]:

{
  "default-network-opts": {
    "bridge": {
      "com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
    }
  }
}

If a published port must stay public but only one address may use it, add the rules the Docker documentation shows for restricting external connections. Insert the DROP rule first and the established-connections rule second, because the second one then sits on top of the chain, in front of the DROP [3]. Replace eth0 with your public interface and the address with yours. Mind the effect: only that address can reach any published container port, so use this only on a server that serves nothing public through Docker:

sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.10 -j DROP
sudo iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT

Docker's rules also cover IPv6, but we did not verify here how the DOCKER-USER chain behaves for ip6tables on your Docker version. That is one more reason to prefer loopback binding and the provider firewall, which filters IPv4 and IPv6 [10]. Finally, test from outside, from another machine or a phone hotspot: nc -zv <VPS-address> 8080 should fail with a timeout or a refusal [15]. The Nmap documentation explains the difference: closed means the packet arrived and nothing listens, filtered means a firewall dropped it [18].

Last, the SSH basics. Put your public key on the server and test the key login in a second session first. Then set these options in /etc/ssh/sshd_config and reload the SSH service as your distribution describes [16]:

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no

The manual page advises disabling either PasswordAuthentication or KbdInteractiveAuthentication when PAM is in use. Disabling both covers it [16]. Pin your image tags as above and plan regular updates, including Docker itself, since the localhost fix arrived only in 28.0.0 [2]. A snapshot does not replace a backup, so keep the 3-2-1 rule in mind.

Checklist

  • Take a snapshot in the provider panel before you change any firewall setting.
  • Create a provider firewall that allows only SSH, port 80 and port 443, and keep a second SSH session open while you test.
  • Allow SSH in ufw before you enable it, and read the result of sudo ufw status verbose.
  • List what is published with docker ps and sudo ss -tlnp, and fix every 0.0.0.0 or [::] entry you did not intend.
  • Bind every published port to 127.0.0.1 unless it has to face the internet.
  • Remove the ports: line from containers that only talk to other containers.
  • Put a reverse proxy in front of web services, and let it be the only thing on ports 80 and 443.
  • Add DOCKER-USER rules only for ports that must stay public but restricted, and keep them in a script that runs after Docker starts.
  • Test every change from outside your network, and check docker version for 28.0.0 or newer.
  • Pin image tags, and disable root and password logins for SSH once key login works.

Primary sources

These sources are the basis of this page. That does not mean every claim was checked individually; whether a review record exists is shown above under “Status”. The interpretation and checklist are our own work.

There is no documented review record for this version yet. The text relies on the sources listed.

How this page was made: Bytearium creates these pages with the help of AI and is responsible for publishing them. AI can be wrong, so we show our sources and review status openly.

  1. DockerPacket filtering and firewalls
  2. DockerPort publishing and mapping
  3. DockerDocker with iptables
  4. DockerDocker with nftables
  5. DockerDefine services in Docker Compose, ports
  6. DockerInstall Docker Engine on Ubuntu
  7. Docker for Linux projectDocker bypasses ufw firewall rules
  8. Moby projectSummary of the issues about custom iptables rules
  9. ContaboFirewall
  10. HostingerHow to use a managed VPS firewall at Hostinger
  11. HetznerFirewalls FAQ
  12. ContaboHow do I create a snapshot of my server?
  13. HostingerHow to back up or restore a Hostinger VPS
  14. UbuntuFirewall
  15. MicrosoftTroubleshoot connectivity issues by using Uncomplicated Firewall (UFW) in Azure Ubuntu VMs
  16. Linux man-pagessshd_config(5)
  17. Debian Wikiiptables
  18. Nmap projectPort Scanning Basics
  19. Docker Hubnginx, Official Image
  20. Docker Hubpostgres, Official Image
  21. HostingerHow to set up a firewall at Hostinger VPS

This page has no ads and no affiliate links.