SSD Nodes Learn
Guides Matt ConnorBy Matt Connor

Uptime Kuma: self-hosted status monitoring

Run Uptime Kuma in Docker to monitor websites, ports, DNS and cron jobs, alert via email or Telegram, and publish a status page from a separate VPS.

What you are building

A single small container that watches your other servers and websites from the outside and tells you the moment one stops answering, by email, Telegram, Discord or a webhook. Uptime Kuma is one Node process backed by a SQLite file, so it runs comfortably in 256-512 MB of RAM, and gives you a live dashboard, history graphs and a public status page. The install is a ten-line Compose file; the part that actually matters is where you run it and whether your alerts have ever fired in a test, because a monitor you never proved can reach you is worse than none at all: it makes you feel covered while watching nothing.

Run the monitor somewhere the outage cannot reach it

This one decision makes or breaks the whole thing, so it comes first. Do not run Uptime Kuma on the same box as the things it watches. If the monitor lives on the server it monitors, the exact event you care about, that box dying or running out of memory, kills the monitor too and you get no alert at all: silence from a dead monitor reads identically to "everything is fine." There is a subtler trap even while the box is alive: a monitor pointed at localhost shares the CPU with the workload, so a load spike makes its own check time out and flip the target to down, a false alarm, while real users are served fine.

So run Uptime Kuma on a different VPS from the one it watches, ideally a different provider or region, reaching your services the way your users do: over the public internet, by hostname. A cheap instance is enough, and one small monitoring VPS can watch all your servers. To catch a dead Kuma itself, add a push heartbeat from a cron elsewhere.

Prerequisites and sizing

  • A fresh Ubuntu 24.04 VPS with Docker Engine and the Compose v2 plugin, installed from Docker's own apt repository, not the docker.io distro package, which lags.
  • 256 MB RAM runs a handful of monitors; 512 MB to 1 GB is comfortable for dozens plus the reverse proxy, and CPU is near-idle between checks.
  • A domain and a DNS A record (say status.example.com pointing at the VPS), only if you want TLS and a public status page. A private instance can skip DNS and use a VPN or SSH tunnel.
  • Outbound network to wherever alerts go: SMTP to your mail provider, or HTTPS to Telegram and Discord.

The Compose file

Put this in /srv/uptime-kuma/compose.yaml.

services:
  uptime-kuma:
    image: louislam/uptime-kuma:2
    container_name: uptime-kuma
    restart: unless-stopped
    ports:
      - "127.0.0.1:3001:3001"
    volumes:
      - kuma-data:/app/data

volumes:
  kuma-data:

Bring it up and watch the first boot:

sudo mkdir -p /srv/uptime-kuma
# save the file above as /srv/uptime-kuma/compose.yaml, then:
cd /srv/uptime-kuma && sudo docker compose up -d
sudo docker compose logs -f uptime-kuma

A correct start logs Listening on 3001 and goes quiet. Three things in that file are deliberate.

127.0.0.1:3001:3001, not 3001:3001. Docker publishes ports with DNAT rules evaluated before ufw ever sees the packet, so a bare 3001:3001 puts your dashboard on the public internet regardless of your firewall. Binding to loopback keeps it private, with only the reverse proxy exposed; a private instance can skip the proxy and reach 3001 over a self-hosted WireGuard VPN instead.

A named volume at /app/data. Everything Uptime Kuma remembers, the SQLite database, your monitors, notification settings and status-page logos, lives there. Lose it and you start from an empty admin screen; it is the only thing you must back up.

The image is pinned to a major tag, :2. That is the current stable line; check Docker Hub for the newest major before copying it, and never track a moving tag like latest, which the project deprecates. A major-version jump on this image is a one-way database migration you want to trigger deliberately, not stumble into on a routine pull.

One caveat: /app/data must sit on a filesystem with POSIX file locks. A local Docker volume is fine; on NFS the SQLite database corrupts and you get SQLITE_BUSY and database disk image is malformed, so never use a network share.

First run: create the admin account

Browse to the instance through your proxy at https://status.example.com, or via an SSH tunnel: run ssh -L 3001:127.0.0.1:3001 user@your-vps and open http://localhost:3001. The first page is a setup form for the administrator username and password; there is no default login. Pick a real password: this dashboard sees the internal addresses and tokens of everything you monitor. Forgot it later? Reset from the host, not the browser:

sudo docker compose exec uptime-kuma npm run reset-password

Add your notification channels first, and test them

Set up alerts before adding monitors, so you can attach a channel as you create each one. Go to Settings then Notifications then Setup Notification, and use each channel's Test button to confirm the message lands, because an untested notification is the second most common way a setup silently fails.

Email (SMTP). Fill in host, port, encryption, username, password, a From and a To. The two working combinations are 465 with "Secure" set to TLS/SSL, or 587 with STARTTLS. For Gmail and most providers with two-factor auth you must generate an app password; a normal account password returns Error: Invalid login: 535-5.7.8 Username and Password not accepted.

Telegram. Message @BotFather, send /newbot, copy the bot token. For your chat ID, message the new bot once, open https://api.telegram.org/bot<token>/getUpdates, and read chat.id from the JSON. A bot you never messaged first has an empty getUpdates and nowhere to send.

