
How to Install Docker and Docker Compose on Debian and Ubuntu
Sooner or later, almost every self-hosting tutorial starts with the same line: “first, install Docker”. It sounds like a five-second job, and that is exactly why so many servers end up with an outdated Docker from the wrong package, a user who has to type sudo in front of every command and, worst of all, a database port wide open to the internet while the firewall swears it is closed. In this guide we do it properly, once, on a fresh Debian 13 or Ubuntu 24.04 VPS, and we explain every step so you know what changed on your server and why.
By the end you will have Docker Engine and the Docker Compose plugin installed from Docker’s official repository, you will run containers as a normal user, and you will understand the one Docker behavior that surprises almost everyone: ports published by Docker go straight past UFW. We will reproduce the problem live, then fix it in a way that is simple enough to become a habit.
What Docker and Docker Compose actually are
A container is an ordinary Linux process that runs on your server’s kernel, but with its own isolated view of the filesystem, the network and the process list. From inside, it looks like a tiny dedicated machine; from outside, it is just another process. Unlike a virtual machine, there is no second operating system booting inside it, which is why a container starts in about a second and uses only the memory its application needs.
A container always starts from an image: a read-only package that contains the application plus everything it needs to run (libraries, runtime, default configuration). Images are downloaded from a registry, most often Docker Hub. The same image runs the same way on your laptop and on your VPS, which is the main reason Docker became the standard way to ship self-hosted apps.
What we call “Docker” is actually a few pieces working together:
- Docker Engine (
dockerd), the background service that manages images, containers, networks and volumes. - containerd and runc, the lower-level runtime that actually starts and supervises container processes.
- The
dockerCLI, the command you type; it talks to the engine through a socket at/var/run/docker.sock. - Docker Compose, which lets you describe one or more containers in a YAML file (
compose.yaml) and start the whole set with one command.
Compose today is a plugin of the docker command, so you type docker compose with a space. The old standalone docker-compose with a dash was Compose v1, written in Python and no longer maintained. Many older tutorials still use it; if you see docker-compose up, just read it as docker compose up. Also, the version: line at the top of old Compose files is obsolete and can simply be deleted.
Why we install from Docker’s official repository
Both Debian and Ubuntu ship a package called docker.io in their own repositories, and Ubuntu also offers Docker as a snap (the Ubuntu Server installer even suggests it). They work, but they have downsides:
- The distribution package lags behind. It is maintained by the distribution, not by Docker, so new versions and fixes arrive later, and the Compose and Buildx plugins are packaged differently.
- The snap runs confined. That causes confusing permission problems with bind mounts outside your home directory and with some storage paths.
- Docker’s own convenience script (at get.docker.com) is fine for throwaway test machines, but it does everything as root in one go. On a server we want to see each change.
The official repository gives you the current stable release, security fixes straight from upstream, and updates through the normal apt upgrade you already run. Docker’s documentation lives at docs.docker.com
if you want the upstream reference.
What you need
- A VPS running a fresh Debian 13 (trixie) or Ubuntu 24.04 LTS (noble), 64-bit (amd64 or arm64).
- A regular user with
sudorights. We never need to log in as root. - SSH access to the server. If your SSH daemon listens on a port other than 22, write it down, it matters in step 6.
- At least 1 GB of RAM; 2 GB is more comfortable if you plan to run a database next to your app.
- About 15 minutes.
KVM or LXC: where does Docker run?
On a KVM VPS you get your own kernel, and Docker works out of the box. In an unprivileged LXC container you share the host’s kernel, and Docker needs the “nesting” (and usually “keyctl”) features enabled on the host. Without them, dockerd fails to start or containers die with permission errors about mounts or sysctl settings, and firewall rules inside the container may not behave as described below. If you are on an LXC plan, ask support to enable nesting, or pick a KVM VPS
for Docker-heavy workloads.
How big a VPS do you need?
Docker itself is light. The engine and containerd together use roughly 75 MB of RAM at idle, and each running container adds a small supervising process (the “shim”) of a few megabytes. What really decides the size of your VPS is the applications inside the containers.
In practice, a 1 GB VPS comfortably runs Docker plus two or three small services, while a database plus a Node.js or PHP application is happier with 2 GB. Disk matters too: images are typically between 10 MB and 1 GB each, so keep at least 10 GB free for Docker’s data in /var/lib/docker.
How to install Docker on Ubuntu and Debian: the plan
Steps 1 to 4 are a one-time setup; steps 5 to 7 are the habits that make Docker comfortable and safe to use every day. Run all commands as your sudo user over SSH.
Step 1: Update the system and remove conflicting packages
Start from an up-to-date system, so the packages we install next find current dependencies:
sudo apt-get update
sudo apt-get upgrade -y
update refreshes the list of available packages, upgrade installs the newer versions. If the upgrade included a new kernel, reboot before going further. Here Debian and Ubuntu differ. On Ubuntu, a pending reboot is signaled by a file:
# Ubuntu: prints "*** System restart required ***" if a reboot is pending
cat /var/run/reboot-required 2>/dev/null || echo "No reboot needed"
On Debian, that file is not created by default, so compare the running kernel with the newest installed one:
# Debian: running kernel vs installed kernels
uname -r
ls /boot/vmlinuz-*
If the newest vmlinuz version in /boot is higher than what uname -r shows, run sudo reboot and log back in after a minute.
Next, remove any Docker packages that could conflict with the official ones. On a fresh VPS there is usually nothing to remove, but checking costs nothing:
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
sudo apt-get remove -y "$pkg"
done
The loop tries to remove each package in turn. Messages like “Package ‘docker.io’ is not installed, so not removed” are normal, and on Debian you will also see “Unable to locate package docker-compose-v2”, because that package exists only on Ubuntu. Both are harmless. If you had an older Docker, its images and volumes in /var/lib/docker are kept.
On Ubuntu, also check that Docker was not installed as a snap during setup:
snap list 2>/dev/null | grep -i docker && sudo snap remove docker
No output means there was no snap to remove.
Step 2: Add Docker’s official repository
APT, the package manager on Debian and Ubuntu, installs software from repositories: servers that publish signed package lists. To use Docker’s repository we need two things: Docker’s GPG key, which lets APT verify that packages really come from Docker, and a sources file that tells APT where the repository is.
First, the tools and the folder for the key (same on both distributions):
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
Now the part that differs: Docker has separate repositories for Debian and Ubuntu, and you must use the one that matches your system. Mixing them up is the most common reason this installation fails.
On Debian 13
sudo curl -fsSL https://download.docker.com/linux/debian/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/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
On Ubuntu 24.04
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
Signed-By: /etc/apt/keyrings/docker.asc
EOF
The $(. /etc/os-release ...) part reads your release codename automatically, and tee prints the file it just wrote, with Suites: trixie on Debian 13 or Suites: noble on Ubuntu 24.04. This is the modern “deb822” format for APT sources; it does the same job as the one-line docker.list files you may find in older guides, but is easier to read.
Refresh the package lists:
sudo apt-get update
In the output you should see a line from download.docker.com (for example Get:5 https://download.docker.com/linux/debian trixie InRelease) and no errors. If you see NO_PUBKEY or a 404, jump to the troubleshooting section.
Step 3: Install Docker Engine and the Compose plugin
One command installs everything:
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
What each package is:
docker-ce: Docker Engine, thedockerdservice (“ce” stands for Community Edition).docker-ce-cli: thedockercommand.containerd.io: the container runtime the engine relies on.docker-buildx-plugin: the modern image builder used bydocker build.docker-compose-plugin: Compose v2, thedocker composecommand.
Confirm that APT took Docker from the official repository and not from the distribution:
apt-cache policy docker-ce | head -n 4
The “Candidate” version should be followed by a line pointing to download.docker.com. On both Debian and Ubuntu, the package starts the Docker service and enables it at boot automatically. Check it:
sudo systemctl status docker --no-pager
Look for Active: active (running). If you need a specific version (for example to match another server), list the available ones with apt-cache madison docker-ce and install with docker-ce=<VERSION>; for a new server, the latest stable release is the right choice.
Step 4: Verify the installation
Docker provides a tiny test image for exactly this moment:
sudo docker run --rm hello-world
Expected output (shortened):
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
...
Hello from Docker!
This message shows that your installation appears to be working correctly.
In those few seconds Docker contacted Docker Hub, downloaded the image, created a container, ran it, printed its output and, thanks to --rm, deleted the container when it exited. That is the complete life cycle of a container.
Now check both tools:
docker --version
docker compose version
You should see Docker version XX.Y.Z, build ... and Docker Compose version v2.Y.Z, with the exact numbers depending on when you install. Note there is no sudo on these two: printing versions does not need the engine. Running containers does, and we fix that next.
Step 5: Run Docker without sudo
The docker command talks to the engine through the socket /var/run/docker.sock, which belongs to the user root and the group docker. The installer already created that group; we just add our user to it:
sudo usermod -aG docker $USER
-aG means “append to group”, without removing you from your other groups (forgetting the -a is a classic way to lose your sudo rights). Group membership is read at login, so log out of SSH and log back in. Then check:
groups
docker run --rm hello-world
groups should list docker, and hello-world should now run without sudo. If you prefer not to reconnect, newgrp docker opens a subshell with the new group active, but only in that terminal.
Important: membership in the docker group is equivalent to root access. Anyone in it can start a container that mounts the host’s root filesystem and change anything on it. Add only trusted administrators to this group. If a user must use Docker without being root, look at Docker’s rootless mode, which runs the whole engine as an unprivileged user (with some limits on networking and on ports below 1024).
Step 6: Understand and fix the UFW bypass
This is the step most guides skip, and the one that causes real incidents.
What a port is and what “publishing” means
A port is a number between 1 and 65535 that lets a single IP address offer many network services: SSH usually listens on 22, web servers on 80 (HTTP) and 443 (HTTPS), PostgreSQL on 5432. Containers live on a private internal network (by default 172.17.0.0/16, behind a virtual bridge called docker0), invisible from outside. Publishing a port with -p 8080:80 tells Docker: take connections arriving on port 8080 of the host and pass them to port 80 inside the container. Written like that, without an IP address, the port is published on every interface, IPv4 and IPv6, which on a VPS means the public internet.
Why UFW does not see Docker’s ports
UFW (Uncomplicated Firewall) is a friendly front end for the Linux packet filter. When you allow or deny a port, UFW writes rules that are checked in the INPUT chain, the path for packets addressed to the server itself.
Docker publishes ports differently. It adds a DNAT rule (destination address translation) that rewrites incoming packets for port 8080 to the container’s private address, for example 172.17.0.2:80, before the INPUT chain is ever consulted. From the kernel’s point of view, the packet is no longer for the host; it must be forwarded to another network, so it travels through the FORWARD chain. Docker places its own chains at the top of FORWARD and accepts the traffic there. UFW’s rules never see it.
See it with your own eyes
Let’s reproduce it. UFW is preinstalled (but inactive) on Ubuntu; on Debian, install it first:
# Debian only, Ubuntu already has it
sudo apt-get install -y ufw
Enable a strict firewall that allows only SSH. Allow SSH before enabling UFW, or you will lock yourself out (replace 22 with your SSH port if you changed it):
sudo ufw allow 22/tcp
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable
sudo ufw status verbose
Answer y to the warning about SSH connections. The status should show Status: active, Default: deny (incoming) and a single rule for 22/tcp. Now start a test web server, published the “normal” way:
docker run -d --name ufw-test -p 8080:80 nginx:1.28-alpine
docker ps --format 'table {{.Names}}\t{{.Ports}}'
The PORTS column shows 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp: listening on all addresses. From your own computer (not the VPS), try:
curl -I http://YOUR_VPS_IP:8080
You get HTTP/1.1 200 OK, even though UFW denies everything except SSH and ufw status says nothing about 8080. To see the reason, look at the FORWARD chain on the VPS:
sudo iptables -S FORWARD | head -n 6
You will typically see Docker’s jumps (DOCKER-USER, DOCKER-FORWARD or similar, depending on the version) near the top, ahead of UFW’s ufw-before-forward chains. Debian 13 and Ubuntu 24.04 use the nftables-based iptables by default; the logic is exactly the same.
The fix: publish on 127.0.0.1
The simplest and most robust fix is to tell Docker which address to publish on. 127.0.0.1 is the loopback address, reachable only from the server itself:
docker rm -f ufw-test
docker run -d --name ufw-test -p 127.0.0.1:8080:80 nginx:1.28-alpine
curl -I http://127.0.0.1:8080
On the VPS, curl still answers 200 OK. From your computer, curl -I http://YOUR_VPS_IP:8080 now fails with “connection refused” or a timeout, and docker ps shows 127.0.0.1:8080->80/tcp. The service works, it is just no longer public. Clean up:
docker rm -f ufw-test
These are the rules we follow on every server we manage:
- Publish internal services on 127.0.0.1 only: admin panels, databases, apps that sit behind a proxy.
- Don’t publish at all when containers only talk to each other. Containers in the same Compose project share a network and reach each other by service name (for example
db:5432), no published port needed. - Let only a reverse proxy face the world. A reverse proxy (Caddy, Nginx or Traefik) listens on 80 and 443, handles HTTPS and forwards requests to
127.0.0.1:8080. If the proxy runs on the host, open 80 and 443 in UFW; if it runs in a container, those are the only ports you publish publicly. - Do not set
"iptables": falsein Docker’s configuration to “fix” this. It breaks container networking in subtle ways (no outgoing internet, no working port publishing). - Advanced: Docker reserves a chain called
DOCKER-USERfor your own filtering rules. They are evaluated after DNAT, so they match the container’s port, not the host’s: to block host port 8080 you need something likesudo iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL -j DROP, and you must make it persistent yourself. It works, but binding to 127.0.0.1 is much easier to get right.
Later, in the daemon.json section, we also add a safety net that makes 127.0.0.1 the default.
Step 7: Run your first Docker Compose project
Typing long docker run commands gets old quickly. Compose stores the same information in a file, so your setup is documented, repeatable and easy to back up. Let’s create a small website served by Nginx, published safely on localhost.
mkdir -p ~/apps/hello/html
cd ~/apps/hello
echo '<h1>Hello from Docker Compose</h1>' > html/index.html
nano compose.yaml
Paste this content, save with Ctrl+O, Enter, and exit with Ctrl+X:
services:
web:
image: nginx:1.28-alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
Line by line: services lists the containers of this project, and web is the name we chose for ours. image pins Nginx to the 1.28 branch on Alpine Linux. We avoid :latest because it can jump to a new major version on the next pull and break things without warning, while a pinned branch still receives patch releases (check Docker Hub for the current stable tag when you read this). restart: unless-stopped brings the container back after a crash or a reboot, unless you stopped it on purpose. ports publishes container port 80 on host port 8080, on 127.0.0.1 only, exactly as in step 6. volumes mounts our html folder into the container read-only (:ro), so we edit the site on the host and Nginx serves it. There is no version: line: current Compose does not need it.
Start the project in the background (-d means detached):
docker compose up -d
docker compose ps
curl http://127.0.0.1:8080
docker compose ps should show the web service as Up, and curl returns our <h1> line. To see the page in the browser on your computer without opening any port, use an SSH tunnel. Run this on your computer:
ssh -L 8080:127.0.0.1:8080 youruser@YOUR_VPS_IP
While that session is open, http://localhost:8080 in your browser shows the page, carried through the encrypted SSH connection. This is also a great way to reach admin panels that should never be public.
The Compose commands you will use every day, always run from the project folder:
docker compose logs -f # follow the logs, Ctrl+C to stop
docker compose pull # download newer images for the pinned tags
docker compose up -d # apply changes (recreates what changed)
docker compose restart web # restart one service
docker compose down # stop and remove the containers (volumes stay)
Sensible defaults in /etc/docker/daemon.json
Two settings are worth adding on every Docker host. First, log rotation: by default Docker keeps container logs in JSON files that grow forever, and a chatty container can fill your disk in a few weeks. Second, a default publishing address, so a forgotten -p 8080:80 lands on localhost instead of the internet.
sudo nano /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"ip": "127.0.0.1"
}
This keeps at most three log files of 10 MB per container, and makes 127.0.0.1 the address used when a port mapping has no explicit IP. Validate and apply:
sudo dockerd --validate --config-file=/etc/docker/daemon.json
sudo systemctl restart docker
The first command should print configuration OK. Restarting Docker briefly stops your containers; those with a restart policy come back by themselves. Two things to know: log settings apply only to containers created after the change (run docker compose up -d --force-recreate in each project), and with the ip default in place, a service you really want public must say so explicitly, for example "0.0.0.0:443:443". We like that: going public becomes a deliberate decision.
Troubleshooting and common mistakes
“permission denied while trying to connect to the Docker daemon socket”
Your user is not in the docker group yet, or your session started before you added it. Run groups; if docker is missing, repeat step 5, then log out and back in completely (closing one tab is not enough if your SSH client shares connections).
“Unable to locate package docker-ce” or “has no installation candidate”
APT does not see Docker’s repository. Check cat /etc/apt/sources.list.d/docker.sources: on Debian the URI must contain /linux/debian and the suite must be trixie; on Ubuntu, /linux/ubuntu and noble. Fix the file, run sudo apt-get update again and read the output for errors.
“NO_PUBKEY” or signature errors
The key file is missing, empty or unreadable. Check with ls -l /etc/apt/keyrings/docker.asc: it should be a few KB and readable by everyone (-rw-r--r--). Download it again with the curl and chmod lines from step 2.
“docker-compose: command not found”
You are using Compose v1 syntax. Type docker compose with a space, and don’t install the old Python docker-compose.
“port is already allocated” or “address already in use”
Something else already listens on that host port. Find it with sudo ss -tlnp | grep ':8080', then either stop it or choose another host port, for example 127.0.0.1:8081:80.
Docker does not start at all
Read the engine’s log: sudo journalctl -u docker -n 50 --no-pager. In an unprivileged LXC container, errors about overlay mounts, sysctl or keyctl mean nesting is not enabled on the host. A syntax error in daemon.json also stops the service, which is why we validate it first.
Locked out after enabling UFW
You enabled UFW without allowing your SSH port. Log in through the VPS console in your control panel and run sudo ufw allow 22/tcp (or your custom port).
The disk keeps filling up
Old images and stopped containers accumulate. docker system df shows what uses space, and docker image prune removes unused images. Be careful with docker system prune --volumes: it also deletes volumes that are not attached to a container, and those may contain data you care about.
Security notes
- The
dockergroup is root. Treat membership exactly like sudo rights. - Never expose the Docker API. Don’t make
dockerdlisten on TCP (ports 2375/2376), and be careful with containers that mount/var/run/docker.sock: whoever controls that socket controls the server. - Publish on 127.0.0.1 by default, and check what is really public with
docker psandsudo ss -tlnp. Once Docker is installed,ufw statusis not the full picture. - Pin image tags and update on purpose. Use specific branches (
nginx:1.28-alpine,postgres:17), prefer official images or the project’s own images, and rundocker compose pull && docker compose up -dregularly. - Keep Docker itself updated. Docker packages update with
sudo apt-get update && sudo apt-get upgrade. If you useunattended-upgrades, remember it applies only Debian or Ubuntu security updates by default, not packages from Docker’s repository, so keep Docker in your regular manual update routine. - Rotate logs and watch disk space, as shown above. A full disk takes down every container at once.
- Back up the right things: your Compose folders and the named volumes (under
/var/lib/docker/volumes). Containers themselves are disposable.
So, is Docker ready on your server?
You now have Docker Engine and Compose v2 from the official repository, a user that runs containers without sudo, and the most important lesson about Docker networking: a published port ignores UFW unless you bind it to 127.0.0.1 or put a reverse proxy in front. With the daemon.json defaults, even a forgotten port mapping stays private. From here, every self-hosted app is just a compose.yaml and a docker compose up -d away.
If you need a place to run it, a KVM VPS from our Romanian data center is ready in minutes with Debian 13 or Ubuntu 24.04. And if you would rather have someone else install, secure and update Docker and your containers, our server administration team does exactly that, every day.
Frequently asked questions
Is Docker Compose included when I install Docker on Ubuntu?
It is a separate package. From Docker’s official repository it is docker-compose-plugin, which we installed in step 3 together with the engine. You use it as docker compose, with a space.
Can I run Docker on a 1 GB RAM VPS?
Yes. Docker itself needs under 100 MB of RAM; the real question is what you run inside it. A few lightweight services fit fine, while a database plus a heavier application deserves 2 GB (or at least some swap).
How do I update Docker later?
Run sudo apt-get update && sudo apt-get upgrade. The Docker service restarts during the upgrade and containers with a restart policy come back automatically. Images are updated separately, per project, with docker compose pull && docker compose up -d.
How do I uninstall Docker completely?
Remove the packages, then the data directories. The second command deletes all images, containers and volumes, so back up first:
sudo apt-get purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
sudo rm -rf /var/lib/docker /var/lib/containerd
sudo rm /etc/apt/sources.list.d/docker.sources /etc/apt/keyrings/docker.asc
Does this work on Debian 12, Ubuntu 22.04 or ARM servers?
Yes. The repository commands read the codename automatically, so they work on Debian 12 and Ubuntu 22.04 as well. Docker publishes arm64 packages too, and most official images are available for arm64.