Running Docker on a KVM VPS, properly
KVM virtualisation runs a real Linux kernel, so Docker behaves like it does on bare metal. Here is the setup that holds up under load.

Docker on KVM is straightforward: there is no nesting weirdness, no missing kernel modules, no shared-kernel surprises. KVM gives each VPS its own Linux kernel, so containers see the same primitives they would on a dedicated server. That said, a few defaults are worth changing before you put production traffic on the box.
Install Docker Engine, not docker.io
Skip the Ubuntu repo's docker.io package, which lags upstream by months. Use Docker's official repo. Piping a script from the internet straight into a root shell runs code you have never seen, so download it, skim it, then run it. If you would rather audit every step, Docker's docs also cover adding their apt repository by hand.
curl -fsSL https://get.docker.com -o get-docker.sh
less get-docker.sh
sudo sh get-docker.sh
Next, decide who gets to talk to the daemon. Membership in the docker group is root-equivalent: anyone in it can mount the host filesystem into a container and own the box. Only add accounts you would trust with sudo, or skip this step and keep prefixing docker commands with sudo.
sudo usermod -aG docker $USER
Log out and back in so group membership takes effect. docker run hello-world should print the welcome message without sudo.
Move the data root off the small partition
Docker writes images, layers, and volumes to /var/lib/docker, which lives on the root partition. On a VPS with a small root and a larger data disk, that fills up fast. Moving the data root takes five steps: mount the data disk, stop Docker, copy the existing data across, point Docker at the new path, and start it back up. Here it is end to end.
First, find the data disk. On KVM it usually shows up as vdb:
lsblk
Formatting wipes a disk completely and there is no undo. Read the lsblk output carefully and make sure the device you format is the empty data disk, not one holding anything you care about. If in doubt, sudo blkid /dev/vdb on an empty disk prints nothing.
sudo mkfs.ext4 /dev/vdb
sudo mkdir -p /mnt/data
Next, add the disk to /etc/fstab so it mounts at boot. A bad fstab entry can drop the VPS into emergency mode on the next reboot, so use the nofail option and test the entry with mount -a now, while you can still fix mistakes:
echo "UUID=$(sudo blkid -s UUID -o value /dev/vdb) /mnt/data ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab
sudo mount -a
findmnt /mnt/data
findmnt should show /mnt/data mounted from the new filesystem. Now copy Docker's existing data over. Stopping Docker stops every container on the box, so if anything production is running, do this in a maintenance window. Stop the socket too, or the first docker command anyone runs will silently start the daemon again mid-copy. If rsync is missing, install it first with sudo apt-get install -y rsync.
sudo systemctl stop docker docker.socket
sudo rsync -aP /var/lib/docker/ /mnt/data/docker/
Then point Docker at the new path. Create /etc/docker/daemon.json, or merge these keys into it if the file already exists, since a fresh install does not ship one:
{
"data-root": "/mnt/data/docker",
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
The log rotation lines matter just as much as the move. Default Docker logs grow without bound, and a chatty container will eat your disk in a week.
Start Docker back up and confirm it is using the new location:
sudo systemctl start docker
docker info | grep "Docker Root Dir"
docker images
You should see Docker Root Dir: /mnt/data/docker, and docker images should list everything that was there before the move. Once you are satisfied nothing is missing, reclaim the space on the root partition. This delete is permanent and removes the only remaining copy of the old data root, so run it only after you have confirmed Docker is healthy on the new path:
sudo rm -rf /var/lib/docker
Use overlay2
Docker auto-detects, but verify:
docker info | grep "Storage Driver"
You want overlay2. If you see vfs, your kernel is missing overlay support, which is unusual on KVM. File a ticket.
Network: don't expose ports you didn't mean to
Docker's port publishing inserts iptables rules that bypass UFW. A docker run -p 5432:5432 postgres exposes Postgres to the public internet even with ufw default deny incoming. Two fixes:
- Bind to localhost when the port only needs to be reachable from the host:
-p 127.0.0.1:5432:5432. - Bind to a private interface for inter-VPS comms:
-p 10.0.0.5:5432:5432.
For services that genuinely need to be public (a web server), bind the container to localhost as above and put a reverse proxy in front. With Caddy installed (our Caddy guide covers the install and TLS), the entire /etc/caddy/Caddyfile is:
example.com {
reverse_proxy 127.0.0.1:3000
}
Run sudo systemctl reload caddy and Caddy forwards public traffic to the container's localhost-bound port, with HTTPS handled for you. Treat exposed Docker ports as a last resort.
Resource limits
Containers can fork-bomb the host if you let them. Note that CPU and memory limits alone do not stop a fork bomb: thousands of tiny processes fit comfortably under a memory cap while exhausting the kernel's process table. The pids_limit line is the one that actually caps process count, so set all three via Compose:
services:
app:
image: your/app
pids_limit: 256
deploy:
resources:
limits:
cpus: '1.5'
memory: 512M
reservations:
memory: 256M
restart: unless-stopped
restart: unless-stopped is the right choice for stateful services. always will restart a container you stopped with docker stop the next time the daemon restarts; unless-stopped respects the manual stop.
Backups: volumes, not bind mounts
Docker volumes survive container recreation; bind mounts entangle host paths with container lifecycles. For databases:
volumes:
pgdata:
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
Snapshot the volume out of band. One rule first: never tar the data directory while Postgres is running. A copy of a live database directory is almost always internally inconsistent, and you find out the backup is unrestorable on the exact day you need it. Stop the container, take the tarball, start it again. The stop means a brief outage for anything using the database, so pick a quiet moment.
docker compose stop db
docker run --rm -v pgdata:/data -v $PWD:/backup alpine \
tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .
docker compose start db
If you cannot afford the stop, take a logical backup instead. pg_dump and pg_dumpall are safe to run against a live database:
docker compose exec db pg_dumpall -U postgres | gzip > pgdump-$(date +%F).sql.gz
Combine with the GetVPS snapshot feature for a belt-and-braces approach.
Watch what's actually running
docker stats is fine for a quick look. For something friendlier, ctop gives you a live per-container view. It is not in the Ubuntu repos, so grab the release binary:
sudo wget -O /usr/local/bin/ctop \
https://github.com/bcicen/ctop/releases/download/v0.7.7/ctop-0.7.7-linux-amd64
sudo chmod +x /usr/local/bin/ctop
For continuous visibility, run cAdvisor and let Prometheus scrape it:
services:
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
ports:
- "127.0.0.1:8080:8080"
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /mnt/data/docker/:/mnt/data/docker:ro
devices:
- /dev/kmsg
restart: unless-stopped
The last volume line is the data root we moved earlier; if you kept the default, use /var/lib/docker/:/var/lib/docker:ro instead. The web UI lives at http://127.0.0.1:8080 and the Prometheus metrics endpoint at http://127.0.0.1:8080/metrics. Wiring up the Prometheus side works exactly like scraping node_exporter, which our monitoring guide walks through.
Final sanity check
docker run --rm alpine sh -c 'cat /proc/1/cgroup; uname -r'
You should see a different kernel than your laptop and no mysterious errors. On a cgroup v2 host, which is what any current distro gives you, the cat prints 0::/ because the container runs in its own cgroup namespace. Only on older cgroup v1 hosts will you see paths under /docker/. Welcome to a real Linux box.
Continue reading