What to Run on a 2 vCPU / 2 GB VPS: 8 Workloads With Measured RAM, CPU, and I/O

A 2 vCPU / 2 GB VPS is the smallest instance most providers sell that can host something real. The useful question is not whether it works but what fits inside roughly 1,860 MB of usable memory after the kernel, systemd, and sshd take their cut. Eight workloads follow, measured on a KVM instance with 2 vCPU, 2 GB RAM, and 50 GB NVMe, sampled 30 minutes into steady traffic.

Baseline matters. Clean Debian 12 used 96 MB; Ubuntu 24.04 used 121 MB because of snapd. Every figure below includes that base, so subtract about 100 MB to compare against your own host. For tier-by-tier specs, see the VPS plans and specifications page.

Method: how these numbers were measured

Memory came from smem -t -k, reported as proportional set size so shared pages such as glibc and libcrypto are not double-counted across processes. Swap was disabled on purpose, because a 2 GB box that swaps is already failing. CPU was sampled with vmstat 1 60 during a 200 requests per second mixed load.

I/O used fio --rw=randwrite --bs=4k --iodepth=16 for 60 seconds alongside the service, to catch background writes that hit request latency. Anything pushing p99 above 400 ms on 4 KB random writes was flagged, since that is where cheap storage starts to feel slow.

Workload 1: LEMP WordPress, 620 MB resident

Nginx 1.24, PHP-FPM 8.2 with six workers, MariaDB 10.11 with a 256 MB buffer pool, and Redis for object caching totalled 620 MB: MariaDB 340 MB, PHP workers 180 MB, Nginx 22 MB, Redis 24 MB, plus system overhead. An uncached page rendered in 71 ms at p50 and 190 ms at p95 while serving 35 requests per second.

MariaDB is the hog. Cutting the buffer pool to 160 MB brought the total to 510 MB and cost only about 12 ms at p50 on a site with 8,000 posts, a fair trade. Add a 128 MB OPcache with opcache.validate_timestamps=0 and keep PHP workers at six; that combination fits without memory pressure.

Workload 2: Node.js API with PostgreSQL, 540 MB

Fastify 4 on Node 20 with four cluster workers plus PostgreSQL 16 at shared_buffers=256MB settled at 540 MB. Node workers ran 48 MB each; PostgreSQL held 300 MB. Under autocannon at 300 JSON requests per second, CPU sat at 62 percent across both vCPUs and p99 was 38 ms.

Set max_connections=40 and put PgBouncer in transaction mode in front. Each idle backend costs about 5 MB, so 100 idle connections would eat 500 MB alone. That one setting separates a stable box from an out-of-memory kill.

Workload 3: Standalone Redis, 90 to 300 MB

Redis 7.2 is the friendliest service on a small VPS because it can be capped precisely. With maxmemory 256mb and allkeys-lru, the process reported 268 MB including allocator overhead. A pure cache of 50,000 small keys used 92 MB; as a session store with 10,000 active sessions it hit 180 MB.

Two cautions. AOF with appendfsync everysec adds about 40 MB and produced the only I/O spike in testing, 2.1 ms typical writes rising to 11 ms during rewrite. And a replica full resync briefly doubles memory. On 2 GB, run Redis as a cache with an AOF cap of 512 MB, and do not stack it with MariaDB unless both are trimmed.

Workload 4: Prometheus + Grafana monitoring, 480 MB

Prometheus 2.51 with 7-day retention and a 15-second scrape across six targets used 310 MB. Grafana 10 with two open browsers used 130 MB and node_exporter 14 MB. A 6-hour range query at 15-second resolution returned in 220 ms.

Disk matters more than memory here. Six targets at roughly 900 active series each produced 1.4 GB of blocks over 7 days, so cap retention at 15 days and enable --storage.tsdb.wal-compression. Keep Grafana’s SQLite file on the same NVMe rather than a network mount.

Workload 5: Docker Compose stack, 890 MB

Docker 26 plus Compose running a reverse proxy, two app containers, PostgreSQL, and Redis used 890 MB. The daemon and containerd alone accounted for 110 MB before any container started,. Image layers for those five images consumed 6.4 GB on disk.

Do not set mem_limit everywhere and hope. We saw PostgreSQL OOM-killed when the page cache owned by Docker’s overlay filesystem grew under a 512 MB limit. Set explicit limits on the largest consumers, leave 300 MB headroom, and switch logging to json-file with max-size=10m or journald with a cap, or logs will fill the disk within a week.

Workload 6: Gitea with PostgreSQL, 310 MB

The lightest full application tested. Gitea 1.22 as a single binary used 120 MB with 40 users and 90 repositories. PostgreSQL alongside it with a 128 MB buffer pool used 160 MB, and sshd added 30 MB. Cloning a 200 MB repository took 11 seconds, bounded by disk.

Gitea’s built-in SQLite removes the PostgreSQL 160 MB and stays fine to roughly 200 repositories. Actions runners are the hidden cost: one idle gitea-actions runner used 45 MB, and a Go build spiked to 1.2 GB and triggered the OOM killer. Keep CI on a separate instance.

Workload 7: MinIO object storage, 260 MB

MinIO in single-node single-drive mode used 260 MB with 50,000 objects stored. Uploading 1 GB as 1,000 PUT requests of 1 MB averaged 41 ms each. Small objects are where RAM cost appears: 4 KB objects at 500 requests per second drove usage to 410 MB through multipart buffers and the write cache.

MinIO officially targets large objects and needs erasure coding across four drives for durability. On one 50 GB disk you get no redundancy, so treat it as a development S3 endpoint. For durable storage, the hosting options overview lists instances with attached block storage.

Workload 8: Self-hosted CI runner, 700 MB

A GitHub Actions self-hosted runner with Docker and one job at a time used 180 MB idle. Compiling a mid-sized Rust project spiked to 700 MB and pinned both vCPUs for 4 minutes 20 seconds. A Node build with 1,200 packages peaked at 640 MB. Both fit only if nothing else large is running.

The practical setup is runner plus nothing else at concurrent jobs = 1. Reserve 20 GB of disk margin because build caches grow fast, and run docker system prune -af --filter until=72h nightly. Parallel jobs on 2 vCPU simply thrash.

When 2 GB is not enough

Two GB fails when three things overlap: a database working set over 300 MB, Docker in the middle, and a second heavy service. LEMP plus Redis plus monitoring crosses 1.1 GB and leaves nothing for backup compression, which spikes 200 to 400 MB. That is when the OOM killer takes MariaDB at 3 a.m.

Move up on any of these signals: sustained swap above 50 MB per hour, non-zero si/so in vmstat, OOM kills in dmesg, or steal time over 3 percent at peak. A 4 GB instance removes the juggling act and usually costs less than the tuning hours. Also check disk endurance, because 2 GB boxes often ship 25 to 40 GB SSDs shared by databases and Docker caches.

Choosing the workload that fits

Pick one primary service and at most one light companion. LEMP plus Redis at 650 MB, Gitea plus PostgreSQL at 310 MB, and MinIO alone at 260 MB are comfortable. Compose stacks and CI runners are feasible but should be alone on the box. In every case set a systemd MemoryMax= ceiling, keep a 1 GB swap file as a safety valve, and alert when available memory drops below 150 MB.

Before going live, compare entry-level instances on virtualserversvps.com, confirm the CPU is dedicated with stress-ng --cpu 2, and check that storage is NVMe rather than network-backed. Those three checks matter more than any benchmark table.

Leave a Reply