Self-Hosted SaaS Architecture: A Capacity Model Measured Across Seven Applications
Published 6 September 2026. Every figure in this guide was measured on one live server we operate, which currently runs five production sites across four different stacks. No number here is an estimate.
1. One Number Governs Every Sizing Decision
We benchmarked seven applications on identical hardware over the past week: WordPress, a minimal Laravel route, Invoice Ninja, n8n, Shlink, Cal.com and Easy!Appointments. Different languages, different frameworks, different databases. The results cluster far more tightly than the marketing does.
| Application | Stack | Req/s per vCPU | What limits it |
|---|---|---|---|
| n8n 2.37 (webhook) | Node.js | 11.4 | Single event loop |
| Cal.com | Next.js | 16.4 | Application CPU |
| Invoice Ninja 5.13 | PHP/Laravel | 19.1 | Application CPU (675 routes) |
| WordPress 7.1, uncached | PHP | 19.9 | Application CPU |
| Shlink 5.1 (redirect) | PHP | 22.8 | PostgreSQL writes |
| Easy!Appointments | PHP | 43.0 | Application CPU |
| Laravel 13, minimal route | PHP | 190.8 | Nothing real |
| WordPress + nginx microcache | PHP | 749.6 | Network |
Six of the eight rows sit between 11 and 43 requests per second per core. The two outliers are instructive rather than exceptional: the Laravel figure is a synthetic route with two endpoints and no middleware, and the WordPress figure is a cached response where PHP never executes at all.
Plan for about 20 requests per second per core for any real application, whatever it is written in. That single number, applied honestly, prevents the most common self-hosting mistake, which is provisioning from a framework benchmark and discovering the gap under load.
The corollary: caching is the only lever with an order of magnitude in it
The same WordPress install moved from 19.9 to 749.6 requests per second with an nginx FastCGI microcache — a factor of 37 — for about twenty minutes of configuration and zero additional cost. No hardware upgrade available at any price does that.
The constraint is what can be cached. A public page shared between visitors caches perfectly. An authenticated dashboard, a cart, a booking form cannot, which is exactly why Cal.com, Invoice Ninja and n8n all sit near 20 req/s and stay there. Before sizing anything, work out what fraction of your traffic is shareable, because it decides whether you need one core or four.
2. What Actually Fits on One Server
The machine producing these benchmarks is a 2 vCPU, 7.8 GiB KVM VPS costing roughly $9 a month. At the moment of writing it runs, simultaneously:
- Five nginx server blocks serving five unrelated production domains
- Three Django applications under Gunicorn, each with its own systemd unit and unix socket
- One Next.js application and one Node API under PM2
- One WordPress install on a dedicated PHP-FPM pool
- MySQL, PostgreSQL 16 and Redis 7, all bound to loopback
- Docker, idle, used for temporary evaluation stacks
| Resource | In use | Available | Headroom |
|---|---|---|---|
| RAM | 2,531 MB | 7,940 MB | 68% |
| Swap | 0 MB | 2,047 MB | untouched |
| Disk | 15 GB | 96 GB | 84% |
| Load average | 0.40 | 2.00 (2 vCPU) | 80% |
Five production sites, four stacks, three databases, at a third of the memory and a sixth of the disk. That is the actual answer to “how much can one small VPS hold”, and it is considerably more than the hosting industry’s per-site pricing implies.
Memory, measured per component
| Component | Measured | Note |
|---|---|---|
| PHP-FPM worker (WordPress) | 45.5 MB | Average across 10 live workers |
| PHP-FPM worker (Laravel) | 33.1 MB | Average across 11 live workers |
| Gunicorn, three Django apps | 637 MB | Combined |
| Next.js server | 213 MB | Single process under PM2 |
| Node API | 176 MB | Single process under PM2 |
| MySQL 8 | 450 MB | Mostly the default InnoDB buffer pool |
| PostgreSQL 16 | 183 MB | Serving three applications |
| n8n container, idle | 355 MB | Measured during evaluation |
| Cal.com container, idle | 1,050 MB | Measured during evaluation |
Two entries deserve attention when you budget. MySQL’s 450 MB is largely a default buffer pool you can tune down, whereas Cal.com’s gigabyte is a Node.js process and is not recoverable by configuration. When a component’s footprint is a default it is negotiable; when it is the runtime, it is not.
# The sizing arithmetic, from measured values
#
# PHP workers = pm.max_children x measured_RSS
# = 8 x 45.5 MB = 364 MB per PHP application
# Database = innodb_buffer_pool_size + ~100 MB overhead
# OS + nginx = ~400 MB
# Headroom for spikes and a composer install = 1 GB minimum
#
# For a 2-vCPU box the CPU ceiling arrives long before the memory one:
# 8 workers cannot execute concurrently on 2 cores, so raising
# pm.max_children converts a queue into a longer queue with the same
# throughput and worse latency.The five layers, and who owns each
A self-hosted stack is five layers deep. Most guides describe the middle one and skip the two that cause outages.
| Layer | What runs there | Fails as | Cheapest mitigation |
|---|---|---|---|
| 1. Edge | DNS, TLS termination, reverse proxy, rate limiting | Expired certificate, DNS pointing at nothing | certbot renew --dry-run from cron |
| 2. Cache | FastCGI or proxy page cache, CDN | Stale content after a deploy | Purge on publish, not a shorter TTL |
| 3. Application | PHP-FPM pools, Gunicorn, Node processes | Saturated pool that queues silently | One pool per app, alert on P95 |
| 4. Data | MySQL, PostgreSQL, Redis, uploaded files | Disk full, unrestorable backup | Quarterly restore test |
| 5. Outbound | Transactional email, webhooks, API calls | Mail in spam, nobody notices | Relay with SPF, DKIM, DMARC |
Layer 5 is the one that produces failures nobody reports, because its symptom is silence: a password reset that never arrived, a booking confirmation in a spam folder, a webhook a partner stopped receiving. Nothing errors. Treat outbound delivery as a monitored dependency rather than an afterthought.
Layer 2 has a failure mode worth stating precisely, because we walked into it ourselves. A page cache with a ten-minute TTL and no purge-on-publish serves the previous version of your site for ten minutes after every deploy. On a blog that is a curiosity; on a stack where content arrives through automation it means new articles are invisible to whoever checks immediately after publishing. The fix is a purge hook on save, not a shorter TTL — a shorter TTL trades away the cache benefit to paper over a missing invalidation.
# Confirm which layer is answering a request before you tune anything
curl -s -o /dev/null -D - https://your-site.example.com/ \
| grep -iE "x-cache|x-fastcgi-cache|cf-cache-status|age:|server:"
# HIT -> you are measuring layer 2, and layer 3 is idle
# MISS -> you are measuring layer 3, which is the real capacity figure
# BYPASS -> a rule excluded this URL; check it is the rule you intendedConflating a cached and an uncached measurement is the most common way hosting benchmarks become meaningless, including your own. Measure both deliberately and label which is which.
3. The Isolation Model
Multi-tenancy on one box is only safe if a failure in one application cannot reach another. Three boundaries do almost all the work.
A process pool per application
# /etc/php/8.3/fpm/pool.d/app-one.conf
[app-one]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm-app-one.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 10s
pm.max_requests = 500
php_admin_value[error_log] = /var/log/php8.3-fpm-app-one.log
php_admin_flag[log_errors] = on
php-fpm8.3 -t && systemctl reload php8.3-fpmWithout a dedicated pool, a single slow endpoint in one site exhausts the shared worker budget and every other PHP site on the machine returns a timeout. With one, the damage stops at the application that caused it. The same principle applies to Python and Node: one systemd unit and one socket per application, never a shared process.
Data services on loopback, always
# Nothing but SSH and HTTP should be reachable from outside
ss -tlnp | awk 'NR==1 || $4 !~ /^(127\.|\[::1\])/'
# And verify from ANOTHER machine, which is the only test that counts
nmap -Pn -p 3306,5432,6379 YOUR_PUBLIC_IP # expect: filteredIf Docker is installed, UFW is not sufficient. Docker writes iptables rules in a chain consulted before UFW’s, so a container published as -p 5432:5432 is reachable from the internet while your firewall reports the port denied. Publish as 127.0.0.1:5432:5432 instead. This is the single most common way a self-managed server leaks a database, and it is documented behaviour rather than a bug.
Credentials scoped to one schema
# Per application, never GRANT ON *.*
mysql -e "CREATE DATABASE app_one CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -e "CREATE USER 'app_one'@'localhost' IDENTIFIED BY '$(openssl rand -base64 24)';"
mysql -e "GRANT ALL PRIVILEGES ON app_one.* TO 'app_one'@'localhost';"
# Audit what you actually granted
mysql -e "SELECT user, host FROM mysql.user WHERE user NOT IN ('root','mysql.sys');"
for u in $(mysql -N -B -e "SELECT user FROM mysql.user WHERE user NOT LIKE 'mysql%' AND user != 'root'"); do
echo "--- $u"; mysql -e "SHOW GRANTS FOR '$u'@'localhost';" 2>/dev/null | grep -c '\*\.\*'
done
# Any count above zero is an application that can read its neighbours.A scoped user is also why your backups need --no-tablespaces: the tablespace query requires a global PROCESS privilege the application has no reason to hold. Getting that error is a sign the boundary is correct, not that something is broken. The full deployment sequence is in our CodeCanyon deployment guide and, for Laravel specifically, our production setup guide.
4. Choosing What to Self-Host
The technical question is settled by section 1: almost anything fits. The economic question is harder, and its answer depends on a number most articles in this genre quietly set to zero — your own hourly rate.
Self-hosted total(m) = L + (V x m) + (H x rate x m)
Hosted total(m) = S x m
L = one-time licence, if any
V = server + backup storage, per month
H = maintenance hours per month
S = hosted subscription, per month
The breakeven in months: m = L / (S - V - H x rate)
If the denominator is negative, there is no breakeven and the hosted
option wins for as long as you keep paying yourself.| Scenario | Denominator | Verdict |
|---|---|---|
| One app, 1 h/month, rate $50 | 25 − 10.50 − 50 = −35.50 | Hosted wins outright |
| One app, 1 h/month, rate $0 | 25 − 10.50 − 0 = +14.50 | Self-hosting pays in 4 months |
| Four apps, 1.5 h/month total, rate $50 | 100 − 12 − 75 = +13.00 | Self-hosting wins |
| Per-seat SaaS, 20 seats | 3,840/yr vs 180/yr | Self-hosting wins decisively |
Two patterns emerge and they are the honest summary of this entire site. Self-hosting a single application, when you bill your time, is usually a worse deal than the subscription. It becomes compelling on amortisation: the fourth application on an existing server costs memory rather than money, and maintenance does not scale linearly with the number of applications. It also becomes compelling the moment the vendor prices per seat or per record and you have many of either.
Two reasons that never appear in the arithmetic and often decide it anyway: data residency, because a booking form or an invoicing tool holds customer names, addresses and amounts you may not want on a third party’s infrastructure; and the fact that a VPS runs anything, while a managed platform runs what it supports. Our Cloudways vs. Hostinger VPS comparison works through where each model wins.
Buy, self-host free, or subscribe
| Need | Open-source option we measured | Footprint | Guide |
|---|---|---|---|
| Workflow automation | n8n | 402 MB idle | Deployment guide |
| Appointment booking | Easy!Appointments / Cal.com | 27 MB / 1.05 GB | Comparison |
| Invoicing | Invoice Ninja | 756 MB | Buyer’s framework |
| Link management | Shlink | 144 MB | Buyer’s framework |
| Content and marketing site | WordPress | ~365 MB (8 workers) | Deployment guide |
| Custom application | Laravel | ~265 MB (8 workers) | Production setup |
A commercial marketplace licence earns its price in exactly one situation: a vertical the open-source option does not model. Salon chair rotations, rental deposits, paid directory listings. When the answer is “it has more features”, the free option usually wins once you have deployed both, which is the conclusion we reached after measuring them.
5. The Layer Where Things Actually Go Wrong
Server hardening defends a perimeter. It is cheap, it removes enormous noise — our machine logged 1,422 failed SSH attempts in a single week, 1,404 of them from one address, all defeated by key-only authentication — and it is not where incidents come from. The controls are in our Ubuntu 24.04 hardening guide.
Incidents come from the application, reached over port 443 that your firewall is obliged to allow. A live example from this week, on a Django site on this same machine.
A contact form that sent the attacker’s payload from our domain
A bot submitted 3,096 POST requests over 48 hours. Existing anti-spam rules rejected 2,951 of them; 145 got through, each carrying Cyrillic text and a fraudulent URL in the name field. The application then did this:
# The auto-reply, built by f-string
f"<p>Dear {inquiry.full_name},</p>"Unescaped. The server therefore sent an HTML email from our own domain containing the scammer’s link, to every address the bot supplied. The reputational exposure was not the bounces anyone noticed first; it was our domain relaying the fraud.
Four lessons generalise beyond this application, and each is a rule worth applying to anything you self-host.
- Any user value that reaches an outbound message must be escaped by a template engine, never interpolated. Django, Laravel and Jinja all autoescape by default; the vulnerability appears precisely when someone bypasses them with string formatting.
- No user value belongs in an email Subject header. A newline in a name is header injection.
- Rate limit at both layers. Application-level limiting still executes your interpreter. The 2,951 rejected requests all ran Python before being discarded; a reverse-proxy rule stops them earlier.
- Validate structure, not just vocabulary. A keyword blocklist is language-specific and permanently behind. Rules about shape — no URL in a name field, no writing system your customers do not use, no digits in a person’s name — held against 145 out of 145 real payloads with zero false positives on legitimate messages.
Deliverability is part of your architecture
Every self-hosted application in section 4 sends mail: password resets, booking confirmations, invoices, workflow notifications. A fresh VPS IP has no sending reputation and its mail lands in spam, which for a booking tool means appointments nobody knows about.
# Budget $10-15/month for a transactional relay, and verify three records
dig +short TXT example.com | grep spf
dig +short TXT resend._domainkey.example.com # your DKIM selector
dig +short TXT _dmarc.example.com
# Then send a real message and read the received headers, not the theory.One caveat we learned the hard way. If you relay through an authenticated consumer SMTP service, it rewrites the envelope sender with the authenticated account and ignores your Return-Path. Bounce-address settings have no effect there, and the only real fixes are a proper transactional relay or not sending the message at all.
6. The Operational Layer
Four things separate a server you own from a server that owns you. None is difficult; all are skipped.
Backups that have been restored
#!/bin/bash
# One script per application. Consistent, encrypted, off the machine.
set -euo pipefail
STAMP=$(date +%Y%m%d-%H%M)
DEST=/var/backups/app-one
install -d -m 700 "$DEST"
mysqldump --no-tablespaces --single-transaction --quick \
-u "$DB_USER" -p"$DB_PASS" "$DB_NAME" \
| gzip -9 \
| gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase-file /root/.backup-passphrase \
> "$DEST/db-$STAMP.sql.gz.gpg"
aws s3 cp "$DEST/db-$STAMP.sql.gz.gpg" s3://your-bucket/app-one/ --storage-class STANDARD_IA
find "$DEST" -name '*.gpg' -mtime +14 -delete
echo "$(date -Is) OK $(stat -c%s "$DEST/db-$STAMP.sql.gz.gpg")o" >> "$DEST/journal.log"--single-transaction for a consistent InnoDB snapshot without locking writes. Encryption before upload, so the storage provider never holds readable customer data. And the part that is not optional:
# Quarterly. Restore into a throwaway database and count rows.
gpg --batch --quiet --passphrase-file /root/.backup-passphrase \
-d /var/backups/app-one/db-*.sql.gz.gpg | gunzip | mysql restore_test
mysql restore_test -e "SELECT COUNT(*) FROM users;"
mysql -e "DROP DATABASE restore_test;"A backup job can exit zero every night for months while producing empty dumps, because the credentials lost a grant. Only a restore reveals it, and only if somebody performs one.
Certificate renewal, proven rather than assumed
certbot renew --dry-run
# Automate the proof, not just the renewal
0 4 1 * * certbot renew --dry-run || echo "CERT CHECK FAILED" | mail -s cert you@example.comAn expired certificate on a blog is embarrassing. On a booking page or a checkout it stops revenue, and it arrives ninety days after you last thought about it.
Monitoring from outside the machine
A health check running on the server reports healthy right up until the server is the problem. A free external uptime monitor hitting each public URL every five minutes is the highest-value addition to any self-hosted stack, and it costs nothing.
Alert on latency, not only on errors. Our load tests returned zero failed requests even at full saturation: a saturated PHP-FPM pool queues rather than erroring, so an error-rate dashboard stays green while users wait seconds. The P95 is the signal.
Knowing what changed
# Baseline when you believe the machine is clean
find /var/www -name '*.php' -type f -exec sha256sum {} + | sort -k2 \
> /root/baseline-$(date +%F).sha256
# Weekly, from cron
find /var/www -name '*.php' -type f -exec sha256sum {} + | sort -k2 > /tmp/now.sha256
diff /root/baseline-*.sha256 /tmp/now.sha256 || echo "files changed" | mail -s integrity you@example.comEvery other control describes the machine on the day you configured it. This one answers the question you are actually asked during an incident. The audit protocol for third-party code is in our security checklist, which includes the false-positive rates measured across 1.67 million lines.
When one server stops being enough
Three signals, in the order they usually appear. None of them is “the load average looked high once”.
Sustained P95 latency above your tolerance while throughput is flat. This is saturation, and it is the only one of the three that a bigger box fixes. Confirm it is real before spending: a load average above your core count for minutes, not seconds, and throughput that stops rising as concurrency does. Our measurements showed exactly that shape at concurrency 25 on every application tested.
One application’s incidents keep affecting the others. If a memory leak in a Node process is pushing the machine into swap, or a runaway import is starving every other site of CPU, the problem is not capacity but blast radius. Moving that one application to its own instance is cheaper than upgrading the shared one, and it fixes the actual failure.
Downtime has become expensive. A single VPS has no failover. Once an hour of outage costs more than a second server, the argument stops being about performance. That threshold arrives from the business side, not from a monitoring graph, and it is the one that genuinely ends single-server architecture.
# Before buying anything, check the four things that look like
# "we need a bigger server" and are not.
# 1. Is a cache actually serving? (worth 37x on shareable pages)
curl -s -o /dev/null -D - https://your-site/ | grep -i x-fastcgi-cache
# 2. Is the opcode cache enabled in the FPM pool, not just the CLI?
# Disabling it cost us 7.9x throughput on Laravel.
php-fpm8.3 -i 2>/dev/null | grep -E "^opcache.enable "
# 3. Is one process eating the machine?
ps -eo pid,rss,pcpu,comm --sort=-rss | head -8
# 4. Is the disk the bottleneck rather than the CPU?
# Sequential throughput is irrelevant; 4 KiB synchronous latency
# is what caps database commits.
dd if=/dev/zero of=/tmp/b4k bs=4k count=8192 oflag=direct,dsync; rm -f /tmp/b4kIn our own measurements those four checks accounted for every performance problem we found. The hardware was never the constraint: a disabled opcode cache cost a factor of 7.9, a missing page cache a factor of 37, and an unindexed write path made a redirect service database-bound rather than CPU-bound. Buying a larger instance would have masked all three at monthly cost.
7. Frequently Asked Questions
How many websites can one VPS host?
More than you expect. Our 2 vCPU, 7.8 GiB server runs five production sites across four stacks, three databases and a container runtime, at 32% memory and 16% disk with a load average of 0.40. The binding constraint on a small box is CPU, not memory, and it arrives around 20 requests per second per core.
How many requests per second should I plan for?
About 20 per core for any real application. We measured n8n at 11.4, Cal.com at 16.4, Invoice Ninja at 19.1, WordPress at 19.9 and Shlink at 22.8 on identical hardware, across Node, PHP and Next.js. Framework benchmarks showing 190 measure a route with no application behind it.
What single change improves performance most?
A reverse-proxy page cache, by a wide margin. The same WordPress install went from 19.9 to 749.6 requests per second with an nginx FastCGI microcache, a factor of 37 for twenty minutes of configuration. No hardware upgrade approaches it. The limit is that authenticated pages cannot be shared between visitors.
Is self-hosting actually cheaper than SaaS?
Only once you stop valuing your own time at zero, or once the server carries several applications. For one application at $50 an hour and one maintenance hour a month, the subscription usually wins. Self-hosting becomes decisive on amortisation across four applications, or when the vendor prices per seat and you have twenty.
Where do self-hosted incidents actually come from?
The application, not the perimeter. Our server logged 1,422 failed SSH attempts in a week, all defeated by key-only authentication. The real incident that week was a contact form interpolating unescaped user input into an outbound email, which made our own domain relay a scammer’s link over port 443.
8. The Reference Architecture
# --- Sizing, from measurement ---
[ ] ~20 req/s per core for any real application; 750+ if cacheable
[ ] PHP: pm.max_children x measured worker RSS (45 MB WP, 33 MB Laravel)
[ ] Node: budget 200 MB-1 GB per process; it is the runtime, not a default
[ ] Databases: tune innodb_buffer_pool_size before buying more RAM
[ ] Leave 1 GB headroom; a composer install under live traffic needs it
# --- Isolation ---
[ ] One PHP-FPM pool / systemd unit / socket per application
[ ] Databases and caches on 127.0.0.1, verified with nmap FROM OUTSIDE
[ ] Docker ports published as 127.0.0.1:PORT:PORT, never bare
[ ] Database grants scoped to one schema, never ON *.*
[ ] No PHP execution inside writable directories
# --- The layer that matters ---
[ ] Every user value in an outbound message rendered by a template engine
[ ] No user value in an email Subject header
[ ] Rate limiting at BOTH the proxy and the application
[ ] Validation on structure, not vocabulary: no URLs in short fields,
no writing systems your customers do not use, no digits in names
# --- Operations ---
[ ] Backup encrypted before upload, off the machine, and RESTORED quarterly
[ ] certbot renew --dry-run, monthly from cron, alerting on failure
[ ] External uptime monitoring, alerting on P95 latency not just 5xx
[ ] File integrity baseline, diffed weekly
[ ] unattended-upgrades active, proven with --dry-run
[ ] Every service restart: unless-stopped / systemctl enable, then REBOOT
and confirm everything came back
# --- Verify the whole thing in 90 seconds, monthly ---
sshd -T | grep -E "^(permitrootlogin|passwordauthentication)"
ss -tlnp | awk 'NR==1 || $4 !~ /^(127\.|\[::1\])/'
systemctl is-active nginx php8.3-fpm mysql redis-server fail2ban unattended-upgrades
certbot renew --dry-run
free -m; df -h /; uptimeThe reboot line is the one people skip and the one that decides whether your stack survives an unattended kernel update. A queue worker that does not come back fails silently, and you learn about it from the work that never happened.
If you measure a real application on comparable hardware and land somewhere other than 20 requests per second per core, we want the numbers. Send the ab output, the application and version, and your server specification, and we will extend the table in section 1 with measurements somebody else took.