MADEBYARIS

Can a 1 GB VPS serve a million WordPress requests a minute?

By Aris Setiawan12 min read
Can a 1 GB VPS serve a million WordPress requests a minute?

Summary

I wanted to know whether one small WordPress server could handle 1,000,000 requests per minute. The server was an UpCloud VPS with one vCPU and 1 GB of RAM, running Nginx, PHP 8.4, MariaDB and Valkey, with the finished HTML pages cached in RAM.

Benchmarks run on the server itself said yes. It answered 1,000,000 HTTPS requests in 55.8 seconds, which works out to about 1,075,000 per minute. Real traffic from Loader.io said yes too, for a small page. The best external run was 1,034,655 requests in one minute with zero errors, an average response of 137 ms and a maximum of 792 ms, with Loader.io ramping from 0 to 5,000 clients. For the full 25 KB homepage, the best clean run was 271,412 requests in one minute.

Most of the gap came from four things: the memory each open HTTPS connection costs, an Nginx setting I had added that made memory use worse, Cloudflare’s DDoS protection blocking the test when I put Cloudflare in front, and the fact that a benchmark on the same machine never touches the network.

The test server

ItemValue
Provider and locationUpCloud, Chicago
CPU1 vCPU, AMD EPYC 9575F (KVM)
RAM856 MiB usable (1 GB plan), plus a 2 GB swap file I added
Disk25 GB
OS and kernelCentOS Stream 10, Linux 6.12
Web serverNginx 1.26.3 with OpenSSL 3.5
PHPPHP 8.4.27 FPM with OPcache and the tracing JIT
Database and object cacheMariaDB 10.11, Valkey 8.1 over a Unix socket
WordPressWordPress 7.1.4, Ollie 1.6.6 block theme, Redis Object Cache plugin

The test site was testwp.madebyaris.com: a Gutenberg landing page, four inner pages, three blog posts, and a plain-text page at /lite/ made for low-bandwidth testing.

How the server was set up

Kernel and network

The defaults on a small VPS are sized for light traffic, so I raised the connection queues and file limits and switched TCP to BBR:

net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 65535
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_mem = 32768 49152 65536
net.ipv4.tcp_max_orphans = 65536
fs.file-max = 2097152
vm.swappiness = 10

Nginx runs one worker (one CPU) with worker_connections 65535, reuseport, a listen backlog of 65535, and a file limit of 262,144. The kernel TLS module is loaded so Nginx can hand encryption to the kernel. HTTP/2 and HTTP/3 are on.

Two layers of page cache in RAM

A 192 MB tmpfs (a RAM disk) holds two caches:

  1. A small must-use plugin writes every anonymous page view to the RAM disk as index.html, plus a pre-compressed index.html.gz. Nginx serves those files directly with try_files, gzip_static and sendfile, so a cached request never reaches PHP.
  2. Behind that sits the Nginx FastCGI cache, also on the RAM disk, with fastcgi_cache_lock and fastcgi_cache_use_stale so a burst of misses produces one PHP render instead of hundreds.

Logged-in users, WooCommerce cart cookies, query strings, wp-admin, wp-login.php and the REST API skip both caches. The plugin clears both when a post, menu, comment or theme changes.

PHP and one SELinux surprise

PHP-FPM uses OPcache (96 MB) and the tracing JIT (opcache.jit = 1255, 32 MB buffer). With SELinux enforcing, the first page request failed with “502 Bad Gateway”. The PHP workers were crashing with a segmentation fault, because SELinux blocks a web server process from creating executable memory, which the JIT needs. Setting setsebool -P httpd_execmem 1 fixed it while keeping SELinux enforcing. If you enable the JIT on RHEL, CentOS or Fedora and get unexplained 502s, check the audit log for execmem denials.

Benchmarks on the server itself

These ran on the VPS against its own address, with the load tool sharing the single CPU.

TestResult
1,000,000 HTTPS requests, keep-alive, 200 connections, 12 KB gzipped page55.8 s, 17,922 requests/s, 0 failed, median 11 ms, 99th percentile 19 ms
Same, Ollie homepage (25.9 KB gzipped), 200,000 requests15,421 requests/s, 0 failed
/lite/ page (0.7 KB), new connection per request, 200,000 requests11,892 requests/s, 0 failed
Uncached dynamic page (logged-in cookie), 8 at a time27 requests/s, median 286 ms

The first row is the “1 million a minute” number. It is real, but it measures Nginx serving a file from RAM to a client on the same machine. Traffic never crosses the network card, there is no internet latency or packet loss, and every client finishes its connection almost instantly. The external tests below behaved very differently.

The last row is worth keeping in mind too. Without the page cache, this server renders about 27 WordPress pages per second.

Load tests from Loader.io

Loader.io sends traffic from Amazon servers. Most runs used its “clients per second” mode: a set number of new clients each second for one minute, each making requests over its own connection.

