← Back to journal
Docker, DevOps, VPS

< 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.

7 Docker Mistakes I Keep Seeing on Production Servers

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.yml files 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 prune on 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.md on the server beats tribal knowledge
Rule of thumb: If you cannot list every running container and its purpose in under two minutes, you have sprawl.

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.0 with 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 Upgrade headers
  • Trusting X-Forwarded-For without 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=true in 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.production separate from local .env
  • Never mount /var/run/docker.sock unless 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

SymptomLikely causeFirst move
Disk full, SSH sluggishUnbounded Docker logsSet max-size / max-file; prune old logs
Random DB restartsOOM from memory leakdocker stats; set mem_limit; fix the leak
"It worked yesterday" after rebootNo restart policyAdd unless-stopped; check docker ps -a
SSL expiredProxy/certbot misconfigCentralize TLS on one reverse proxy
Data gone after redeployNo volume / no backupNamed volumes + off-site dumps
Too many unknown containersContainer sprawlAudit, name projects, prune, document

Frequently Asked Questions

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.

Running Docker on a VPS and not sure if your setup will survive traffic? I write about production architecture from Laravel stacks to the ops layer that keeps them online. If your server keeps crashing and you want a second pair of eyes on compose files, logging, and backups — hit Connect on this site.

Images from Unsplash — free to use under the Unsplash License.