Back to Home

Docker on VPS: 24 containers in production

The article breaks down the production deployment of 24 Docker containers on one VPS with 6 GB RAM. It covers architectural principles of isolation, dependency management via healthcheck, optimization of memory usage for Elasticsearch and Chrome Headless, secure work with remote DBs via SSH tunnels, and lightweight monitoring without Prometheus.

24 containers on one server: technical breakdown
Advertisement 728x90

24 Containers on a Single VPS: High-Density Technical Architecture for Middle/Senior DevOps

One server, 6 GB RAM, 24 containers — and everything runs stably for over a year. This is not an experiment, but production infrastructure supporting seven active projects: an EdTech bot, an anti-spam service, an AI backend, a meta-search engine, local LLM inference, and two utility services. None of them generate revenue, yet all are critical to operational activities. The goal was not cost-cutting for its own sake, but a conscious trade-off: manual management instead of managed services, predictable load instead of cloud elasticity, and deep integration instead of abstraction.

Architectural Principles: Isolation, Observability, Resilience

The core approach relies on strict network isolation. Each project resides in its own Docker network (bridge), eliminating cross-dependencies and minimizing the attack surface. A shared shared network is used only for system services (e.g., monitoring or common Redis). Internal ports (9200, 8088, 9222) are accessible exclusively via 127.0.0.1; external access is blocked at the iptables and nginx configuration levels.

Resilience is ensured through healthcheck dependencies in docker-compose.yml. For example, PHP services in the EdTech project do not start until an SSH tunnel to the remote database passes an nc -z 127.0.0.1 3306 check. This guarantees the application won't attempt to connect to an unavailable database and crash the queue with errors.

Google AdInline article slot

Observability is built without Prometheus/Grafana due to RAM constraints. Instead, a bash script runs every 15 minutes:

  • Checks HTTP status codes of public endpoints (/health, /webhook/advert)
  • Identifies unhealthy containers via docker ps --filter "health=unhealthy"
  • Monitors disk space and swap usage
  • Sends alerts to a Telegram chat via curl to the Bot API

This provides minimal but sufficient visibility for a single administrator.

Critical Components: Elasticsearch, Chrome Headless, and SSH Tunnels

Three components define the performance boundaries of the VPS:

Google AdInline article slot
  • Elasticsearch 8.12.2 consumes 1.47 GB RAM. Without an explicit memory: 2g limit in deploy.resources.limits, the JVM begins swapping out neighboring processes, causing instability. For an index of ~50K documents and logging, this offers the optimal balance between functionality and load. An alternative like Meilisearch would require migrating existing indexes and modifying client code, rated as a high-effort, low-ROI task at this stage.
  • Chrome Headless (zenika/alpine-chrome) occupies 636 MB RAM, though it is used only once an hour for JS rendering scraping. Running on-demand via docker run --rm would save memory but add a 5–10 second delay per call. For services with strict SLAs, this is unacceptable; here, it is an acceptable compromise.
  • SSH Tunnels are implemented in separate alpine containers with autossh. They provide secure access to MySQL on a shared host without opening ports externally. Configuration includes ServerAliveInterval=30, AUTOSSH_GATETIME=0, and a healthcheck via nc. This solution works with all hosts supporting SSH and fully replaces paid managed databases.

Nginx and PHP Optimization: Reducing Latency by Milliseconds

Nginx is configured exclusively for webhooks and APIs — no static files except /health. The key optimization is offloading Telegram webhook processing to a separate PHP file (webhook-advert.php) that bypasses the Laravel bootstrap. This reduces response time from 80–120 ms to 15–25 ms, which is critical during mass webhook calls from the Telegram API (max timeout — 60 seconds).

Configuration also includes:

  • Forced HTTP → HTTPS redirect
  • Refusal to process any paths other than /health, /webhook/, and /api/telegram/
  • Increased fastcgi_read_timeout to 120 seconds for long-running tasks
  • Use of realpath_root for correct index.php handling

SSL and Security: Three Strategies on One Host

SSL termination is organized hybridly:

Google AdInline article slot
  • getssl + Let's Encrypt — for domains requiring end-to-end encryption without Cloudflare
  • Cloudflare Proxy — for most domains: SSL terminates at the edge, traffic to VPS goes over HTTP, but protected by IP filtering (only Cloudflare ASN)
  • Manual Update — a temporary measure for the AI backend, currently undergoing automation via Caddy

Cloudflare setup includes mandatory Always Use HTTPS, WAF, and Rate Limiting (100 requests/min per IP), compensating for the lack of WAF on the VPS side.

Key Takeaways

  • Strict network isolation via separate Docker networks prevents cascading failures and simplifies security audits.
  • Healthcheck dependencies in docker-compose ensure services start only after their dependencies are ready (e.g., SSH tunnel to DB).
  • Elasticsearch requires a hard memory limit (2 GB); otherwise, the JVM swaps out neighboring containers, triggering the OOM killer.
  • Keeping Chrome Headless in always-on mode is justified at low usage frequency if startup latency is critical for business logic.
  • Bash monitoring with Telegram alerts is a working replacement for Prometheus/Grafana under RAM constraints, provided SLOs are clearly defined.

— Editorial Team

Advertisement 728x90

Read Next