Updated and tested 20 October 2026
One caveat before the install command, because it shapes everything after it: when Docker publishes a container port, it writes its own rules into the host firewall, and traffic to that port never passes through the chains ufw manages. A -p 5432:5432 on a Postgres container puts the database on the public internet with ufw reporting "deny incoming" the whole time. Every other section of this article is ordinary sysadmin work. That one is the reason people get breached on their first Docker VPS, so it gets the longest section, and I'd read it before running a single container that publishes a port.
Commands are for Ubuntu 24.04 LTS on a KVM VPS, with Docker Engine 29 from Docker's own repository.
Why Docker needs a KVM VPS
Containers are processes on the host kernel wearing namespaces and cgroups, which means the kernel has to be yours. On a KVM VPS it is: the guest boots its own kernel, loads overlayfs, br_netfilter and whatever else Docker asks for, and the daemon behaves exactly as it would on a laptop. On an OpenVZ or LXC container plan the kernel belongs to the host, nested containers work only if the provider enabled them, and the failure modes are odd errors about cgroups or overlay mounts that no amount of reinstalling fixes. The explainer on KVM VPS has the one command to check what you're on. If systemd-detect-virt says anything other than kvm (or none), ask the provider before assuming Docker will run.
Sizing a Docker VPS
Sizing is per container, and the classes are more predictable than people expect. A reverse proxy (Caddy, Traefik, Nginx) idles at 20 to 60 MB. A Go or Rust service, similar. A Node or Python application sits at 100 to 300 MB and grows with traffic. Postgres wants 256 MB as a floor and as much as you'll give it for cache; MySQL and MariaDB the same. Anything on the JVM (Keycloak, Jenkins, Elasticsearch) starts at 512 MB and 1 GB is a more honest floor. Add those up, add 500 MB for the host OS and the daemon and add 25% headroom, because containers don't share memory the way separate processes on a bare host do and a spike in one lands on the whole box.
That arithmetic is why a 1 GB VPS is a bad Docker host. A proxy, a small app and a database fit on paper, and then the app's first traffic spike pushes the box into swap, the database's cache gets evicted and every request waits on disk. 2 GB runs a handful of small services comfortably; 4 GB is where I'd start for anything with a JVM or more than one database. On the CPU side Docker adds nothing, so vCPU count is whatever the applications need, and two vCPUs is plenty for a box of light services.
Disk grows in four places. Images, where each pull of a new tag keeps the old layers until you prune. Container logs, which by default are unbounded (more on that below). Build cache, if you build images on the box. And volumes, which are your data and grow as data does. A 40 GB disk holds a lot of containers, and I've still seen it fill inside a month on a box with a chatty application logging JSON at debug level, so the rotation section is not optional.
Install Docker Engine from Docker's apt repository
Ubuntu's own docker.io package lags upstream and conflicts with Docker's. Remove any of those first:
sudo apt remove $(dpkg --get-selections docker.io docker-compose \
docker-compose-v2 docker-doc docker-buildx podman-docker containerd \
runc | cut -f1)
On a fresh image it prints a "no packages found matching" line for each name and removes nothing, which is the result you want. Then add Docker's repository. This is the current form from the Docker Engine install guide for Ubuntu, which uses the deb822 .sources format and lists 22.04, 24.04 and 26.04 as supported:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
The Suites: line resolves to noble on 24.04. Confirm the daemon is up with sudo docker run hello-world, and check docker compose version; Compose ships as the docker-compose-plugin package and is invoked as docker compose with a space. The old Python docker-compose with a hyphen was retired in 2023, and any tutorial still using it is older than it looks. The plugin is on its fifth major version now, and the file format has stayed compatible, so old compose.yaml files keep working.
If you'd rather skip the repository dance, the provider's knowledge base usually has the same steps for its own images; the Docker Engine install article is the LumaDock version, and a one-click template such as the Docker VPS boots with the daemon already installed and the repository configured, which saves ten minutes per box and nothing else.
The docker group is root
The post-install guide offers sudo usermod -aG docker $USER so you can drop the sudo. Skip it and every docker command needs sudo in front, docker compose up -d included, or the client stops at "permission denied while trying to connect to the docker API at unix:///var/run/docker.sock". Do it knowing what it means: the Docker daemon runs as root, the socket is owned by root and any member of the docker group can run docker run -v /:/host and edit /etc/shadow from inside a container. Docker's own documentation says the group "grants root-level privileges to the user", and that's the whole story. On a single-admin VPS I add my user to the group because I'm already root on that machine. On a box with a deploy user for CI, that deploy user is now root, and if that's unacceptable the answer is rootless mode or a socket proxy.
Docker and ufw: the published port bypass
Here is what happens. When the daemon starts it enables IP forwarding, sets the default policy of the FORWARD chain to DROP and creates its own chains in the filter table: DOCKER-USER, DOCKER-FORWARD, DOCKER and a few more, plus a DOCKER chain in the nat table for the port mappings. A packet arriving for a published port is DNAT'd in PREROUTING to the container's address and then handled in FORWARD, where Docker's chains accept it. It never enters INPUT, and INPUT is where ufw's rules live. Docker's packet filtering and firewalls page states it flatly: the two are incompatible, and published ports ignore your ufw configuration. This applies to bridge networks, which is every default docker run -p and every Compose service; host networking and macvlan create no Docker rules at all.
You can check it in thirty seconds. Publish something, then look at the FORWARD chain:
sudo docker run -d --name test -p 8080:80 nginx
sudo iptables -L FORWARD -n --line-numbers | head
sudo iptables -t nat -L DOCKER -n
Docker's chains sit at the top of FORWARD, so container traffic is decided before ufw sees it.
The nat DOCKER chain shows the DNAT for 8080 and the FORWARD chain jumps into DOCKER-USER and DOCKER-FORWARD before anything ufw added. From another machine, curl http://YOUR_SERVER_IP:8080 answers even with ufw default deny incoming active. Remove the test container afterwards with docker rm -f test.
ufw says only SSH is allowed in...
...yet the published port answers from another server on the internet.
Fix 1: Bind published ports to 127.0.0.1
The simplest fix, and the one I use on every single-host setup: publish to the loopback address and let a reverse proxy on the host, or in another container on the same Docker network, be the only thing with a public port.
docker run -d -p 127.0.0.1:5432:5432 postgres:16
In Compose the same thing is ports: ["127.0.0.1:5432:5432"]. A port bound to loopback is reachable from the host and from nothing else: re-run the test container as -p 127.0.0.1:8080:80 nginx and the curl from another machine now times out, because ufw drops the packet without a reply. Services that only talk to each other don't need ports: at all, since containers on a shared user-defined network reach each other by service name on the container port. Docker's documentation notes that before Engine 28.0 hosts on the same L2 segment could reach loopback-published ports, which is one more reason to be on 29.
Bound to 127.0.0.1, the port no longer answers from outside; the request times out.
To make loopback the default for anything published without an address, set it in /etc/docker/daemon.json. For the default bridge:
{
"ip": "127.0.0.1"
}
With "ip": "127.0.0.1" in daemon.json, a plain -p 8080:80 binds to loopback.
For user-defined bridge networks (which is what Compose creates) the equivalent is the default-network-opts key with com.docker.network.bridge.host_binding_ipv4 set to 127.0.0.1, and the port publishing docs show the exact JSON. Restart the daemon after either change; existing containers keep their old bindings until recreated.
Fix 2: Filter in the DOCKER-USER chain
When a port has to be public but only to some addresses (a database reachable from one application server, an admin panel reachable from the office), the supported place for your rules is the DOCKER-USER chain, which Docker's iptables documentation describes as a placeholder for user rules processed before Docker's own. Rules appended to FORWARD run after Docker's accept and never see the packet; rules in DOCKER-USER run first. The pattern from the docs, with the external interface and the allowed source filled in:
sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -j DROP
sudo iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT
The working order: established flows accepted first, then everything else from outside the allowed range dropped.
Read the order carefully: -I inserts at the top, so the second command ends up above the first, and the chain reads "accept established flows, then drop anything else arriving on eth0 that isn't from 203.0.113.0/24". The established rule has to be on top because containers make outbound requests (DNS, package mirrors, webhooks), and the replies arrive on eth0 from addresses outside the allowed range. With the DROP above it they never reach the ACCEPT, and a container can't even resolve a hostname: wget inside one fails with "bad address".
Replace eth0 with your interface from ip -br link; on many Ubuntu VMs it's ens3 or enp1s0. Two more details from the same page: by the time a packet reaches DOCKER-USER it has already been DNAT'd, so matching on the original public port means using the conntrack extension with --ctorigdstport, and iptables rules don't survive a reboot on their own, so install iptables-persistent and run netfilter-persistent save once the rules are right. Also: ufw's own after.rules file is a legitimate place to put DOCKER-USER rules so ufw reloads them, and several popular "ufw-docker" scripts do exactly that. I keep them in a plain file and load it from a systemd unit, because I want to see the rules in one place, but either works.
Fix 3: iptables false, and what breaks
The daemon option "iptables": false in daemon.json stops Docker from creating most of its rules. It sounds like the clean answer, and for most people it's a trap with a config key. Docker's own page says it's "likely to break container networking": with no masquerade rule, containers on bridge networks can't reach the internet, and with no filter rules their ports are open to any host on the local network. You then own every NAT and forwarding rule by hand, for every network Docker creates. I've done it once, on a host that already had a strict nftables ruleset managed elsewhere, and it was a week of surprises. Bind to loopback, filter in DOCKER-USER and leave the option alone unless you're that host.
One line for completeness: Engine 29 added an experimental nftables backend selected with "firewall-backend": "nftables". The chain names and the DOCKER-USER pattern differ there, so the rules above are for the default iptables backend.








