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.
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:
- Elasticsearch 8.12.2 consumes 1.47 GB RAM. Without an explicit
memory: 2glimit indeploy.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-demandviadocker run --rmwould 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 includesServerAliveInterval=30,AUTOSSH_GATETIME=0, and a healthcheck vianc. 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_timeoutto 120 seconds for long-running tasks - Use of
realpath_rootfor correctindex.phphandling
SSL and Security: Three Strategies on One Host
SSL termination is organized hybridly:
- 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
No comments yet.