Discord. In the channel, open Edit Channel then Integrations then Webhooks then New Webhook, copy the URL, and paste it as a Discord notification.

Generic webhook. For anything else, a Slack incoming webhook, a custom endpoint, a home-automation hook, the Webhook type POSTs a JSON payload to a URL you supply, and the bundled Apprise integration covers most of the ninety-odd other services on the list.

Add monitors, one type at a time

Click Add New Monitor, pick a type, and set the Friendly Name, the Check Interval (60 seconds is sane), the Retries (consecutive failures before "down"; 2 or 3 so one dropped packet is not a page), and the notifications to fire. The types you will use:

  • HTTP(s). A full URL. Up means an accepted status code (200-299 by default; widen it under Accepted Status Codes if 301 or 401 is normal for you). Your workhorse for websites and APIs.
  • HTTP(s) - Keyword. The same request, but "up" also requires a string present, or with Invert absent, in the body. This catches the site returning 200 OK while rendering "Error establishing a database connection", which a plain HTTP check calls healthy.
  • TCP Port. A bare TCP connection to a host and port, for things that are not HTTP: SSH on 22, Postgres on 5432, an SMTP server on 25, a game server.
  • Ping. ICMP echo: cheap reachability and latency. But many networks and cloud firewalls drop ICMP, so a red ping monitor can mean "host down" or "provider blocks ping"; confirm with a TCP monitor.
  • DNS. Resolves a record (A, AAAA, MX, TXT and so on) against a resolver you name, and can assert the answer, catching a registrar or DNS outage early.
  • Push. The inside-out monitor, covered next.

Monitoring a cron job with a push (heartbeat) monitor

Every monitor above reaches into your service from outside. A push monitor works the other way: Uptime Kuma waits, and your job calls it to say "I ran." It is the only honest way to watch a backup or cron: an HTTP check knows a URL responds, but only the job knows it completed.

Create a monitor of type Push. Uptime Kuma generates a unique URL like:

https://status.example.com/api/push/j8Xa2Kd9Qe?status=up&msg=OK&ping=

Set the Heartbeat Interval to how often the job runs, plus a little slack. Then add one line to the end of the script, so it fires only on success:

#!/usr/bin/env bash
set -euo pipefail
# ... your backup or job runs here; set -e aborts on any failure ...
curl -fsS --retry 3 "https://status.example.com/api/push/j8Xa2Kd9Qe?status=up&msg=backup+ok&ping="

If the job fails, set -e aborts before the curl; if the box is down, it never runs either. Either way the heartbeat stops, and once the interval-plus-retries window passes, Uptime Kuma flips the monitor to down and alerts you. Treat that push token as a secret: anyone with it can forge a healthy beat.

Build a public status page

A status page is the customer-facing view: which services are up and their recent history, without exposing your dashboard. Go to Status Pages then New Status Page, give it a name and a slug (the public path, such as /status/main), drag the monitors you want into groups like "Websites" and "APIs", add a logo and a short description, and Save. You can also bind the page to its own domain so status.example.com serves it directly.

Two cautions: only add monitors you are willing to make public, since a status page reveals that a service exists and whether it is up; and the dashboard stays behind your login while the status page is intentionally public and needs no auth.

Put it behind a reverse proxy with TLS, and mind the websockets

For a public instance, put a reverse proxy in front of the loopback-bound container for TLS and a hostname. The detail that trips everyone up: Uptime Kuma's UI is a live Socket.IO app, so the proxy must upgrade the WebSocket connection. Miss it and the page loads but never connects; the dashboard sits on "Connecting...", live heartbeats never update, and the browser console shows WebSocket connection to 'wss://.../socket.io/...' failed.

Install nginx and certbot, then write the vhost that proxies to the loopback port. Put it on port 80 for now and let certbot add TLS afterward; the challenge, renewal timer and its failure modes are covered in issuing Let's Encrypt certificates with certbot and nginx.

sudo apt install -y nginx certbot python3-certbot-nginx

Save this as /etc/nginx/sites-available/status.example.com; the two WebSocket lines are the ones that matter:

server {
    listen 80;
    server_name status.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;
        proxy_read_timeout 3600s;
    }
}

Enable the site, test the config, then let certbot rewrite the block to listen on 443, drop in the certificate and add an HTTP-to-HTTPS redirect:

sudo ln -s /etc/nginx/sites-available/status.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d status.example.com

The Upgrade and Connection "upgrade" pair is the whole ballgame, and proxy_read_timeout 3600s stops nginx tearing the long-lived socket down; certbot copies both into the 443 block it generates. If you already run several containers behind one proxy, routing them through Traefik with automatic TLS does the same with container labels and forwards WebSocket upgrades by default.

Do not basic-auth the whole vhost, because that also locks out the public status page and the /api/push endpoint. Keep Uptime Kuma's built-in login, add fail2ban watching for repeated failed logins if it is internet-facing, and if the dashboard never needs to be public, drop the proxy and reach it over a VPN.

Certificate-expiry monitoring, done right

