Docker lets you package an application with everything it needs to run and start it as an isolated container, and Docker Compose lets you define several related containers - a web server and a cache, for example - in a single file. This guide installs Docker Engine and Compose on a BinaryLane Ubuntu 24.04 VPS, covers the container and Compose basics you'll use day to day, and flags a firewall interaction that will silently break container networking if you've also hardened the server with a custom nftables ruleset.
Prerequisites:
- A BinaryLane VPS running Ubuntu 24.04 LTS
- Root or sudo access
- Outbound HTTPS access - if this server has BinaryLane's port blocking enabled, disable it first, as Docker needs to reach Docker Hub and Docker's package repository
Rescue Console note: none of the steps below touch SSH or networking in a way that can lock you out on their own. If you've also applied a custom firewall (see the nftables section near the end) and something goes wrong, the BinaryLane Rescue Console gives you local access independent of the network.
TABLE OF CONTENTS
- Remove conflicting packages
- Add Docker's official repository
- Install Docker Engine and Compose
- Verify the installation
- Container lifecycle basics
- Docker Compose basics
- Docker and a custom nftables firewall
- Cleanup
Remove conflicting packages
Ubuntu ships older Docker-related packages under different names. Remove them first so they don't conflict with the official Docker packages:
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove -y $pkg done
It's fine if some or all of these report "not installed" - that just means there's nothing to clean up.
Add Docker's official repository
sudo apt-get update -y 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 "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update -y
Install Docker Engine and Compose
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
This installs Docker Engine itself plus the docker compose CLI plugin (no hyphen - docker compose, not the older standalone docker-compose binary).
Verify the installation
docker --version docker compose version sudo systemctl is-active docker sudo docker run --rm hello-world
You should see Docker's "Hello from Docker!" message, confirming the daemon can pull images and run containers end to end.
Container lifecycle basics
A few commands you'll use constantly:
docker run -d --name test-nginx -p 8080:80 nginx:alpine # start a container in the background docker ps # list running containers docker logs test-nginx # view its output docker exec -it test-nginx sh # open a shell inside it docker stop test-nginx # stop it docker start test-nginx # start it again (same container) docker rm -f test-nginx # remove it entirely
stop/start keep the container, including anything written to its writable layer. rm deletes it permanently. Data you actually care about should go in a volume (see below), not the container's writable layer - the writable layer disappears with rm.
Docker Compose basics
Compose lets you define multiple containers, their networking, and their storage in one file. Example - a web server that talks to a cache:
# docker-compose.yml
services:
web:
image: nginx:alpine
ports:
- "8080:80"
depends_on:
- cache
cache:
image: redis:alpine
volumes:
- redis-data:/data
volumes:
redis-data:docker compose up -d # create and start everything defined above docker compose ps # see what's running docker compose down # stop and remove containers + the network (volumes are kept) docker compose down -v # same, but also delete volumes
Networking
Compose puts every service in a file on one private network and gives each one a DNS name matching its service key. The web service above can reach the cache at the hostname cache - no IP addresses, no manual linking:
docker compose exec web getent hosts cache # 172.18.0.2 cache cache
This works for any two services in the same Compose file, automatically.
Volumes and persistence
Named volumes (like redis-data above) live outside the container and survive container removal - the difference between data that's actually durable and data that disappears the moment you run docker compose down:
docker compose exec cache redis-cli set mykey "hello" docker compose down # containers + network removed; volume is NOT docker compose up -d # fresh containers, same volume re-attached docker compose exec cache redis-cli get mykey # "hello" - the data survived because it lived in the named volume, not the container
Only down -v (or docker volume rm) actually deletes volume data.
Docker and a custom nftables firewall
If this server has also been hardened with a custom nftables ruleset (see the nftables Firewall Configuration guide), there are two real gotchas here - both confirmed on a live test deploy, not theoretical.
Important: A locked-down forward chain silently breaks published container ports for external traffic. Docker publishes container ports (-p 8080:80) using DNAT rules it manages itself, in its own nftables tables (visible via nft list tables as ip nat / ip filter, added via the iptables-nft compatibility layer). Traffic arriving from outside the server for a published port has to pass through the kernel's forward hook to reach the container's bridge network - if your own nftables config adds a forward base chain with policy drop and no exceptions, it will drop that traffic even though Docker's own rules are completely intact and correctly configured. Testing with curl localhost:8080 from the server itself will misleadingly look fine, because locally-generated traffic takes the output path, not forward - you have to test from an external client to see the problem.
The fix - explicitly allow forwarding for Docker's bridge interfaces in your own forward chain:
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
iifname "docker0" accept
oifname "docker0" accept
iifname "br-*" accept
oifname "br-*" accept
}Important: flush ruleset in your nftables config will wipe Docker's rules too, not just your own. Docker's iptables-nft rules live in the same overall nftables ruleset as any rules you write by hand. If your /etc/nftables.conf starts with flush ruleset (a very common pattern in generic hardening guides), reapplying it - on every boot, or any time you run nft -f /etc/nftables.conf by hand - deletes Docker's nat/filter tables along with yours. Every container's published ports stop working until you run systemctl restart docker, which makes Docker re-add its rules.
The fix - scope the flush to your own table, not the whole ruleset:
#!/usr/sbin/nft -f
destroy table inet filter # only removes *this* table if it exists - leaves Docker's tables alone
table inet filter {
chain input { ... }
chain forward { ... }
}If you've already hit this - containers unreachable, nft list tables shows no ip nat/ip filter - the fix is just systemctl restart docker.
Cleanup
To remove everything Docker-related from a server:
sudo docker system prune -a --volumes sudo apt-get purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
Verified end-to-end on a fresh Ubuntu 24.04 BinaryLane VPS: package install, hello-world, a two-service Compose stack with cross-container DNS resolution, volume persistence across down/up, and both nftables interactions above - confirmed broken without the fix, confirmed working with it, tested from both localhost and an external client.
