Uptime Kuma: self-hosted monitoring setup for websites and APIs
Uptime Kuma self-hosted monitoring setup: Docker on a separate VPS, your own domain with TLS, HTTP and push monitors, Telegram alerts, status pages, the 2.x upgrade.
Current as of September 2026: Uptime Kuma 2.5.5; the 1.x branch no longer gets updates. The focus is on what breaks in production: false alarms, reverse proxies, the 2.x migration and growth.
What you'll need
- A separate VPS for monitoring. A few dozen monitors fit in 1 vCPU and 1 GB of RAM; sizing for 200 is below. ForestsNet Cloud deploys a VPS within minutes of payment, with locations in the Netherlands and Germany.
- Ubuntu 24.04 or Debian 12/13 with Docker and the Compose plugin, see Docker and Docker Compose.
- A subdomain such as
kuma.example.comwith an A record pointing to the server, see Pointing a domain at your server. - Basic hardening: SSH hardening and the ufw firewall.
Step 1. Decide where monitoring runs and what it checks
Not on the same server
Monitoring that shares a machine with your services goes quiet exactly when you need it: the server hangs or loses its network, Kuma goes down with it, and silence looks like "all good". Checks from inside also bypass the external network, DNS and the firewall. Put Kuma on a separate VPS, ideally in another location or with another provider.
Kuma itself needs watching too: a second instance elsewhere or an external dead man's switch. For example, add an HTTP monitor in Kuma that requests a Healthchecks.io check URL every minute: while Kuma is alive it keeps pinging, and when it stops, Healthchecks.io alerts you. The free plan covers 20 checks.
What to monitor
- User-facing entry points: the home page with a keyword, the login page, the API health endpoint.
- Expiry dates of TLS certificates and domains.
- Databases and internal services, reachable only from the monitoring IP.
- Background jobs via push: backups and cron jobs.
- Not here: Kuma doesn't collect CPU, memory or disk usage; that's what Beszel and Zabbix are for. The Docker Container monitor needs access to the Docker API, which is effectively root on the checked host, so it's awkward to use from a separate server.
Step 2. Install Uptime Kuma with Docker
mkdir -p /opt/uptime-kuma && cd /opt/uptime-kumacompose.yaml:
services:
uptime-kuma:
image: louislam/uptime-kuma:2.5.5-slim
container_name: uptime-kuma
restart: unless-stopped
ports:
- "127.0.0.1:3001:3001"
volumes:
- ./data:/app/data
environment:
TZ: Etc/UTC # replace with yours, e.g. Europe/Londondocker compose up -d
docker compose logs -fWait for Listening on in the log. What matters here:
- The tag.
lateststill points to 1.23.17, which no longer gets security fixes. The2tag tracks the newest 2.x, but pin a version: releases do regress, and 2.2.0 to 2.3.1, for example, sent false DOWN alerts because of SQLite locking. - The slim image. The full image takes about 1.7 GB on disk with MariaDB and Chromium for Browser Engine monitors; slim is about 0.5 GB.
- 127.0.0.1. A port published by Docker bypasses ufw: traffic is diverted in Docker's iptables chains before ufw rules apply, so a port on
0.0.0.0is open to the whole internet. - Keep the data directory on a local disk: on NFS, SQLite gets corrupted because of file locking.
- TZ is the timezone for maintenance windows, see step 7.
ssh -L 3001:127.0.0.1:3001 deploy@SERVER_IPAt http://localhost:3001 Kuma asks which database to use. SQLite is officially recommended for small deployments and in practice handles hundreds of monitors. Embedded MariaDB exists only in the full image and uses more memory. Choose now: moving from SQLite to MariaDB isn't officially supported.
Step 3. Add a domain with TLS and WebSocket
Kuma's interface runs over WebSocket, so the proxy must pass the Upgrade and Connection headers and keep Host intact. A subpath such as example.com/kuma isn't supported; you need a domain or subdomain.
Caddy
Caddy obtains a Let's Encrypt certificate on its own, passes WebSocket through and sets the X-Forwarded-* headers; installation is covered in Caddy: automatic HTTPS. In /etc/caddy/Caddyfile:
kuma.example.com {
reverse_proxy 127.0.0.1:3001
}sudo systemctl reload caddyNginx
Start from Nginx: install and your first site; the certificate comes from Free SSL with Let's Encrypt.
server {
listen 80;
server_name kuma.example.com;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
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;
}
}sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d kuma.example.comHost is mandatory: since 1.23.9 Kuma compares the WebSocket Origin with Host and rejects mismatches, logging Origin (...) does not match host. Use 127.0.0.1 in proxy_pass, not localhost: on Debian that name also resolves to ::1, and nginx fills its log with errors.
In Kuma, enable Settings → Reverse Proxy → Trust Proxy to see client IPs rather than the proxy's, and set Settings → General → Primary Base URL to https://kuma.example.com: push monitor URLs are built from it. Behind Cloudflare, turn on the WebSockets toggle under Network.
Step 4. Close direct access and enable 2FA
sudo ufw default deny incoming
sudo ufw allow 22/tcp # or your SSH port
sudo ufw allow 80,443/tcp
sudo ufw enable
sudo ss -tlnp | grep 3001ss should show 127.0.0.1:3001, and curl -m 5 http://SERVER_IP:3001 from another machine should time out or be refused. Then enable 2FA under Settings → Security: the login form is always reachable from the internet, including on the status page domain.
Step 5. Set up your first monitors
HTTP monitor settings that remove most false alarms:
- Heartbeat Interval: 60 seconds.
- Retries: 2–3. In 2.x new monitors default to 0, so a single lost check sends DOWN right away. While retrying, the monitor is PENDING and sends nothing.
- Heartbeat Retry Interval: 20–30 seconds, to confirm a failure faster.
- Request Timeout: 10–20 seconds instead of the default 48, which is 80% of the interval.
HTTP with a keyword
HTTP(s) - Keyword requires a 2xx response and a given string in the body, which catches application error pages and placeholder pages that also return 200. The search runs over the raw HTML or JSON and is case-sensitive, and in an SPA the visible text only appears after JavaScript runs, so pick a string from <title>. Invert Keyword checks that a string is absent; for APIs there's HTTP(s) - Json Query with a JSONata expression. Don't enable Ignore TLS/SSL errors: it also turns off certificate expiry notifications.
Certificate and domain expiry
The Certificate Expiry Notification checkbox is off by default in HTTP monitors, so turn it on. How many days ahead to warn is set in Settings → Notifications → TLS Certificate Expiry: 7, 14 and 21 days by default. Since 2.1 the TCP Port monitor checks certificates too: IMAPS, plus STARTTLS for SMTP, IMAP and XMPP.
Since 2.1 Kuma also checks domain expiry via RDAP, and Domain Name Expiry Notification is on by default. But ccTLDs such as .de, .eu, .io and .ru have no entry in the IANA RDAP registry as of September 2026, so the check doesn't work for them: keep such domains on auto-renewal.
TCP for databases
TCP Port only shows that the port accepts connections. The PostgreSQL and MySQL/MariaDB monitors run a query, SELECT 1 by default. Give them a least-privilege user and open the database port only to the monitoring IP:
sudo ufw allow from 203.0.113.10 to any port 5432 proto tcpIf the database runs in Docker with a published port, ufw won't close it, for the same reason as in step 2. Publish that port on an internal interface such as WireGuard.
Push for cron jobs
A push monitor works the other way round: the job calls Kuma's URL itself, and if no report arrives within the Heartbeat Interval, you get an alert. For a nightly backup, set the interval to 25 hours (90000 seconds) and Retries to 0:
#!/bin/sh
PUSH="https://kuma.example.com/api/push/TOKEN"
if /usr/local/bin/backup.sh >>/var/log/backup.log 2>&1; then
curl -fsS -m 10 --retry 3 "$PUSH?status=up&msg=OK" >/dev/null
else
curl -fsS -m 10 --retry 3 "$PUSH?status=down&msg=backup_failed" >/dev/null
fiThis catches both a failing script and cron not running it at all. The script and schedule are covered in Scheduled backups with cron and Database backups.
Step 6. Set up Telegram notifications
- Send
/newbotto @BotFather and get a token. - For a private chat, send the bot
/start. In a group, send/start@your_bot: with privacy mode on, the bot may not see ordinary messages. In a channel, make the bot an admin. - In Settings → Notifications → Set Up Notification choose Telegram, paste the token and click Auto Get: Kuma takes the ID of the latest chat from
getUpdates. Group IDs are negative, supergroup IDs start with-100, and forum topics use the Message Thread ID field. - Click Test. Default enabled attaches the channel to new monitors, Apply on all existing monitors to the existing ones.
Auto Get calls Telegram from your browser. If it fails, take chat.id from the output of curl -s https://api.telegram.org/botTOKEN/getUpdates run on the server.
How not to spam yourself
- Messages go out only on state changes: UP → DOWN, DOWN → UP and DOWN after a maintenance window. A monitor that is PENDING while retrying stays silent.
- Resend Notification if Down X times consecutively defaults to 0, one message per incident. A value of 30 with a 60-second interval gives a reminder every half hour.
- Group a server's checks under a Group monitor and attach notifications to it: when the server goes down, you get one message listing the failed checks instead of ten.
- Run planned work through maintenance windows, and send minor checks to a separate chat or with Send Silently on.
Step 7. Create a status page and maintenance windows
Status page
In Status Pages → New Status Page set a name and slug; the page lives at /status/slug. Add only public-facing monitors. Incidents are posted with Create Incident, and since 2.1 their history is kept.
For a separate domain such as status.example.com, add it under Domain Names and to the proxy with the same reverse_proxy. The proxy must pass Host, or Kuma can't tell which page to show. Statuses on the public page are cached for up to a minute.
Maintenance windows
In Maintenance → Schedule Maintenance pick the monitors, status pages and a strategy: manual, single window, cron expression, interval, days of the week or of the month. During the window monitors turn blue, notifications stop and the status page shows a banner. If the service is still down when the window ends, you get a DOWN.
Timezones have traps:
- Kuma takes the server timezone from the admin's browser at first login, and
TZoverrides it on every container start. KeepTZin compose and Settings → General → Server Timezone identical, or a timezone change in the UI reverts after a restart. - Set each window's timezone explicitly instead of "same as server".
- Open bugs as of September 2026: time-of-day windows ignore daylight saving time (#6233), and the window list shows times in the browser's timezone (#7895). After a clock change, check your windows in the edit form.
Step 8. Move from 1.x to 2.x
What's new in 2.x
- Aggregates instead of raw history. Detailed checks are kept for about a day, then per-minute stats for 24 hours, hourly stats for 30 days and daily stats for the whole retention period. Dashboards are faster and the database no longer grows to gigabytes. Retention is set in Settings → Monitor History: 365 days on new installs, the old value after migration.
- MariaDB, embedded or external, plus slim and rootless images.
- New monitors: SNMP, SMTP, SFTP, NTP, RabbitMQ, WebSocket Upgrade and Globalping, which runs checks from third-party probes in a chosen country, up to 250 tests an hour without a token.
- Notifications: LiquidJS templates, a custom Bot API server for Telegram, a proxy for outgoing notifications via
NOTIFICATION_PROXY. - API: there's still no API for managing monitors (#118 has been open since 2021), only push, badges, status page JSON and
/metricsfor Prometheus with a key from Settings → API Keys.
Migration
On first start 2.x converts the history into aggregates. The project author's 20 monitors with 90 days of history took about 7 minutes; a long history on a slow disk takes hours.
cd /opt/uptime-kuma # the directory with compose.yaml
docker compose down
tar -czf kuma-v1-$(date +%F).tar.gz data # your /app/data directory
# in compose.yaml change the tag to louislam/uptime-kuma:2.5.5-slim
docker compose up -d && docker compose logs -fThe log shows [DON'T STOP] Migrating monitor ... lines with a percentage and ends with Aggregate Table Migration Completed. Also:
- If you don't need the old history, shorten the retention period in 1.23 and click Shrink Database before upgrading: there will be less to migrate.
- Rootless images aren't recommended for upgrading from 1.x.
- JSON backup export and the DNS cache for HTTP monitors are gone; in Docker, the bundled nscd takes over caching. SMTP template variables became case-sensitive, and the badge period accepts only values like
24,24h,30dor1y. Without Docker you need Node.js 20.4+.
Step 9. Set up backups and updates
The official backup in 2.x is a copy of the data directory. Don't copy the SQLite file while Kuma runs: the database is in WAL mode, and the copy may be corrupted. Let SQLite take the snapshot; sqlite3 is included in the image:
#!/bin/sh
set -e
D=/opt/uptime-kuma
mkdir -p $D/backup
rm -f $D/data/snapshot.db
docker exec uptime-kuma sqlite3 /app/data/kuma.db "VACUUM INTO '/app/data/snapshot.db'"
tar -czf $D/backup/kuma-$(date +%F).tar.gz -C $D/data --exclude='kuma.db*' .
rm -f $D/data/snapshot.db
find $D/backup -name 'kuma-*.tar.gz' -mtime +14 -deleteTo restore, stop the container, unpack the archive into an empty data, rename snapshot.db to kuma.db and start the same Kuma version. Move the archives off the server. Back up an external MariaDB with mariadb-dump.
To update, change the tag in compose.yaml and run:
docker compose pull && docker compose up -dTake a backup and read the release notes first. Run at least 2.5.5, which fixes a memory leak in TCP monitors.
How much hardware you need
Kuma is a single Node.js process and barely uses more than one core. Measurements from the project's issues: 200 HTTP monitors at a 60-second interval on 1.23 took about 130 MB of memory and 2–3% of two vCPUs, and about 250 monitors on 2.0 averaged under 400 MB.
- 20 monitors: 1 vCPU and 1 GB of RAM with headroom. Kuma takes 100–150 MB; the rest goes to the OS, Docker and the proxy.
- 200 monitors: 2 vCPUs and 2 GB of RAM, with the second core for the OS, the proxy and
pingprocesses. Embedded MariaDB or Browser Engine monitors with Chromium add hundreds of megabytes, so plan for 4 GB then. - Disk: the slim image is about 0.5 GB, the full one about 1.7 GB. Given the 2.x storage scheme, the database takes roughly 1 MB per monitor at a 60-second interval, while 1.x would have piled up about 9 GB for 200 monitors over six months. Use SSD or NVMe: on slow storage SQLite runs into locks.
- The ceiling. The monitor list isn't paginated: with 600–900 monitors the dashboard takes up to 8–15 seconds to load, and the maintainers say plainly that Kuma isn't built for thousands of monitors. Beyond that, split them across instances or use Gatus.
- Traffic is almost all inbound: Kuma downloads the pages it checks and sends only short requests and notifications. ForestsNet Cloud plans include only outbound traffic in the quota; inbound isn't counted.
Alternatives: Gatus and Beszel
Gatus
Gatus 5.37 is written in Go, and every check is defined in YAML, so the config lives in git and goes through review with the code. Conditions are expressions such as [STATUS] == 200, [RESPONSE_TIME] < 300 and [CERTIFICATE_EXPIRATION] > 240h. It supports Telegram and dozens of other alert channels, push via external-endpoints, maintenance windows, and SQLite or PostgreSQL storage. Differences from Kuma:
- monitors exist only in the config, not in the UI;
- without a
storageblock, history lives in memory and is lost on restart; - an alert fires after 3 failures in a row, and resolution messages are off by default (
send-on-resolved: false); - the config reloads on the fly, but edits may go unnoticed if you mount a single file instead of the directory.
Pick Gatus when monitoring should be deployed from a repository together with the rest of your infrastructure.
Beszel
Beszel 0.20 answers a different question: what's happening inside the server. The hub, built on PocketBase (port 8090), collects CPU, memory, disk, network, temperatures, SMART data and Docker and Podman container stats from agents, keeps history and sends threshold alerts. Agents accept the hub over SSH on port 45876 or connect to it themselves over WebSocket. Since 0.20 agents also run ICMP, TCP, HTTP and DNS network probes, but there are no status pages or content checks. A sensible pair: Kuma for availability from outside, Beszel for resources from inside.
For full infrastructure monitoring with templates and triggers, there's Zabbix.
Troubleshooting
- "Cannot connect to the socket server. Reconnecting…": the proxy doesn't pass WebSocket. Check
UpgradeandConnectionin nginx and WebSockets in Cloudflare. If Kuma logsOrigin (...) does not match host, the proxy rewritesHost. Disabling the check withUPTIME_KUMA_WS_ORIGIN_CHECK=bypassis a last resort. - 502 from the proxy: the container isn't running or is still migrating the database; check
docker compose psanddocker compose logs --tail=50. - DOWN while the site opens fine.
timeout of ... exceededmeans the site responds slower than the timeout or packets get lost. A 403 or 503 means a WAF blocks the requests, for example Cloudflare's Browser Integrity Check: allow the monitoring IP in IP Access Rules. Repeat the request from the container:docker exec -it uptime-kuma curl -sv https://example.com. - False DOWNs with
SQLITE_BUSY: database is locked: a regression in 2.2.0–2.3.1, so update. On a current version, look for a slow disk or a network filesystem. - Won't start after upgrading from 1.x, with an aggregate migration error: the migration was interrupted, so restore the backup.
- Container memory grows to gigabytes: in 2.1–2.5.4, TCP monitors with certificate checks left sockets open. Update to 2.5.5.
- Telegram returns
chat not found: nobody sent the bot/start, or the ID is wrong.group chat was upgraded to a supergroup chatmeans the group became a supergroup and needs the new ID starting with-100. - A push monitor goes red while the job works: the monitor interval is shorter than the job's period, or
curldoesn't reach Kuma; run it by hand. - Forgot the password:
docker exec -it uptime-kuma npm run reset-password.
Sources
- Uptime Kuma on GitHub: README,
compose.yamland the 2.5.5 source. - Uptime Kuma releases: 2.0.0-beta.0, 2.0.0, 2.3.2, 2.5.5.
- Uptime Kuma wiki: installation, reverse proxy, migrating from v1 to v2, Docker tags, environment variables, maintenance, status pages, internal API, sites behind Cloudflare.
- Uptime Kuma issues: SQLITE_BUSY — #7289, #7338; interrupted migration — #7274; memory leak — #7537, PR #7848; resources — #5884, #5568, #6181, #7079; API — #118; timezones — #6233, #7895; the latest tag — #6396.
- Docker: packet filtering and firewalls and Caddy: reverse_proxy.
- IANA RDAP bootstrap registry for domains.
- Gatus, v5.37.0 release; Beszel, environment variables, v0.20.0 release.
- Healthchecks.io pricing.