How to Secure a VPS That Runs Docker Containers
SSH hardening, a default-deny firewall, and the fix for the problem that catches almost everyone — Docker publishing container ports straight past UFW into the public internet.
The parts of Docker that matter once something is actually running.
Docker is straightforward until something is running in front of users. At that point the interesting questions are networking, storage and lifecycle, and none of them are covered by the getting-started guide.
Reach for Compose on a single host for almost any self-hosted application. It is declarative, the file lives in git, and it handles multi-service dependencies without any orchestrator to operate. Reach for something larger only when you genuinely need scheduling across machines.
The networking behaviour that surprises people is port publishing. Docker
manipulates iptables directly, and its rules run before the ones UFW manages.
A container published with -p 5432:5432 is reachable from the internet even
when the firewall shows a default-deny policy. The fix is to bind published
ports to loopback and add an explicit rule to the DOCKER-USER chain.
Storage catches people twice. Named volumes are invisible until you need to back one up, and a tar of a live database directory restores into a corrupt cluster. Databases must be dumped by their own tooling; file volumes can be copied.
Lifecycle is the third. Without a health check, docker compose up reports
success the moment a container is created, which means a crash-looping deploy
goes green. With one, --wait gives you a deploy step that fails when it should.
Everything here assumes Compose v2 and a recent Docker Engine, states versions explicitly, and verifies the result rather than asserting it.
Start here
A production n8n install on Ubuntu 24.04 using Docker Compose, PostgreSQL and Caddy for automatic TLS — with the encryption key, webhook URL and backup steps most quickstarts leave out.
A reading order that builds Docker knowledge in the sequence it is actually needed.
SSH hardening, a default-deny firewall, and the fix for the problem that catches almost everyone — Docker publishing container ports straight past UFW into the public internet.
A production n8n install on Ubuntu 24.04 using Docker Compose, PostgreSQL and Caddy for automatic TLS — with the encryption key, webhook URL and backup steps most quickstarts leave out.
Failure modes that come up repeatedly with Docker, and where each one is solved.
A nightly backup for a self-hosted Docker host: database dumps taken with the right tools, file volumes snapshotted with restic, a systemd timer to run it, and a restore test that proves it works.
Test, build a container image, push it to GHCR and deploy it to a VPS over SSH — with the concurrency guard, environment approval and token scoping that keep the pipeline from becoming the weak point.
A Meilisearch deployment that survives a real workload — master key handling, scoped API keys, the index settings you must set before loading data, snapshots, and what its memory usage actually means.
Expose Docker services through Cloudflare with no inbound ports open — cloudflared plus Traefik, the forwarded-header setting that trips people up, and how to verify the firewall is closed.
Dokploy runs apps as Docker Swarm services, so rolling updates are already there — they just need a real health check and an update policy. Plus how to prove no request was dropped.
Both put a Heroku-style deploy UI on your own VPS, but on different container primitives. What that means for zero-downtime deploys, multi-server growth and the day the control plane breaks.
Both put a Heroku-style deploy UI on your own VPS, but on different container primitives. What that means for zero-downtime deploys, multi-server growth and the day the control plane breaks.
SSH hardening, a default-deny firewall, and the fix for the problem that catches almost everyone — Docker publishing container ports straight past UFW into the public internet.
A nightly backup for a self-hosted Docker host: database dumps taken with the right tools, file volumes snapshotted with restic, a systemd timer to run it, and a restore test that proves it works.
Named volumes for data the container owns — databases, application state. Bind mounts for configuration you edit on the host. Bind mounts also bring file ownership problems, because the container's user ID rarely matches yours.
Only for what the outside world must reach, and then bind explicitly to 127.0.0.1 unless it genuinely needs to be public. Containers on the same Compose network reach each other by service name with no published port.
Not by default — it returns once containers are created. Add `--wait`, which blocks until health checks pass, and make sure the services actually define a healthcheck or there is nothing to wait for.