Docker Compose on a small VPS: what actually fits
TL;DR: The honest answer to "how much can I run on a small VPS" is a memory budget, not a container count. Docker itself is cheap; what you run inside it is not, and the range is enormous — a reverse proxy uses about 100MB while a search engine or a JVM application can want a gigabyte on its own.
On a 4GB server, budget roughly 600MB for the operating system and Docker, keep 20% free as headroom, and you have around 2.5GB to allocate. That comfortably runs a proxy, a database, a cache and three or four lightweight applications. It does not run anything with Elasticsearch or a JVM in it without crowding everything else out.
Docker overhead is small, containers are not
A common misconception is that containers carry significant overhead. They do not, in the way virtual machines do.
Containers share the host kernel and run as isolated processes. There is no guest operating system, no hypervisor, no duplicated kernel. A containerised process uses roughly the memory the process would use anyway, plus a small amount for the container runtime.
The Docker daemon itself typically consumes a modest amount, on the order of 100 to 200MB depending on how many containers it manages.
So the framing "how many containers can I run" is the wrong question. Ten lightweight containers can total less than one heavy one.
What actually determines fit:
The memory each process needs, which varies by orders of magnitude between a static file server and a search index.
Whether the process is aware of container limits. Some runtimes historically read host memory rather than the container's limit and sized their heap accordingly, which meant a JVM in a 512MB container happily deciding it had 4GB available and getting killed. Modern versions handle this correctly, but older images may not.
Whether you set limits at all. Without them, one container can consume everything and the out-of-memory killer terminates something else, frequently your database.
Disk, which fills faster than people expect through image layers, logs and volumes.
The useful mental model: you are not running containers, you are running processes with an accounting layer. Budget memory as you would if they were installed directly.
Building a memory budget
The exercise that turns guesswork into planning. Do it before provisioning, not after something dies.
Start with total RAM. Say 4GB.
Subtract the operating system. A minimal Linux install with Docker uses roughly 500 to 700MB once running. Call it 600MB.
Reserve headroom. Keep at least 20% free. Memory pressure degrades performance long before it triggers a kill, and a server running at 95% has no capacity for a traffic spike or a backup job. On 4GB, reserve 800MB.
What remains is your allocation. 4096 minus 600 minus 800 leaves roughly 2,700MB to distribute.
Allocate per service, using realistic figures rather than idle ones. A database at idle uses far less than a database under load, and you must size for load.
Add swap regardless. Swap does not create capacity, but it converts a hard out-of-memory kill into temporary slowness, which is the difference between a degraded service and a dead database. A 2GB swap file on a 4GB machine with vm.swappiness set low is the standard configuration, as covered in the new VPS checklist.
If your budget does not fit, the answer is a bigger server or fewer services. Running services that do not fit and hoping is how you get an OOM killer terminating PostgreSQL at three in the morning.
Typical memory consumption by service
Approximate working figures under light to moderate load. Treat as planning estimates rather than guarantees, since configuration changes them substantially.
| Service | Typical working memory | Notes |
|---|---|---|
| Caddy or Nginx proxy | 50–150MB | Cheap, always worth having |
| PostgreSQL | 300–600MB | Defaults tuned for larger machines |
| MySQL or MariaDB | 400–700MB | Similar; tune buffer pool down |
| Redis | 50–300MB | Depends entirely on dataset; set maxmemory |
| n8n | 300–700MB | Payload size drives this |
| Nextcloud (PHP-FPM) | 400–800MB | Preview generation spikes it |
| Uptime monitoring tool | 100–200MB | Lightweight |
| Static site container | 20–50MB | Negligible |
| Node.js app | 150–500MB | Varies hugely by application |
| Grafana | 150–300MB | Reasonable |
| Prometheus | 300MB–1GB+ | Grows with retention and series count |
| Elasticsearch | 1–2GB minimum | Effectively rules out small servers |
| Any JVM application | 512MB–2GB | Heap must be capped explicitly |
| GitLab (self-hosted) | 4GB+ | Needs its own server |
| Headless browser service | 500MB–1GB | Spiky and unpredictable |
The pattern: the infrastructure pieces are affordable and the search, metrics and JVM tier is not. A single Elasticsearch container consumes what four application containers would.
Realistic stacks by server size
1 vCPU, 2GB. Genuinely constrained. A reverse proxy, one small application, and a small database, with nothing to spare. Suitable for a single project or learning. Adding a fourth meaningful service means something gets killed. Do not run a production database and an application that both matter here.
2 vCPU, 4GB. The practical starting point for a multi-service box. Comfortably runs a proxy, PostgreSQL, Redis, and three or four lightweight applications — for example a static site, a small Node service, an automation tool, and an uptime monitor. Still no room for Elasticsearch or a JVM.
4 vCPU, 8GB. Room to be relaxed. The above plus a heavier application, a metrics stack, or a document editing component. One Elasticsearch instance becomes possible if you cap its heap and accept it consuming a quarter of the machine.
8 vCPU, 16GB. Multiple substantial applications, a metrics and logging stack, and headroom. This is where self-hosting a broad set of services stops requiring constant arithmetic.
The consolidation principle: run one database server shared by several applications with separate databases, rather than one database container per application. Three PostgreSQL containers cost roughly three times one, for no benefit at this scale. The same applies to Redis.
The counter-principle: do not consolidate things with genuinely different availability requirements. If one application's traffic spike shouldn't be able to take down another business-critical service, they belong on different machines regardless of memory arithmetic.
Set memory limits, or one container takes everything
Without limits, containers compete freely for host memory. A memory leak or a spike in one consumes everything, and the kernel's out-of-memory killer selects a victim by its own heuristics — frequently the largest process, which is usually your database.
The result is that a bug in a minor application takes down your primary data store, which is precisely the failure mode containers were supposed to isolate.
Set limits on every service. Docker Compose supports memory limits per service, and setting them means a misbehaving container is killed rather than the host being destabilised. A restart policy brings it back.
Set reservations too where supported, expressing what a service needs rather than only its ceiling.
Configure applications to respect their limits. A memory limit does not tell the process inside about the constraint. Specifically:
Cap the JVM heap explicitly rather than relying on defaults. Set PostgreSQL's shared buffers and work memory to suit its allocation rather than the machine. Set Redis maxmemory and an eviction policy, or it grows until killed. Configure PHP-FPM worker counts against available memory rather than leaving generous defaults.
Test the limits. A limit that is too tight causes the container to be killed under normal load, which is a self-inflicted outage. Watch actual consumption for a week before tightening.
Use restart policies. restart: unless-stopped on everything, so a killed container comes back rather than staying down until someone notices.
The Docker firewall problem
The security issue that catches people who did everything else right.
Docker manipulates the host's packet filtering rules directly to enable container networking and port publishing. On many systems this means published container ports bypass UFW rules entirely.
So a container started with a published port mapping can be reachable from the internet even though your firewall configuration appears to block that port. You check UFW, it says the port is denied, and the service is answering to the whole world.
Why this matters enormously: the most common thing people publish is a database port for convenience during setup. An internet-reachable PostgreSQL or MySQL with weak credentials is found by automated scanning within days.
The fixes:
Bind to localhost explicitly. Publishing as 127.0.0.1:5432:5432 rather than 5432:5432 restricts the mapping to the loopback interface, so it is not reachable externally regardless of firewall behaviour. This is the simplest and most reliable answer.
Do not publish at all for services only other containers need. Containers on the same Docker network reach each other by service name without any host port mapping. Your application talks to postgres:5432 on the internal network; the host does not need a published port.
This is the correct default and it eliminates the problem entirely. Publish ports only for services that genuinely need to be reachable from outside, which is usually just your reverse proxy.
Verify what is actually listening. Check the listening sockets on the host and confirm which interface each is bound to. Do not trust the firewall configuration alone.
Scan from outside. From another machine, scan your server's public address and confirm only the ports you intended are open. This takes two minutes and is the only check that reflects reality.
Bind admin interfaces to a VPN interface rather than publishing them, which is the pattern described in the WireGuard guide.
Volumes, and the data you will otherwise lose
Container filesystems are ephemeral. Anything written inside a container disappears when the container is removed, and containers are removed routinely — on updates, on configuration changes, on docker compose down.
Named volumes are Docker-managed storage that persists independently of container lifecycle. Use them for databases and application data.
Bind mounts map a host directory into a container, giving you direct filesystem access. Convenient for configuration files you want to edit, and for data you want in a known location for backup.
What must be on a volume: database data directories, uploaded files, application state, certificates if not regenerated automatically, and configuration you have customised.
The classic mistake: running PostgreSQL without mounting its data directory. Everything works, and then a container recreation deletes the entire database. This happens more often than it should, and the moment of discovery is memorable.
Back up volumes, not just files. A backup strategy for a containerised stack needs to capture volume contents, which for databases means using proper dump commands rather than copying data files from a running database. Copying live database files produces an inconsistent snapshot that may not restore.
Stop or quiesce before file-level backups, or use the database's own dump tooling while running.
Keep your Compose files and environment files in version control, separately from the volumes. Rebuilding a stack is straightforward if you have the definitions; reconstructing them from memory is not.
Test a restore. Bring a stack up from backup on a throwaway server and confirm the data is intact, following the 3-2-1 approach.
Disk, the other constraint nobody plans for
Memory gets attention; disk quietly fills and takes everything down at once.
Where it goes:
Image layers. Every pull and build adds layers, and old images are retained. On a server deploying regularly, this accumulates faster than anything else.
Container logs. Docker's default logging driver writes to disk without rotation unless configured. A chatty container can write gigabytes over months.
Volumes. Database growth, uploaded files, and generated content.
Build cache if you build images on the server.
Orphaned volumes from removed containers, which are not deleted automatically.
The controls:
Configure log rotation in the Docker daemon settings with a maximum size and file count. This is a two-line change most people never make, and it prevents the most avoidable form of disk exhaustion.
Schedule image and build cache pruning, weekly or monthly depending on deployment frequency.
Prune orphaned volumes deliberately, but carefully — this deletes data, so verify what will be removed before running it.
Monitor disk usage with an alert at 80%. A full disk means the database cannot write, logs cannot be written, and services fail in confusing ways that do not obviously point at disk.
Size the disk for growth, not for today. Database growth and log accumulation are steady, and resizing a VPS disk is more disruptive than provisioning adequately at the start.
Operating a multi-service box
One Compose file per logical stack, or one large file, but be consistent. Scattered ad-hoc containers started with long command lines are unmaintainable, and nobody remembers the flags six months later.
Pin image versions. Running latest means an unattended pull can introduce a breaking change. Pin, read release notes, upgrade deliberately.
Use a reverse proxy in front of everything, handling TLS centrally rather than per service. Caddy or Traefik with automatic certificate issuance removes an entire category of maintenance.
Health checks so orchestration knows whether a container is actually working, not merely running.
Restart policies on everything.
Monitor from outside, hitting real endpoints rather than checking whether a container is up.
Watch memory and disk trends, not just current values. Approaching a limit is actionable; hitting it is an outage.
Update deliberately, one stack at a time, with a backup taken first.
Document what runs where. Which services, which ports, which volumes, where the backups go, and how to bring it back. A multi-service box that only one person understands is an operational risk, and that person eventually goes on holiday.
Reassess periodically. Services accumulate. A container running for a year that nobody uses is consuming memory and presenting an unpatched attack surface.
FAQs
How many containers can a 4GB VPS run?
Container count is the wrong measure, because memory consumption varies by orders of magnitude between services. On 4GB, budget about 600MB for the operating system and Docker, reserve 20% as headroom, and allocate the remaining 2.5GB. That comfortably covers a reverse proxy, a database, a cache and three or four lightweight applications.
Does Docker itself use a lot of memory?
No. Containers share the host kernel and run as isolated processes with no guest operating system or hypervisor, so a containerised process uses roughly what the process would use anyway. The Docker daemon typically consumes 100 to 200MB. The memory cost is in what you run, not in the containerisation.
Should I set memory limits on Docker containers?
Yes, on every service. Without limits, a leak or spike in one container consumes all host memory and the out-of-memory killer selects a victim by its own heuristics, frequently your database. Limits mean the misbehaving container is killed instead, and a restart policy brings it back.
Why is my database reachable from the internet despite my firewall?
Because Docker manipulates packet filtering rules directly, so published container ports frequently bypass UFW entirely. Bind published ports to localhost explicitly, as 127.0.0.1:5432:5432, or better, do not publish them at all since containers on the same Docker network reach each other by service name without any host port mapping.
Can I run Elasticsearch on a small VPS?
Not comfortably. Elasticsearch wants at least 1 to 2GB on its own, which on a 4GB server consumes most of your allocation and leaves nothing for the applications that need it. If you require it, size for it explicitly or use a hosted search service instead.
Should each application have its own database container?
Not at this scale. Run one PostgreSQL server with separate databases per application, which costs roughly a third of running three separate containers for no practical benefit. The same applies to Redis. Consolidate infrastructure services and keep applications separate.
What happens if I do not use volumes?
Any data written inside the container is lost when the container is removed, which happens routinely on updates and configuration changes. Running a database without mounting its data directory is the classic version of this mistake, and the moment of discovery, when a routine container recreation deletes your entire database, is memorable.
How do I back up Docker volumes?
For databases, use the database's own dump tooling rather than copying data files from a running instance, since a file-level copy of a live database can be inconsistent and fail to restore. For other volumes, stop the container or quiesce writes first. Keep Compose and environment files in version control separately, and test a restore on a throwaway server.
Why does my VPS disk keep filling up?
Docker image layers accumulating from every pull and build, container logs written without rotation by default, growing volumes, and orphaned volumes from removed containers. Configure log rotation in the Docker daemon settings, schedule image pruning, and alert on disk usage at 80%, since a full disk fails services in confusing ways.
Do I need swap on a Docker host?
Yes. Swap does not add capacity, but it converts a hard out-of-memory kill into temporary slowness, which is the difference between a degraded service and a terminated database. A 2GB swap file on a 4GB machine with swappiness set low is the standard configuration.
Should I use latest tags for Docker images?
No, not in production. Running latest means an unattended pull can introduce a breaking change with no warning. Pin specific versions, read release notes before upgrading, and take a backup first, particularly for anything with a database migration on version change.
How do I know what is actually listening on my server?
Check the listening sockets on the host directly and note which interface each is bound to, rather than trusting your firewall configuration. Then scan your public address from another machine and confirm only the intended ports respond. This takes two minutes and is the only check that reflects what the internet can actually reach.
Conclusion
Plan by memory budget, not by container count. Total RAM, minus the operating system, minus a fifth for headroom, is what you actually have to distribute, and a single Elasticsearch or JVM service can consume as much as four application containers together.
Set limits on everything. The whole point of isolation is defeated if one leaking container can trigger the kernel into killing your database, and that is exactly what happens without limits.
The security item worth acting on today is the Docker firewall behaviour. Published ports frequently bypass UFW, which means a database you published for convenience during setup may be answering the entire internet while your firewall configuration claims otherwise. Bind to localhost, or better, do not publish it at all — containers reach each other by service name on the internal network, and only your reverse proxy needs a host port.
Volumes for anything you would miss. The database without a mounted data directory works perfectly until the day a routine update recreates the container.
And configure Docker log rotation now, because it is two lines and it prevents the most predictable form of disk exhaustion there is.
HostCloud runs Linux VPS plans from ₹999 a month on NVMe with full root access, snapshot backups from the panel and Indian data centres, sized so a 4GB instance has the headroom this arithmetic assumes. Details at https://hostcloud.in.
