Install from Docker's apt repository
On Ubuntu or Debian, add Docker's signed repository. This keeps the repository and signing key visible in the package manager instead of piping a downloaded installation script into a root shell.
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
case "$ID" in
ubuntu|debian) ;;
*) echo "This guide supports Ubuntu and Debian" >&2; exit 1 ;;
esac
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 update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
Log out and back in for the group to apply, then:
docker run --rm hello-world
docker compose version
The firewall trap
Docker writes its own iptables rules and publishes ports past a firewall
running inside the server. -p 5432:5432 on a database container is reachable
from the internet even with ufw denying everything, and people are still
surprised by this years after it was documented.
Two ways to avoid it:
Bind to localhost. Anything that only your own containers or a reverse proxy need to reach:
services:
db:
image: postgres:17
ports:
- "127.0.0.1:5432:5432"
Better still, publish no port at all and let containers talk over the compose network by service name.
Use the LayerOne cloud firewall. It filters upstream of the server, so it is
not affected by what Docker does to iptables. Inbound DROP with explicit allows
is the safe posture. See The cloud firewall.
Check what you have exposed
bash
docker ps --format 'table {{.Names}}\t{{.Ports}}'
sudo ss -tulpn | grep -v '127.0.0.1\|::1'
Anything on 0.0.0.0 is on the internet.
Keeping the disk from filling up
Images and volumes accumulate quietly, and a full disk is one of the most common "my server broke" tickets.
docker system df # what is using space
docker system prune -a # remove unused images, containers, networks
prune -a does not touch named volumes. Add --volumes only when you are sure.
Cap log growth, which otherwise grows without limit:
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
EOF
sudo systemctl restart docker
Memory
Containers see the whole host by default, so one runaway process can take the server down. On a small plan, set limits:
services:
app:
image: your/app
deploy:
resources:
limits:
memory: 512M
restart: unless-stopped
If you are running several containers on a 1 GB plan, add swap:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Backups
Docker volumes are on the server disk, and the disk is gone when the server is. We take no backups. Copy volumes off the box:
docker run --rm -v myapp_data:/data -v "$PWD":/backup alpine \
tar czf /backup/myapp-data.tar.gz -C /data .
Then move that file somewhere else, on a schedule. See Backups and snapshots.