Why a VM, not a container platform
Container platforms are convenient until you need a privileged container, a custom network driver, a sidecar that reads the host's journal, or a volume larger than the platform likes. On a FastNode VM the kernel is yours. Docker, Podman, containerd, k3s and Nomad all behave exactly as their docs describe.
NVMe-backed storage helps more than people expect. Image pulls, layer extraction and database volumes are all write-heavy, and overlay filesystems amplify small random I/O. Fast disks make docker compose up on a fresh host noticeably quicker.
Install Docker Engine
On Debian 12 or 13:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=amd64 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
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
On Ubuntu swap debian for ubuntu in both URLs. On AlmaLinux or Rocky Linux 9:
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo systemctl enable --now docker
Set sane daemon defaults
The default JSON log driver never rotates. A chatty container will fill a 20 GB disk in days. Fix it once in /etc/docker/daemon.json:
{
"log-driver": "local",
"log-opts": { "max-size": "20m", "max-file": "5" },
"live-restore": true
}
Then sudo systemctl restart docker. The live-restore flag keeps containers running while the daemon restarts, which makes Docker upgrades far less dramatic.
The firewall trap
This catches nearly everyone once. When you publish a port with -p 5432:5432, Docker writes iptables rules that accept the traffic before UFW or firewalld sees it. Your host firewall says the port is closed; the internet disagrees.
Two fixes, use both:
- Publish internal services on loopback only:
127.0.0.1:5432:5432. Only the reverse proxy should listen publicly. - Set rules in the FastNode portal firewall. It filters traffic before it reaches the VM, so Docker's iptables changes cannot override it.
A Compose layout that ages well
services:
proxy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
restart: unless-stopped
app:
image: ghcr.io/you/app:1.8.2
env_file: .env
expose: ["8080"]
restart: unless-stopped
depends_on: [db]
db:
image: postgres:16
volumes: [pgdata:/var/lib/postgresql/data]
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/pg
secrets: [pg]
restart: unless-stopped
volumes: { caddy_data: {}, pgdata: {} }
secrets:
pg: { file: ./secrets/pg.txt }
Pin image tags. latest turns a reboot into an unplanned upgrade. Keep the Compose file and Caddyfile in Git and deploy with git pull && docker compose up -d.
Updates without downtime surprises
Pull new images first, then recreate only what changed: docker compose pull && docker compose up -d. Compose leaves untouched services running. For the engine itself, read the release notes, snapshot, then upgrade the packages; with live-restore enabled your containers keep serving while the daemon restarts underneath them.
Housekeeping
# reclaim space from old images and build cache
docker image prune -af --filter "until=336h"
docker builder prune -f
# see what is actually using disk
docker system df -v
Schedule the prune weekly. Back up named volumes separately from snapshots: a pg_dump to another location protects you from a mistake that a snapshot would faithfully preserve.
Which plan
| Stack | Plan |
|---|---|
| Proxy plus one small app | Spark |
| App, Postgres, Redis, proxy | Boost or Turbo |
| Ten-plus containers, search engine, metrics | Nitro (12 GB RAM) |
| Single-node k3s with real workloads | Warp or Hyper |
Elasticsearch, OpenSearch and JVM services set their heap aggressively; cap it explicitly or they will compete with everything else for RAM. Prices are listed in US dollars, which is the currency we invoice in at the moment.