< journal.entry />
7 Docker Mistakes I Keep Seeing on Production Servers
Container sprawl, runaway logging, memory leaks, missing backups, and reverse proxy misconfigurations — the Docker mistakes that crash VPS servers and how to avoid them.
Docker on a VPS feels like free infrastructure. One docker compose up -d and your app is live. No Kubernetes. No platform team. Just you, a $12 server, and a README that says "it works on my machine."
Then production teaches the lesson. Disk hits 100%. SSH becomes unresponsive. docker ps shows forty containers and half of them are from projects you forgot existed. The OOM killer takes out MySQL because a Node process leaked memory for three weeks. You discover backups were never configured — only hoped for.
Docker production mistakes do not always look dramatic. They look like small defaults left unchanged until the VPS crashes at 2 a.m. These are the seven I see most often on real servers — and what to do instead.
Why VPS + Docker Is a Different Beast
A VPS is not a managed platform. There is no autoscaler, no built-in log drain, no volume snapshot button you cannot forget to click. You own the full stack: kernel, disk, networking, TLS, and every container you ever started and never removed.
That is why Docker DevOps on a single server punishes shortcuts harder than local development ever will. The mistakes below compound quietly — until they do not.
1. Container Sprawl
Container sprawl is what happens when every experiment, client project, and "quick staging" environment lives on the same VPS forever.
Typical symptoms:
- Twelve
docker-compose.ymlfiles across/opt,/home/deploy, and/var/www - Stopped containers from 2024 still holding port bindings and volume mounts
- Duplicate databases —
postgres,postgres-staging,postgres-old - Nobody knows which stack serves which domain
Sprawl wastes RAM and disk, creates port conflicts, and makes incident response slow. During an outage, you are not debugging one app — you are archaeology.
Fix it:
- One directory per environment:
/srv/appname/production,/srv/appname/staging - Run
docker system pruneon a schedule — but only after confirming nothing important is stopped - Use explicit project names:
docker compose -p myapp-prod up -d - Document what runs where. A simple
SERVICES.mdon the server beats tribal knowledge
2. Logging With No Rotation
Docker captures stdout/stderr into JSON log files on the host — by default, with no size limit. On a busy Laravel, Node, or nginx container, those files grow until the root partition is full.
Then everything fails at once: database writes, SSL renewals, SSH sessions, deploy scripts.
Fix it:
Set log rotation in docker-compose.yml or your daemon config:
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
For production at scale, ship logs to a central sink — Loki, Grafana Cloud, Papertrail, or at minimum rsyslog. Docker logging on a VPS is not "set and forget" unless you cap it.
Also watch application-level logs mounted to volumes. A Laravel storage/logs directory on a bind mount can fill disk just as fast as Docker's json-file driver.
3. Ignoring Memory Leaks
Applications leak memory. Node workers, PHP-FPM pools, Java heaps, Redis misconfigurations — they all drift upward over days. Without limits, one leaky container consumes the VPS until the Linux OOM killer intervenes.
The OOM killer does not politely stop your leak. It picks a process — often MySQL, PostgreSQL, or dockerd itself — and terminates it. Your "random" database crash at 3 a.m. was probably a memory leak three containers away.
Fix it:
Set memory limits per service:
services:
app:
deploy:
resources:
limits:
memory: 512M
On standalone Docker Compose (non-Swarm), use the older syntax that still works in practice:
services:
app:
mem_limit: 512m
Monitor with docker stats or Prometheus + cAdvisor. Restart policies help containers recover, but they do not fix leaks — they just delay the crash. Profile the app, cap workers, and tune pool sizes (PHP-FPM pm.max_children, Node cluster size, etc.).
4. No Backup Strategy for Volumes
This is the mistake that only hurts once — when you need a restore and discover backups were never real.
Common failures:
- Data stored inside the container filesystem instead of a named volume
- Volumes exist but nothing snapshots them
- Backups copy the git repo, not the PostgreSQL data directory
- "I will set up backups next week" for eleven months
A docker compose down or docker volume rm on the wrong day becomes permanent data loss.
Fix it:
- Always use named volumes for databases and uploaded files
- Automate backups:
pg_dump,mysqldump, or volume snapshots via your provider (Hetzner, DigitalOcean, Vultr all support this) - Store backups off-server — S3, Backblaze B2, another region
- Test restores quarterly. A backup you have never restored is a guess
The 3-2-1 rule still applies
Three copies, two media types, one off-site. Docker does not change that — it just makes it easier to forget where your data actually lives.
5. Skipping or Misconfiguring Reverse Proxies
Exposing app:3000 directly on 0.0.0.0:3000 is the fastest way to turn a VPS into a public attack surface. Reverse proxies — Traefik, Caddy, nginx — exist to terminate TLS, route by hostname, rate-limit, and keep application ports off the public internet.
Mistakes I see constantly:
- Apps bound to
0.0.0.0with no firewall, no proxy, no TLS - Manual certbot on nginx configs that break on renewal
- Mixing proxy configs across three tools on one server
- WebSocket and SSE apps proxied without proper
Upgradeheaders - Trusting
X-Forwarded-Forwithout setting real IP headers correctly
Fix it:
Pick one reverse proxy per server. Traefik or Caddy shine with Docker labels; nginx is fine if you already know it cold.
services:
app:
expose:
- "3000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.example.com`)"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
Bind apps to internal networks only. Let the proxy be the single public entry point on ports 80 and 443. Lock everything else with ufw or your provider firewall.
6. No Health Checks or Restart Policies
Containers exit. Processes segfault. Servers reboot. Without restart: unless-stopped and proper health checks, your app stays down until a human notices — or until a client sends an angry email.
I have SSH'd into servers where docker ps showed a clean bill of health, but the app container had been restarting in a crash loop for days because nothing monitored it.
Fix it:
services:
app:
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
Expose a real /health endpoint — not just "port open." Check database connectivity if your app needs it. Pair this with uptime monitoring (Uptime Kuma, Better Stack, Pingdom) so failures reach you before users do.
7. Treating latest and Dev Defaults as Production Config
Pulling image: latest on every deploy is not continuous delivery — it is roulette. A upstream breaking change at midnight becomes your outage.
Other dev defaults that leak into production:
DEBUG=truein environment variables- Compose files with volume mounts to source code (hot reload on a VPS)
- No pinned image digests or version tags
- Docker socket mounted into containers for "convenience"
Fix it:
- Pin versions:
postgres:16.2,redis:7.2-alpine - Use
.env.productionseparate from local.env - Never mount
/var/run/docker.sockunless you fully understand the security implications - Tag and push your own app images to a registry — build once, deploy the same artifact
Quick Reference: Symptom → Likely Cause
| Symptom | Likely cause | First move |
|---|---|---|
| Disk full, SSH sluggish | Unbounded Docker logs | Set max-size / max-file; prune old logs |
| Random DB restarts | OOM from memory leak | docker stats; set mem_limit; fix the leak |
| "It worked yesterday" after reboot | No restart policy | Add unless-stopped; check docker ps -a |
| SSL expired | Proxy/certbot misconfig | Centralize TLS on one reverse proxy |
| Data gone after redeploy | No volume / no backup | Named volumes + off-site dumps |
| Too many unknown containers | Container sprawl | Audit, name projects, prune, document |
Frequently Asked Questions
Plan for your apps plus 20–30% headroom for the OS, Docker daemon, and spikes. A small stack (reverse proxy + app + database) often needs 2 GB minimum; 4 GB is safer for production. Always set per-container memory limits so one service cannot take the whole server.
Traefik and Caddy integrate cleanly with Docker labels and automatic Let's Encrypt. nginx is excellent if you prefer explicit config files. The best choice is the one you will maintain consistently — not three different proxies on the same box.
Use database-native dumps (pg_dump, mysqldump) on a schedule, copy artifacts off-server, and optionally snapshot volumes at the provider level. Test restores regularly. Backups that only exist on the same VPS as your data are not disaster recovery.
Usually disk full from logs, memory exhaustion from leaks without limits, or container sprawl consuming resources. Check df -h, docker system df, and docker stats. Those three commands diagnose most "mystery" VPS crashes.
Conclusion
Docker on a production VPS rewards discipline more than cleverness. Container sprawl, runaway logging, unchecked memory leaks, missing backup strategy, and reverse proxies done wrong are not exotic failures — they are the default outcome when Docker is treated as a local dev tool dropped onto a live server.
None of these require Kubernetes or an enterprise platform. They require caps on logs, limits on memory, named volumes with real backups, one reverse proxy at the edge, and the habit of cleaning up what you deploy.
Your future self — the one SSH'ing in at 2 a.m. — will thank you for boring infrastructure.
Images from Unsplash — free to use under the Unsplash License.
Adi Sulaksono