Who this is for
Anyone setting up a fresh server to run containers: a single app, a Compose stack, a CI runner or a small homelab-style collection of services. If you are deciding whether one VPS is the right shape for your containers at all, start with Docker hosts. If Docker is already installed and you want to run a stack properly, skip to Compose in production.
Ubuntu 24.04 and 22.04
Remove anything that would conflict. On a fresh FastNode image most of these won't be present, and apt will just say so:
for p in docker.io docker-doc docker-compose docker-compose-v2 \
docker-buildx podman-docker containerd runc; do
sudo apt-get remove -y "$p" 2>/dev/null
done
Add Docker's key to /etc/apt/keyrings and check its fingerprint before trusting it:
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
gpg --show-keys /etc/apt/keyrings/docker.asc
The fingerprint should read 9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88. Now write the source in the newer deb822 format, which apt on 24.04 prefers:
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
The suite resolves to noble on 24.04 and jammy on 22.04. The service is enabled and started by the package.
Debian 12 and 13
Identical steps, with debian in both URLs and $VERSION_CODENAME as the suite. Drop docker-compose-v2 from the removal loop; Debian has no such package. The key and its fingerprint are the same:
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
# in docker.sources:
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
That expands to bookworm on Debian 12 and trixie on Debian 13. Docker publishes packages for both.
AlmaLinux 9
AlmaLinux ships Podman and Buildah, which clash with Docker's packages. Remove them, add Docker's RHEL repository and install:
sudo dnf remove -y podman buildah runc
sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
dnf asks you to accept the GPG key on first install. It should be 060A 61C5 1B55 8A7F 742B 77AA C52F EB6B 621E 9F35, a different key from the Debian one; check before typing y. Unlike the Debian family, the service is not started automatically, hence the last line.
Check it works
sudo docker run --rm hello-world
docker compose version
docker buildx version
docker info --format '{{.CgroupVersion}} {{.Driver}}'
On a fresh install of Engine 29 or later, expect 2 overlayfs: new installs use the containerd image store and its overlayfs snapshotter. A host upgraded from an older engine keeps overlay2. Note it is docker compose, a CLI plugin, not the old Python docker-compose binary. If a script calls the hyphenated form, update the script rather than installing the legacy tool.
Should I add my user to the docker group?
Only if that user would get full sudo anyway. Anyone in the docker group can run docker run -v /:/host and edit any file on the server as root, with no password prompt and nothing in the sudo log. It is a convenience for admins, never a way to give limited access.
sudo usermod -aG docker deploy # then log out and back in
For service accounts that only need to run containers, consider rootless mode, below.
Rootless Docker
Rootless mode runs a separate daemon as an ordinary user, so a container escape lands in that user's account rather than as root. On Ubuntu and Debian, the docker-ce-rootless-extras package comes in with docker-ce; add uidmap and run the setup tool as the target user:
sudo apt-get install -y uidmap
sudo loginctl enable-linger app
sudo -iu app
dockerd-rootless-setuptool.sh install
echo 'export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock' >> ~/.bashrc
enable-linger keeps the user's daemon running after logout and starts it at boot. Trade-offs: binding ports below 1024 needs net.ipv4.ip_unprivileged_port_start lowered, network throughput is somewhat lower, and the user's images live in their home directory, not /var/lib/docker.
Cap log size in daemon.json
By default the json-file driver keeps every line a container ever printed. Set a ceiling before you start real workloads:
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3", "compress": "true" }
}
EOF
sudo systemctl restart docker
That allows about 30 MB per container. Staying on json-file keeps tools that read /var/lib/docker/containers/*/*-json.log directly, such as some log shippers, working. Existing containers keep their old settings until recreated.
Why does Docker bypass my firewall?
Publishing a port with -p 8080:80 makes Docker add a DNAT rule in the nat table. Incoming packets are rewritten to the container's address before filtering, so they travel the FORWARD path and never meet the INPUT chain where ufw and most nftables rulesets live. Your "deny all" rule simply isn't consulted.
On current Ubuntu and Debian releases, Docker's rules use iptables-nft, so you can see them with sudo nft list ruleset alongside your own. There are three fixes, best first.
1. Bind to localhost. Put a reverse proxy on the host and publish containers only on the loopback address:
docker run -d -p 127.0.0.1:8080:80 nginx:1.27
Or make it the default for every container by adding "ip": "127.0.0.1" to daemon.json. You then have to ask for 0.0.0.0 explicitly when you really mean public.
2. Filter in DOCKER-USER. Docker evaluates this chain before its own forwarding rules and never flushes it. To allow a published port only from one address (find your interface name with ip -br link; on VMware it is often ens192):
sudo iptables -I DOCKER-USER -i ens192 -p tcp \
-m conntrack --ctorigdstport 8080 --ctstate NEW \
! -s 203.0.113.50 -j DROP
Match on --ctorigdstport because by this point the destination port has already been rewritten to the container's. These rules don't survive a reboot on their own; reapply them from a oneshot systemd unit ordered After=docker.service. The nftables guide covers keeping a ruleset that coexists with Docker's tables.
3. Use the portal firewall as an outer wall. Rules set per VM in the portal apply before traffic reaches the server, so Docker cannot override them. Allow 22, 80 and 443, deny the rest, and a forgotten -p 5432:5432 stays private.
Sizing
Docker itself adds little overhead; plan for your containers plus image storage. Boost (2 vCPU, 4 GB, 40 GB NVMe, $8/mo) handles a typical app, database and cache. Image-heavy hosts and CI builders fill disk first, so look at Turbo with 80 GB. Prices are in US dollars; your bank handles conversion.