RunSetupSuccessfulErrorsAverage responseData received
1Homepage, 10,000 clients/s, direct to VPS7164,752 network14,206 ms37.5 MB (aborted)
2Homepage, 1,500 clients/s, direct, HTTP89,998028 ms2.19 GB
3Homepage, 2,500 clients/s, direct, HTTP147,445029 ms3.59 GB
4Homepage, 10,000 clients/s, through Cloudflare, HTTPS48,049201,389 HTTP 4031,141 ms2.46 GB (aborted)
5/lite/, 10,000 clients/s, direct94,0867,018 network9,662 ms98.6 MB
6/lite/, 10,000 clients/s, direct, after first fixes46,11416,686 network15,118 ms48.3 MB
7/lite/, 5,000 clients/s, direct299,79901,063 ms314 MB
8Homepage, ramp from 0 to 2,000 concurrent clients271,4120209 ms6.61 GB
90 to 5,000 clients over 1 minute1,034,6550137 ms1.06 GB

Run 9 is the headline number: 1,034,655 requests in one minute with zero errors, an average response of 137 ms and a maximum of 792 ms. The Loader.io result summary does not show which URL it hit, but 1.06 GB over 1,034,655 responses is about 1 KB each, which fits the small /lite/ page rather than the homepage. During the run, htop on the VPS showed 416 MB of 857 MB RAM and 179 MB of swap in use. For the real homepage, run 8 is the best clean result among these runs, at about 270,000 requests per minute with no errors.

Run 7 still had a stall in its first 20 seconds, when response times climbed to about 15 seconds before settling well under one second. The rest of this section is about what broke in the other runs.

Run 1: more traffic than the server could hold

10,000 new clients per second at 25 KB each is about 257 MB/s, or roughly 2 Gbps of outgoing traffic. While connections waited, the kernel spent 61 seconds under TCP memory pressure (it had auto-sized its buffer limit to about 68 MB on this 1 GB machine), and the Nginx worker grew to about 1 GB, most of it pushed into swap. Response times climbed to 18 seconds and Loader.io aborted.

At the time I measured outgoing bandwidth with parallel uploads to Cloudflare’s speed test and got about 550 Mbps. Run 8 later averaged 6.61 GB in 60 seconds, about 880 Mbps, so that upload test underestimated what the VPS can send. In either case, 2 Gbps was out of reach.

Run 4: Cloudflare blocked the test

With the DNS record switched to proxied and a cache rule for the site, Cloudflare served the homepage from its edge cache, with the fastest responses at 6 ms. Around five seconds in, Cloudflare’s HTTP DDoS protection classified Loader.io as an attack and returned 403 for the rest of the run. Cloudflare’s analytics showed the rule source as l7ddos, and only 6 requests reached the VPS.

The zone already had a WAF custom rule that skips security checks for the loader.io user agent. That rule does not cover the DDoS protection. On the Free plan, the HTTP DDoS ruleset allows one override, and it applies to every request in the zone, so loosening it for a test also loosens it for every other subdomain. I chose not to, and tested directly against the VPS instead.

Runs 5 and 6: the Nginx worker ran out of memory

The 0.7 KB /lite/ page should have removed the bandwidth problem. It did, but the server still ran out of memory. After run 6 the Nginx worker held about 840 MB (355 MB in RAM and 480 MB in swap). A once-a-second memory recorder in a later run showed how fast this happens: the worker went from 34 MB to 872 MB within ten seconds of Loader.io starting, and for those seconds Nginx could not answer a local status check within one second. The request log showed Nginx itself was never slow: every one of 387,524 logged Loader.io requests took 0 ms to process. The time went to waiting for memory pages to come back from disk.

I found two causes.

The first was a setting I had added: aio threads with directio. For files that already live in RAM it adds nothing, and it made Nginx allocate extra buffers per request. With 5,000 keep-alive connections, removing it lowered the worker’s peak memory from 63 MB to 35 MB and raised throughput from about 25,000 to 31,000 requests per second.

The second was HTTPS. With the same 5,000 keep-alive connections, the Nginx worker stayed at 35 MB over plain HTTP and reached 315 MB over HTTPS. That is about 56 KB of encryption state per open connection. Loader.io keeps around 20,000 connections open, which needs about 1.1 GB over HTTPS on a machine with 856 MB of RAM. Holding 20,000 idle HTTP keep-alive connections, for comparison, added only 9 MB to the worker. I did not log the protocol of runs 5 and 6, but their memory profile matched the HTTPS reproduction, and the HTTP reproduction never came close.

Before finding the HTTPS cause I also tried two other ideas, a shorter keepalive_timeout (65 down to 15 seconds) and telling glibc to return freed memory sooner. The malloc change made no measurable difference, so I removed it.

What Loader.io’s traffic looked like

