The cleanest way to install Docker on a VPS is from Docker's own apt repository, not the docker.io package or a one-line convenience script. You get current Docker Engine releases, the Compose plugin, and updates through normal apt upgrade. This guide walks through the install on Ubuntu and Debian, runs a first Compose stack, and then covers the part most tutorials skip: published container ports bypass ufw by default, which can expose services you thought were firewalled.
Start from a server you have already hardened. If yours is brand new, do the first-hour security checklist first, and see the VPS locations guide if you are still choosing a region.
What you need to install Docker on a VPS
- A Linux VPS running a current Ubuntu LTS or Debian stable release. Check the OS list for your location in the Cloudj console.
- A user with
sudorights and SSH key login. See how to create SSH keys if needed. - Enough memory for your containers. The engine itself is light; your applications decide the size. A small test stack runs on the smallest plans, and you can resize CPU and RAM later as the workload grows.
Step 1: remove conflicting packages
Distribution packages such as docker.io or podman-docker conflict with Docker's official packages. Remove any that are present; apt reports the ones that are not installed, which is fine:
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove -y $pkg; done
Existing images and volumes in /var/lib/docker/ are not deleted by this.
Step 2: add Docker's official repository
The commands below work on both Ubuntu and Debian, because they read the distribution name and release codename from /etc/os-release. They do not work on derivatives such as Linux Mint; use the matching parent distribution's instructions there.
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
sudo curl -fsSL https://download.docker.com/linux/$ID/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$ID $VERSION_CODENAME stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
The repository is signed, and the signed-by option limits that key to this one repository. The official Docker Engine install guide for Ubuntu and its Debian counterpart list supported releases if apt-get update reports that your codename is not found.
Step 3: install Docker Engine and Compose
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
On Ubuntu and Debian the service starts and is enabled at boot automatically. Verify the install:
sudo systemctl status docker --no-pager
sudo docker run --rm hello-world
docker compose version
Note that Compose is now a plugin invoked as docker compose with a space. The old standalone docker-compose binary is not needed.
Step 4: decide how you run docker commands
By default only root can talk to the Docker daemon. You can add your user to the docker group:
sudo usermod -aG docker $USER
Log out and back in for the group to apply. Be aware of what this means: membership of the docker group is effectively root access, because a user can start a container that mounts the host filesystem. On a single-admin server that is usually acceptable. On a shared server, keep using sudo docker or look at Docker's rootless mode.
Step 5: set sensible daemon defaults
Two settings save trouble later. Container logs grow without limit by default and can fill a small disk, and published ports bind to every interface unless you say otherwise. Create /etc/docker/daemon.json:
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" },
"ip": "127.0.0.1"
}
EOF
sudo systemctl restart docker
The ip setting makes -p 8080:80 bind to 127.0.0.1 unless you explicitly write a different address. Log limits apply to containers created after the change; recreate existing ones to pick them up.
Step 6: run a first stack with Docker Compose
Create a project folder and a compose.yaml:
mkdir -p ~/apps/hello && cd ~/apps/hello
cat > compose.yaml <<'EOF'
services:
web:
image: nginx:stable
ports:
- "127.0.0.1:8080:80"
restart: unless-stopped
EOF
docker compose up -d
docker compose ps
curl -I http://127.0.0.1:8080
You should get an HTTP/1.1 200 OK header from nginx. Useful commands from here: docker compose logs -f to follow logs, docker compose pull && docker compose up -d to update images, and docker compose down to stop and remove the stack.
The Docker and ufw firewall trap
This is the most important section. ufw filters traffic in the host's INPUT chain. When you publish a port, Docker adds its own iptables rules that forward traffic to the container before ufw's rules see it. The result: you run sudo ufw deny 5432, publish -p 5432:5432 for a database, and the database is still reachable from the internet.
Ways to stay safe, from simplest to most involved:
- Publish on localhost only. Write ports as
127.0.0.1:8080:80, or rely on the"ip": "127.0.0.1"default above. Then put a reverse proxy on ports 80 and 443 in front. Our guide to an nginx reverse proxy with SSL shows the setup. - Do not publish internal services at all. Containers on the same Compose network reach each other by service name, so a database usually needs no
ports:entry. - Filter in the DOCKER-USER chain. Docker leaves this iptables chain for your own rules, evaluated before its forwarding rules. It works but needs care, and rules must be saved to survive reboots.
Verify from outside, not from the server itself. From your own computer, try connecting to a port that should be closed:
nc -vz 203.0.113.10 5432
A timeout or refusal is what you want. On the server, sudo ss -tlnp lists every listening socket and the address it is bound to. See the ufw firewall guide for the host rules themselves.
Keeping Docker updated
Docker packages update through apt like everything else:
sudo apt-get update && sudo apt-get upgrade
Unattended upgrades on Ubuntu and Debian install security updates from the distribution by default, not from Docker's repository, so schedule manual updates. Images are separate: pull new versions and recreate containers regularly, and remove unused ones with docker image prune.
FAQ
Should I use the docker.io package from Ubuntu instead?
It works, but it often lags behind upstream and packages Compose differently. The official repository gives you current releases and the docker compose plugin that most documentation assumes.
Why is my container reachable even though ufw blocks the port?
Docker's iptables rules for published ports are evaluated before ufw's. Bind the port to 127.0.0.1, remove the ports: entry, or filter in the DOCKER-USER chain.
How much RAM does Docker need on a VPS?
The engine uses little; your containers decide. Watch memory in the console metrics and resize when you are consistently near the limit.
Can I install Docker on a Windows VPS?
This guide covers Linux only. For containers on a server, a Linux image is the simpler and more common choice.
Deploy and start building
Deploy an Ubuntu or Debian server from the Cloudj console, follow these steps, and your first Compose stack can be running within minutes. Compare locations on the cloud servers page and see live prices on the pricing page.