Docker Install on Ubuntu and Debian: Compose Setup Guide
To install Docker on Ubuntu and Debian, add Docker's official apt repository, install the docker-ce packages plus the Compose plugin, and verify with hello-world. The whole process takes about five minutes on a fresh server, and it gives you current releases instead of the older docker.io package from the distro archive.
This guide covers the repository setup, your first container, a multi-container web app with Docker Compose, and the security settings that are easy to miss. It assumes a fresh Ubuntu (22.04/24.04) or Debian (11/12) server with sudo access. If the server is brand new, do the basics from our Ubuntu initial setup and hardening guide first: non-root user, SSH keys, firewall.
Step 1: Remove conflicting packages
Distro-provided packages conflict with Docker's official ones. Remove them if present (it is fine if apt says some are not installed):
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
sudo apt-get remove -y $pkg
done
This does not delete existing images or volumes in /var/lib/docker/.
Step 2: Add the official Docker repository
Ubuntu
sudo apt-get update
sudo apt-get install -y 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
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
Debian
Same steps, but the URL and key path use debian:
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
Step 3: Install Docker Engine and the Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
Docker Compose is now a plugin, so the command is docker compose (with a space), not the legacy docker-compose binary. Check both components:
sudo systemctl status docker --no-pager
docker compose version
The service is enabled at boot by default on Debian and Ubuntu.
Step 4: Run your first container
sudo docker run --rm hello-world
If you see "Hello from Docker!", the daemon, image pull, and container runtime all work. Now try something useful, such as a throwaway web server:
sudo docker run -d --name test-web -p 127.0.0.1:8080:80 nginx:alpine
curl -I http://127.0.0.1:8080
sudo docker logs test-web
sudo docker rm -f test-web
Note the 127.0.0.1: prefix in the port mapping. We explain why in the security section.
Running docker without sudo (read this first)
You can add your user to the docker group:
sudo usermod -aG docker $USER
newgrp docker
Be aware that membership in the docker group is effectively root access on the host, because a member can start a container that mounts the host filesystem. Only add users you would trust with root. On shared servers, keep using sudo.
Step 5: Deploy a web app with Docker Compose
Compose describes a multi-container application in one file. Here is a small stack: an nginx web front end and a PostgreSQL database that is only reachable from inside the stack.
mkdir -p ~/myapp/html && cd ~/myapp
echo '<h1>It works</h1>' > html/index.html
Create .env for secrets (and keep it out of version control):
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-this-long-random-value
EOF
chmod 600 .env
Create compose.yaml:
services:
web:
image: nginx:1.27-alpine
ports:
- "127.0.0.1:8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: appdb
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
dbdata:
Points worth noting:
- There is no
version:key. Current Compose ignores it and prints a warning if it is present. - The
dbservice has noports:entry. Other services reach it at hostnamedbon the stack's default network, and nothing outside does. - The
webport is bound to loopback. You would put a reverse proxy with TLS in front of it (see our Nginx and Let's Encrypt guide for that part). - Image tags are pinned to a version instead of
latest, so apulldoes not surprise you.
Start and inspect it:
docker compose up -d
docker compose ps
docker compose logs -f web
curl http://127.0.0.1:8080
Daily operations:
docker compose pull && docker compose up -d # update images
docker compose down # stop, keep volumes
docker compose down -v # stop AND delete volumes (data loss)
Security tips that matter
1. Published ports bypass UFW
This is the most common surprise. Docker writes its own iptables rules, so -p 8080:80 can be reachable from the internet even if UFW says port 8080 is denied. Options:
- Bind to loopback (
127.0.0.1:8080:80) and expose only through a reverse proxy. - Publish only the ports that are meant to be public, such as 80 and 443.
- For rules that must apply to container traffic, add them to the
DOCKER-USERchain, which Docker does not overwrite:
sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -p tcp --dport 8080 -j DROP
Replace eth0 and the source range with your own. Persist rules with your normal firewall tooling, and test from an outside host with nmap or curl.
2. Never expose the Docker API
Do not enable tcp://0.0.0.0:2375. It is unauthenticated root access. If you need remote management, use SSH (docker context create with an ssh:// endpoint) instead. Likewise, avoid mounting /var/run/docker.sock into containers unless you fully trust the image.
3. Drop privileges per container
Add hardening options to services that support them:
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
Not every image works with read_only or a fully dropped capability set. Add back only what is required (for example cap_add: [NET_BIND_SERVICE]) and test. Run as a non-root user where the image allows it (user: "1000:1000"). Avoid privileged: true.
4. Limit resources and log growth
By default the json-file log driver has no size limit, so one noisy container can fill the disk. Set limits in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
sudo systemctl restart docker
The new defaults apply only to containers created after the restart, so recreate existing ones with docker compose up -d --force-recreate. In Compose you can also set mem_limit and cpus per service.
5. Keep images and the host patched
Use official or well-maintained images, pin versions, rebuild regularly, and scan images with a tool such as Trivy. Enable unattended-upgrades on the host. Keep secrets in .env files with tight permissions or Docker secrets, not baked into images.
Common mistakes and fixes
| Symptom | Cause | Fix |
|---|---|---|
permission denied ... docker.sock |
User not in docker group, or session not refreshed |
newgrp docker or log out and in |
docker-compose: command not found |
Using the legacy v1 command | Use docker compose |
port is already allocated |
Another service on that host port | ss -tlnp \| grep 8080, change the mapping |
| apt cannot find the repo | Derivative distro with unknown codename | Set the codename manually to the matching Ubuntu/Debian release |
| Disk fills up | Old images, stopped containers, logs | docker system df, then docker system prune (review first) |
| Container cannot reach another | Wrong hostname or different network | Use the service name and check docker network ls |
Where to run it
Containers are light, but production stacks still need predictable CPU, disk I/O, and a network you can protect. A VDS from Nixcon Group LLC runs on VMware vSphere and gives you full root, so Docker installs exactly as described above. If your containers serve public traffic such as web apps or game servers, pair them with DDoS protection and firewall so that attack traffic is filtered before it reaches your host's iptables and Docker's chains.
Frequently asked questions
Do I need to install Docker Compose separately on Ubuntu or Debian?
No. When you install the docker-compose-plugin package from Docker's official repository, Compose is available as the docker compose command. The old standalone docker-compose v1 binary is no longer needed.
Why is my container port open even though UFW blocks it?
Docker adds its own iptables rules for published ports, and these are evaluated before UFW's rules for forwarded container traffic. Bind the port to 127.0.0.1, publish only intended ports, or use the DOCKER-USER chain for restrictions.
Is it safe to add my user to the docker group?
It is convenient but equivalent to giving that user root access on the host. Only add trusted administrators. On shared systems, use sudo for Docker commands instead.
Should I use docker.io from the Ubuntu or Debian repositories?
It works for simple testing, but the package is often older and conflicts with Docker's official packages. For production, the official docker-ce repository provides current releases along with the Compose and Buildx plugins.