log in
consulting hosting industries the daily tools about contact

How Much Traffic a $40 VPS Actually Handles (Real Numbers)

Everyone assumes you need Kubernetes and auto-scaling. I've been running production Laravel apps on single VMs for years. Here's what the numbers actually look like.

The reflex in this industry is to reach for complexity before you've earned it. I've watched clients spend $800/month on managed Kubernetes clusters running apps that serve 200 visitors a day. I run production Laravel apps — real ones, for paying clients — on $40 VMs, and they handle more than most people expect.

This is not a "VPS vs cloud" think piece. It's just the actual numbers from actual servers, with the caveats that matter.

The Stack

Every NWOS-managed app starts on the same baseline:

  • VPS: 4 vCPU / 8 GB RAM / 160 GB NVMe SSD — runs about $40–48/month depending on provider (I use DigitalOcean and Vultr mostly, occasionally Hetzner for EU clients)
  • OS: Ubuntu 22.04 LTS
  • Web: nginx 1.24
  • App: PHP 8.2 + PHP-FPM (OPcache on, JIT off for most apps)
  • DB: MariaDB 10.11
  • Cache: Redis 7.x (sessions, cache, queues)
  • App: Laravel 10 or 11

Nothing exotic. No tuning wizardry. Just sensible defaults plus a handful of config changes I'll show below.

What "$40 VPS" Actually Means in Practice

I'm not talking about a hobby site or a landing page. I'm talking about:

  • A regional e-commerce store doing $2M/year in GMV
  • A healthcare portal with ~1,200 active patients and daily appointment booking
  • A real estate search app pulling live MLS data
  • A print management SaaS with 40–60 concurrent users during business hours

All of these run on single VMs in this price range. None of them have ever needed horizontal scaling.

The Actual Numbers

I ran wrk against a staging clone of the print management app — a reasonably complex Laravel app with authenticated routes, DB reads, Redis cache, and some light queue work happening in the background.

Test conditions

  • Clean VM, no other load
  • Warmed OPcache
  • Authenticated session via cookie (hitting a real controller, not a health-check endpoint)
  • Page renders a dashboard with 3 DB queries, 2 Redis reads, blade template with ~15 partials
  • wrk -t4 -c50 -d30s https://staging.example.com/dashboard

Results (sustained 30 seconds, 50 concurrent connections)

Running 30s test @ https://staging.example.com/dashboard
  4 threads and 50 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    48.3ms   12.1ms  210.4ms   88.42%
    Req/Sec   261.4     28.7    340.0     72.3%
  31,284 requests in 30.06s, 142.3MB read
Requests/sec: 1,040.67
Transfer/sec: 4.73MB

~1,040 requests/second on a real Laravel route. P99 latency under 220ms. That's not a static file — that's PHP executing, hitting MariaDB, reading from Redis, and rendering Blade.

What happens at 200 concurrent connections

Running 30s test @ https://staging.example.com/dashboard
  4 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   198.7ms   84.3ms    1.12s   78.11%
    Req/Sec   253.1     41.2    380.0     68.9%
  30,212 requests in 30.09s, 137.5MB read
Requests/sec: 1,003.9
Transfer/sec: 4.57MB

Throughput barely moves — still ~1,000 req/sec — but latency climbs. P99 hits over a second at 200 concurrent. That's the ceiling starting to show. You're not crashing, but users feel it.

The Config That Gets You There

The defaults that ship with most nginx and PHP-FPM installs are conservative. Here's what I actually deploy.

nginx — /etc/nginx/nginx.conf (relevant bits)

worker_processes auto;
worker_rlimit_nofile 65535;

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

http {
    keepalive_timeout 30;
    keepalive_requests 1000;
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;

    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml;
    gzip_min_length 1024;

    fastcgi_cache_path /var/cache/nginx levels=1:2
        keys_zone=LARAVEL:100m inactive=60m;
    fastcgi_cache_key "$scheme$request_method$host$request_uri";
}

PHP-FPM pool — /etc/php/8.2/fpm/pool.d/www.conf

This is the one people get wrong most often. Default pm = dynamic with max_children = 5 will strangle you.

pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500

; Catches runaway scripts
request_terminate_timeout = 30s

On 8GB RAM with MariaDB and Redis also on the box, 50 max children is about right. Each PHP-FPM worker at idle uses ~25–30 MB. Under load with a typical Laravel app you're looking at 40–60 MB per worker. 50 workers * 55 MB = ~2.75 GB for PHP alone. Leaves headroom for MariaDB's buffer pool and Redis.

MariaDB — innodb_buffer_pool_size

[mysqld]
innodb_buffer_pool_size = 2G
innodb_buffer_pool_instances = 2
innodb_flush_log_at_trx_commit = 2
query_cache_type = 0

innodb_flush_log_at_trx_commit = 2 is a tradeoff — you can lose up to one second of transactions on a hard crash. For most of the apps I run, that's acceptable and it meaningfully reduces I/O. For the healthcare portal it stays at 1 (full ACID). Know your requirements.

OPcache — php.ini

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.revalidate_freq=0
opcache.validate_timestamps=0

validate_timestamps=0 means PHP never checks disk to see if a file changed. On production that's fine — you clear the cache on deploy. On staging, set it to 1 or you'll go insane wondering why your code changes aren't showing up.

The Gotchas That Will Bite You

Queue workers eat memory over time. I've had Laravel queue workers balloon from 60 MB to 400+ MB over 48 hours without --max-jobs set. Supervisor config:

[program:laravel-worker]
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-jobs=500 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
numprocs=4

--max-jobs=500 and --max-time=3600 restart the worker gracefully before it gets fat. Four workers is plenty for most queues on this hardware.

MariaDB connections are the real ceiling, not CPU. When I profiled the e-commerce app during a flash sale, CPU was at 40%, but we were hitting max_connections. Default is 151. I bumped it:

max_connections = 300
wait_timeout = 60
interactive_timeout = 60

Combined with Laravel's database connection pool staying sane, that solved it. But watch SHOW STATUS LIKE 'Threads_connected' under load — it tells you more than CPU graphs.

Redis maxmemory policy. If you're using Redis for both sessions and cache and you don't set a policy, Redis will refuse writes when it's full and your sessions will break in a confusing way. I always set:

maxmemory 1gb
maxmemory-policy allkeys-lru

Sessions survive because they get touched frequently. Old cache keys get evicted. Everyone's happy.

Swap will kill your latency. If the VM starts swapping, response times go from 50ms to 2 seconds and you won't immediately see why. I keep vm.swappiness=10 in /etc/sysctl.conf and monitor RSS closely. If I'm consistently over 85% RAM, that's when I look at moving DB to its own node — not before.

When I'd Reach for This (and When I Wouldn't)

This setup is right for:

  • B2B SaaS with predictable, business-hours traffic patterns
  • Regional e-commerce — even high-revenue stores, if their concurrent user count is in the hundreds not thousands
  • Healthcare, real estate, professional services portals
  • Any app where you can answer "how many people are realistically online at the same time?" and the answer is under 150

I'd move off single-VM when:

  • The DB needs its own I/O budget. Writes are heavy, reads are heavy, and they're fighting each other. Separate the DB first — a $20 managed DB node is the first horizontal move I make.
  • You have a traffic pattern you can't predict or absorb. A product that ends up on national TV. An API that gets resold. If a spike could put you on the front page for being down, you need burst capacity.
  • Your team deploys frequently and can't tolerate the 10-second PHP-FPM reload window. (Though blue-green on a single host with two document roots is a real option I've used.)
  • Compliance requires isolation. Some healthcare contracts want DB on separate infrastructure. Fine — that's a business requirement, not a performance one.

I have not yet had a client in my target market outgrow a properly tuned single VM before outgrowing their product-market fit first. That's the honest truth.

Closing

A $40 VPS running nginx, PHP-FPM, MariaDB, and Redis will serve a thousand real Laravel requests per second at under 50ms average latency. That is more than enough for most of the software businesses I work with. The complexity tax of distributed infrastructure is real and expensive — in time, in money, and in the bugs that only appear in production at 2 AM. Earn the complexity first.

Need help shipping something like this? Get in touch.