Uptime Kuma on a $9 VPS: What 60 Monitors Actually Cost in RAM, CPU and Disk
Published 6 September 2026. Every number below was measured on one live server we operate — a 2 vCPU, 7.8 GiB KVM VPS costing about $9 a month, already running five production sites — by deploying Uptime Kuma, pointing it at those five sites, scaling it to 60 monitors, and removing it afterwards. Docker went back to zero containers and zero images at the end.
1. The Number That Decides This
A hosted status page is sold per monitor and per page. A self-hosted one is paid for in server resources, and the only question that matters is how much of your VPS it eats. Here is the answer, measured rather than estimated:
| Monitors configured | RAM | CPU idle | CPU during check bursts |
|---|---|---|---|
| 0 (fresh install) | 104.7 MiB | 0.66% | — |
| 12, every 60 s | ~104 MiB | 0.68% | 7.2% |
| 60, every 60 s | 114 – 132 MiB | 0.7% | 23.2% |
Two things stand out. Going from zero monitors to twelve cost no measurable RAM at all — 104.7 MiB was the Node runtime floor, and twelve HTTP checks fit inside it. Going to sixty added roughly 11 MiB, which works out to about 0.19 MiB per monitor.
The RAM figure at 60 monitors is a range rather than a value because garbage collection is visible at this scale. Sampling every five seconds, the resident set climbed from 114 MiB to 131.6 MiB, dropped back to 119.7 MiB, and climbed again. If you alert on container memory, set the threshold above the top of that sawtooth or you will page yourself for normal behaviour.
On a 2 vCPU box, 60 monitors is not a capacity question. It is a rounding error against the 2,855 MiB the machine was already using for five production sites.
2. Deploying It Without Breaking What You Already Run
Our server already had nginx on ports 80 and 443 for five domains, and a Next.js application on 3000. Uptime Kuma’s documented port is 3001, which on this machine was also taken. That is the normal situation on a VPS that is already earning its keep, and it changes the deployment command:
docker volume create uptime-kuma-data
docker run -d --name uptime-kuma \
-p 127.0.0.1:3002:3001 \
-v uptime-kuma-data:/app/data \
--restart=unless-stopped \
louislam/uptime-kuma:1 note the 127.0.0.1: prefix on the published port. Docker writes its own iptables rules in a chain that is consulted before UFW, so a plain -p 3002:3001 is reachable from the internet even when ufw status shows the port denied. Binding to loopback and reverse-proxying through nginx is the only arrangement where your firewall rules mean what they say.
Pulling the image took 17.7 seconds on this connection. What it costs on disk is less obvious than it should be, because Docker reports three different answers:
| Where you ask | What it says | What it is |
|---|---|---|
docker images | 724 MB | Sum of uncompressed layers |
docker image inspect --format '{{.Size}}' | 149 MB | Under-reports; do not use |
du -sb /var/lib/containerd | 577 MB | The image, on disk |
du -sb /var/lib/docker | 423 MB | Container layer and volume |
| Both directories together | 1,000 MB | What you actually pay for |
Budget one gigabyte, not half of one. The trap is that docker info reports Docker Root Dir: /var/lib/docker, so measuring that directory looks like the rigorous thing to do — and it misses the image entirely. Docker 29 stores images through the containerd snapshotter, under /var/lib/containerd, and pulling an image adds precisely zero bytes to the directory Docker calls its root.
# Measure both, before and after, or the number is wrong
du -sb /var/lib/containerd /var/lib/docker we published this article with 537 MB in this table, taken from du -sh /var/lib/docker while the container ran. That figure was the container layer and volume only — the 577 MB image sat in a directory we were not measuring. Corrected the same day after a second deployment measured both paths. If you have been sizing Docker hosts from /var/lib/docker on Docker 29, check the other directory.
The first response is a 302, not a 200
Our first deployment script waited for HTTP 200 and timed out after two minutes on a container that had been healthy the whole time. A fresh Uptime Kuma with no user account redirects everything to /setup, so the correct readiness check accepts a redirect:
# Wrong: never succeeds on a fresh install
until [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3002/)" = "200" ]; do sleep 1; done
# Right: 302 is a healthy, un-configured instance
until curl -sf -o /dev/null http://127.0.0.1:3002/ --max-time 2 \
|| [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3002/)" = "302" ]; do
sleep 1
doneThe container’s own healthcheck reports healthy correctly, so docker ps would have told us sooner than curl did.
3. The Cost Nobody Mentions: Disk Growth
RAM and CPU are the figures every comparison quotes, and they are the ones that do not matter. Uptime Kuma writes one row per check per monitor, forever, and that is what eventually fills a small VPS.
We measured it directly: with 60 monitors running at a 60-second interval, we recorded the heartbeat count and the size of the data directory, waited 100 seconds, and measured again.
+103 heartbeats
+36,936 bytes
= 359 bytes per heartbeatExtrapolated at that rate:
| Period | Heartbeats | Disk |
|---|---|---|
| Per day | 88,992 | 30 MB |
| Per month | 2.7 million | 0.9 GB |
| Per year, unpruned | 32.5 million | 10.8 GB |
It does not actually reach 10.8 GB, because Uptime Kuma prunes old heartbeats. We read the default out of the running container rather than trusting documentation:
docker exec uptime-kuma grep -n 'DEFAULT_KEEP_PERIOD' /app/server/jobs/clear-old-data.js
# 5:const DEFAULT_KEEP_PERIOD = 180;180 days at 30 MB a day is a steady state around 5.4 GB. On a 50 GB VPS that is a tenth of the disk spent on history nobody reads. Lowering retention to 30 days brings it to roughly 900 MB, and doubling the check interval to 120 seconds halves the whole thing again.
no retention value exists in the database until you change it in the interface. The setting table on a fresh install contained only databasePatchedFiles, database_version and jwtSecret — the 180-day default lives in code, so nothing in the UI shows you what it is until you touch it.
The check interval is the lever with real leverage here, and it costs less than people assume. Sixty seconds is the default; most services do not fail in ways that a 120-second interval misses, and it halves both the disk growth and the CPU bursts.
4. What the Public Status Page Can Serve
A status page has an unusual traffic profile: it is quiet for weeks, then every customer loads it at once during the incident. So the number worth knowing is what it sustains under concurrency, not at rest.
We built a page with five of our production sites on it and benchmarked all three endpoints a visitor actually hits, with ApacheBench at concurrency 10:
| Endpoint | Req/s | P50 | P95 | Failed |
|---|---|---|---|---|
/api/status-page/{slug} (config) | 1,112 | 8 ms | 19 ms | 0 |
/api/status-page/heartbeat/{slug} | 878 | 10 ms | 17 ms | 0 |
/status/{slug} (HTML shell) | 696 | 12 ms | 23 ms | 0 |
Put that against the benchmark we published for the same hardware: an uncached WordPress install on this machine served 19.9 requests per second per core. The status page serves 35 to 44 times that, because it is Node returning a 4.3 KB JSON document rather than PHP assembling a page from a database.
Nine hundred requests a second is more status page than almost anyone needs. During the load test the container sat at 120 MiB and its CPU had already returned to 0.68% by the time we sampled it. If your status page falls over during an incident, it will not be because of Uptime Kuma.
What the monitors measured about our own sites
Since the checks were pointed at real production endpoints, they produced a useful side result — the response times of five live sites on the same server:
| Endpoint | Response |
|---|---|
| car365.ma (Next.js under PM2) | 20 ms |
| stackrecipes.com home (WordPress, microcached) | 25 ms |
| capersmed.com (Django/Gunicorn) | 49 ms |
| vinaigredumaroc.com (Django/Gunicorn) | 90 ms |
| A WordPress article page, uncached | 257 ms |
The cached WordPress home page answers in 25 ms and an uncached article in 257 ms on the same install — a tenfold difference that only shows up when something measures it every minute. That is the argument for self-hosted monitoring in one line: it is not that it is cheaper, it is that you point it at whatever you want without counting monitors against a plan.
5. Putting It on a Public Subdomain
The container is bound to loopback, so nginx has to carry it. Uptime Kuma uses WebSockets for the live dashboard, which means the upgrade headers are not optional — without them the page loads and then never updates, which is the most common complaint about self-hosted Kuma behind a proxy.
server {
listen 443 ssl http2;
server_name status.example.com;
ssl_certificate /etc/letsencrypt/live/status.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/status.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
# Required: without these the dashboard renders but never refreshes
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# The dashboard holds a socket open; the default 60s read timeout
# closes it and the UI reconnects in a loop.
proxy_read_timeout 3600s;
}
}a status page that runs on the server it monitors reports “all systems operational” right up to the moment that server dies, and then reports nothing at all. This is not a configuration mistake you can fix — it is inherent to self-hosting. If the status page must survive the outage it announces, it belongs on a different machine, ideally a different provider. The cheapest honest arrangement is a second small VPS elsewhere running only Uptime Kuma.
We benchmarked against the loopback port directly rather than publishing a status subdomain on this server, because the point of the exercise was the resource cost, and adding a public service to a machine hosting five production sites was not worth it for a measurement.
6. What It Replaces, and What It Does Not
Hosted status page and monitoring services are priced per monitor, per status page, or per seat, typically in the tens of dollars a month once you pass a free tier. We did not sign up for any of them to write this, so we are not going to quote prices we have not paid — check the current pricing yourself, because it changes.
The structural difference is what matters and it does not change with the price list. A hosted plan charges per monitor; a server charges per server. At twelve monitors the two are comparable. At sixty, we measured the marginal cost of a monitor at 0.19 MiB of RAM and 359 bytes per check — the plan gets more expensive and the VPS does not notice.
What you give up is real and worth stating plainly:
- Independence from your own infrastructure. The single biggest feature of a hosted status page is that it is not hosted by you. Self-hosting on the monitored server gives that away entirely.
- Checks from multiple regions. One VPS checks from one place. A site that is down only for European users looks perfectly healthy from a Moroccan server.
- Someone else’s on-call. Notification delivery, SMS gateways and escalation are your problem now.
The honest recommendation is the boring one. Self-host it on a different cheap VPS from the one it watches. That keeps the per-monitor economics, removes the fatal flaw, and still costs less than most hosted plans — you are buying a second small server, not a subscription that scales with how much you want to know.
7. Frequently Asked Questions
How much RAM does Uptime Kuma need?
We measured 104.7 MiB with no monitors configured and 114 to 132 MiB with sixty monitors checking every 60 seconds, on a 2 vCPU VPS. The marginal cost is roughly 0.19 MiB per monitor, so the Node runtime floor dominates. Any VPS with 1 GB of RAM runs it comfortably alongside other services.
How much disk space does Uptime Kuma use over time?
Each heartbeat costs 359 bytes in our measurements. Sixty monitors at a 60-second interval write about 89,000 heartbeats a day, or 30 MB. The default retention is 180 days, giving a steady state near 5.4 GB. Halving the check interval or cutting retention to 30 days reduces that proportionally.
Can Uptime Kuma handle traffic during an outage?
Its status page served 696 requests per second and its heartbeat API 878, at concurrency 10 with no failed requests and a 95th percentile under 25 milliseconds. For comparison, uncached WordPress on the same hardware managed 19.9 per core. Serving the page is not the risk; the server hosting it going down is.
Should the status page run on the server it monitors?
No. A status page on the monitored machine goes down with it, exactly when people need it. This cannot be fixed with configuration. Run Uptime Kuma on a separate cheap VPS, ideally with a different provider, so an outage at one host does not silence the page announcing it.
Why does Uptime Kuma return 302 instead of 200 after install?
A fresh instance with no account redirects all requests to its setup page, so a readiness check waiting for HTTP 200 never succeeds even though the container is healthy. Accept a 302 as ready, or use the container’s built-in Docker healthcheck, which reported healthy correctly throughout our deployment.
8. The Short Version
- 104.7 MiB idle, 114–132 MiB with 60 monitors. RAM is not the constraint.
- 1.0 GB of disk in total (577 MB image + 423 MB container), plus 30 MB a day of heartbeats at 60 monitors.
- Default retention is 180 days, which settles around 5.4 GB. Lower it.
- Bind the container to 127.0.0.1 — Docker’s firewall rules bypass UFW.
- Run it on a different server from the one it watches, or it fails silently when it matters.
If you are weighing what else fits on the same machine, our capacity model measured across seven applications covers the request-per-core figure this article compares against, and the n8n deployment guide covers the same Docker-behind-nginx pattern for a heavier application.