An HTTP(s) monitor can also warn you before a TLS certificate expires: tick Certificate Expiry Notification and Uptime Kuma alerts a set number of days out. Two mistakes make it misread. Monitor by hostname, not IP, or a request with no SNI gets the server's default certificate and you see Hostname/IP does not match certificate's altnames. And do not tick Ignore TLS/SSL Error on a monitor you want expiry warnings from: that toggle is for self-signed internal hosts (unable to verify the first certificate, DEPTH_ZERO_SELF_SIGNED_CERT), but it stops Uptime Kuma checking the certificate at all, expiry included.

Backups: it is one directory

Because everything lives in /app/data, a backup is a copy of that volume taken while the container is stopped, so the SQLite file is consistent:

cd /srv/uptime-kuma
sudo docker compose stop
sudo docker run --rm \
  -v uptime-kuma_kuma-data:/data \
  -v /var/backups/kuma:/backup \
  alpine tar czf /backup/kuma-$(date -u +%Y%m%dT%H%M%SZ).tgz -C /data .
sudo docker compose start

Confirm the volume's real name with docker volume ls | grep kuma first, since Compose prefixes it with the project directory. Then copy the tarball off the box, because a backup on the same VPS is a copy, not a backup. Restore is the reverse: stop the stack, extract into an empty /app/data volume, start it.

Upgrades

Upgrades are an image pull:

cd /srv/uptime-kuma
sudo docker compose pull
sudo docker compose up -d

The new container runs any database migration on first start; watch docker compose logs -f. Take the backup above before pulling, and stay within a major tag: moving from :1 to :2 is a one-way migration, so back up first and check the release notes.

Failure modes, with the strings you will see

False "down" on a monitor pointed at localhost. The monitor goes red with timeout of 48000ms exceeded or connect ETIMEDOUT, yet the service answers from your laptop. If it targets the same host Uptime Kuma runs on, a CPU or memory spike starved the check, not the target. Move the monitor to a separate VPS and target the public hostname.

connect ECONNREFUSED 127.0.0.1:443 (or any port). Nothing was listening on that port: either the service is down, or you monitored localhost from inside the container, where 127.0.0.1 is the container, not your server. Monitor the public hostname, not loopback.

Invalid login: 535-5.7.8 Username and Password not accepted on an email test. The SMTP credentials are wrong, or the provider wants an app-specific password and got your account password. Generate an app password and paste that.

connect ETIMEDOUT or queryA ETIMEDOUT <host> on an email test. Wrong port, or the provider blocks outbound SMTP. Confirm 465 or 587 matches the Secure/STARTTLS setting, and test from the host with nc -vz smtp.example.com 587. Many providers block outbound 25 and some block submission ports until you ask.

self signed certificate or unable to verify the first certificate on an email test. Your SMTP server presents a certificate Node will not trust; fix the mail server's certificate rather than papering over it.

Dashboard stuck on "Connecting...", console shows WebSocket connection ... failed. The reverse proxy is not upgrading the WebSocket. Add the Upgrade and Connection "upgrade" headers on nginx, or use a proxy that forwards them by default such as Traefik or Caddy. The HTML loads because that is a normal HTTP GET; only the live socket needs the upgrade.

Cert-expiry monitor never warns, or warns wrongly. Either Ignore TLS/SSL Error is ticked, which disables cert checking, or the monitor targets an IP and reads the wrong certificate through missing SNI, showing Hostname/IP does not match certificate's altnames. Untick ignore, monitor by hostname.

SQLITE_BUSY or database disk image is malformed in the logs. The /app/data volume is on a filesystem without proper file locking, usually NFS; move it to a local Docker volume and restore from backup.

FAQ

Where should I run my uptime monitor?

On a different server from the ones it watches, ideally another provider or region, reaching them by hostname over the public internet just as your users do. If the monitor shares a box with its targets, the outage that kills the server kills the monitor too, and an overloaded host makes it cry "down" about services that are fine. A tiny separate VPS avoids both.

How do I get alerts on Telegram or email?

Add the channel under Settings then Notifications, then attach it to each monitor. For Telegram, create a bot with @BotFather and read chat.id from https://api.telegram.org/bot<token>/getUpdates; for email, use 465 for SSL or 587 for STARTTLS with an app password if your provider uses two-factor auth. Press Test and confirm the message arrives before relying on it.

Can Uptime Kuma monitor a cron job or backup script?

Yes, that is the Push monitor: Uptime Kuma gives you a URL and you curl it at the end of the script so it fires only on success. If the job fails or the box is down the heartbeat never arrives, and you are alerted after the interval passes. It is the only reliable way to know a scheduled job actually ran, since an external check cannot see inside it.

Uptime Kuma vs Zabbix, which should I run?

Uptime Kuma answers "is it up, from the outside, and did it alert me" in ten minutes with almost no resources, plus a status page. It does not collect deep metrics like CPU, memory and disk trends or fleet-wide thresholds; for that, a full Zabbix monitoring server is the heavier, agent-based tool, and many people run both. Still deciding what to run at all? our roundup of what to self-host in 2026 puts monitoring in context.

#uptime-kuma#monitoring#docker#self-hosting#status-page