A temporary access log during run 7 and the run before it showed:

  • about 20,000 connections open at the same time, from 5 Amazon IP addresses
  • an average of 7.6 requests per connection, a maximum of 498, and no pipelining
  • 143-byte requests, all answered with status 200
  • up to 11,002 requests per second at Nginx in a single second

Connections that never arrived

In run 6, the kernel counted about 45,600 new connections from Loader.io, while Loader.io reported about 62,800 attempts. The kernel’s listen queue never overflowed and the network card dropped no packets, so roughly 17,000 attempts never reached the server’s TCP stack. I could not tell whether they were lost in the provider’s network or never sent because Loader.io’s own client was saturated. The 15-second average response time fits clients retrying lost connection attempts, but this one stays unresolved.

Making the homepage lighter

Loader.io only downloads the HTML, but a real visitor downloads everything. I measured every file the homepage loads and cut the weight without changing how it looks.

BeforeAfter
HTML (gzipped)25.1 KB15.6 KB
HTML plus every CSS, font and image file852 KB287 KB
Images loaded before scrolling191
Font file (Mona Sans)164 KB47 KB
JavaScript20 KBnone

What changed:

  • Each image was resized to the size it is displayed at. The theme’s six avatar images were 1001 × 1000 px and display at 60 to 76 px; at 160 × 160 they went from 330 KB to 20 KB in total. The photo behind the stats banner sits under a 90% colour overlay, so it took heavier compression (38 KB to 17 KB).
  • Only the hero image loads immediately. The other 18 images get loading="lazy", and blog card images get a sizes hint so desktops pick the 768 px version instead of the full one.
  • Mona Sans is a variable font with weight and width axes. No page used the condensed or expanded widths, so I fixed the width at 100% with fonttools varLib.instancer and subset it to Latin characters with pyftsubset, keeping weights 200 to 900.
  • WordPress inlined about 40 KB of block CSS into every page. Setting the styles_inline_size_limit filter to 0 moved it into separate, cacheable CSS files. A first visit makes about 30 small extra CSS requests, which HTTP/2 handles well.
  • The emoji detection script went away, and so did the mobile hamburger menu, which loaded 42 KB of the Interactivity API. With five links, a menu that wraps onto a second line looks fine on a phone and needs no JavaScript.

Two of these edits change files inside the Ollie theme (images and the font), so a theme update would bring the heavy versions back. A child theme is the better long-term home for them.

Desktop view of the test WordPress site's homepage after the page-weight work, showing the header, hero heading and hero image
Mobile view of the test WordPress site's homepage after the page-weight work, with the menu links wrapping onto their own line instead of a hamburger menu

Conclusions

On this 1 vCPU, 1 GB VPS, an external Loader.io run reached 1,034,655 requests in one minute with zero errors for a small page (about 1 KB per response). For a real 25 KB homepage, the best clean external result among the runs in this post was 271,412 requests in one minute. So the answer to the title is yes, at least for a small cached page.

What limited this machine, in the order I ran into it:

  1. Outgoing bandwidth, for the full homepage at 10,000 new clients per second.
  2. RAM per open connection, especially over HTTPS at about 56 KB each. With 856 MB of RAM, a few thousand concurrent HTTPS clients are safe and 20,000 are not.
  3. My own aio threads setting, which made the memory problem worse and lowered throughput for files served from RAM.
  4. Cloudflare’s DDoS protection, which blocks a load test that looks like an attack and cannot be loosened for one subdomain on the Free plan.

To push the full homepage further on a machine this size, I would put a CDN’s edge cache in front, on a plan where DDoS sensitivity can be adjusted for test traffic, or give the origin more RAM and CPU if it must terminate HTTPS itself. I would also always run load tests from outside the server and check RAM, swap and the Nginx status page during the run, because a benchmark on the server itself hides exactly the problems that showed up here.

Appendix: the Nginx pieces that mattered

worker_processes auto;
worker_rlimit_nofile 262144;

events {
    worker_connections 65535;
    use epoll;
    multi_accept on;
}

http {
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 15;
    open_file_cache max=50000 inactive=60s;
    open_file_cache_valid 30s;
    gzip_static on;
    ssl_conf_command Options KTLS;

    fastcgi_cache_path /var/cache/wp-ram/fastcgi levels=1:2 keys_zone=WORDPRESS:32m
                       max_size=96m inactive=60m use_temp_path=off;

    server {
        listen 80 reuseport backlog=65535;
        listen 443 ssl reuseport backlog=65535;
        http2 on;

        location / {
            try_files $static_cache_root$cache_uri/index.html $uri $uri/ /index.php?$args;
        }
    }
}

$static_cache_root comes from a map that points to the RAM-disk cache only for anonymous GET requests without a query string, and to a path that never exists otherwise, so those requests fall through to PHP.

More onWordPress

Need this built instead of read about?

Send me your project. I reply within 24 hours and tell you plainly whether I’m the right fit.

Send me your project

More on WordPress