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.com with 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-kuma

compose.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/London
docker compose up -d
docker compose logs -f

Wait for Listening on in the log. What matters here:

  • The tag. latest still points to 1.23.17, which no longer gets security fixes. The 2 tag 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.0 is 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.
Important: do the first login through an SSH tunnel. Whoever reaches the admin setup page first gets the account.
ssh -L 3001:127.0.0.1:3001 deploy@SERVER_IP

At 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 caddy

Nginx

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.com

Host 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 3001

ss 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 tcp

If 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
fi

This 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

  1. Send /newbot to @BotFather and get a token.
  2. 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.
  3. 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.
  4. 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 TZ overrides it on every container start. Keep TZ in 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 /metrics for 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.

Important: don't interrupt the migration. After an interruption Kuma won't start, failing with "Aggregate table migration is already in progress, or it was interrupted", and the only way out is to restore the backup and start over.
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 -f

The 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, 30d or 1y. 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 -delete

To 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 -d

Take 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 ping processes. 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 storage block, 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 Upgrade and Connection in nginx and WebSockets in Cloudflare. If Kuma logs Origin (...) does not match host, the proxy rewrites Host. Disabling the check with UPTIME_KUMA_WS_ORIGIN_CHECK=bypass is a last resort.
  • 502 from the proxy: the container isn't running or is still migrating the database; check docker compose ps and docker compose logs --tail=50.
  • DOWN while the site opens fine. timeout of ... exceeded means 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 chat means 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 curl doesn't reach Kuma; run it by hand.
  • Forgot the password: docker exec -it uptime-kuma npm run reset-password.

Sources