How to Deploy Checkmate on a VPS
Updated Aug 2026
verified on Ubuntu 26.04 · Aug 2026Self-host Checkmate on a VPS — app container plus MongoDB, behind HTTPS — including the pinned MongoDB tag that refuses to boot on a current Ubuntu kernel, and how to fix it.
- A VPS with at least 2 GB RAM — MongoDB is the reason, not the app
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain you can point at the server — the app needs to know its own URL
- Docker Engine + Compose installed (see the base guide below)
What Checkmate is
Checkmate is a self-hosted uptime and incident monitor with an unusually polished dashboard. It checks endpoints over HTTP, ping, port and SSL, watches Docker containers and game servers, runs PageSpeed reports, tracks incidents, and publishes status pages. Under the hood it's a TypeScript/Node application backed by MongoDB.
Two things distinguish it from the simpler monitors on this site. The first is that it is two services, not one — the app image and a MongoDB you have to run and back up. The second is that server hardware metrics are not built in: CPU, RAM and disk for your machines come from a separate agent called Capture, installed per machine, and you don't need it at all if you only want uptime checks.
If you want the lightest possible uptime monitor, Uptime Kuma is one container with embedded storage and a faster start. Checkmate is the pick when you want PageSpeed auditing, incident tracking and a nicer front end, and you don't mind running a database.
Before you start: the MongoDB kernel problem
This is the one that will stop your deploy dead, so it comes before the install rather than in troubleshooting.
The reference Compose file in Checkmate's repository pins MongoDB to the
mongo:8.0 tag. That specific tag has a known incompatibility with Linux
kernel 6.19 and newer: the container refuses to start and logs
MongoDB cannot start: Linux kernel versions 6.19 and newer has a known
incompatibility
tracked upstream as SERVER-121912. A current Ubuntu LTS ships a kernel new
enough to hit this, so on a freshly provisioned box the reference file fails
outright — the mongodb service never becomes healthy and the checkmate
service, which depends on it, never starts either. Nothing about the error
message points at Checkmate, which is what makes it a bad half hour.
The fix is a one-character change: use the mongo:8 tag, which resolves to a
patched release. The install below does exactly that, with sed, before the
stack is ever brought up.
Check what kernel you're on if you're curious whether it applies to you:
uname -r
Either way, rewriting the tag is harmless — do it and move on.
Server sizing
Checkmate itself is a modest Node application. MongoDB is what sets the floor.
- 2 GB RAM / 1–2 vCPU — the sensible starting point for a personal or small-team install.
- 4 GB RAM — comfortable, and what to choose if you're running many checks, keeping longer history, or running PageSpeed reports frequently.
- 1 GB RAM — don't. MongoDB and Node together on a 1 GB box is how you get a monitor that gets OOM-killed and stops telling you about outages, which is the worst possible failure for this class of tool.
Disk: 20–40 GB. Check history and PageSpeed results accumulate, and MongoDB is not the most compact storage engine you'll ever run.
Put it on its own box. A monitor must not run on the machine it monitors — if the app server dies, so does the monitor living on it, and you hear about the outage from a customer. A Hetzner CX22 (2 vCPU / 4 GB), a Kamatera instance sized to taste, or a 2 GB DigitalOcean droplet all work.
Prepare the server
This guide assumes Docker Engine and the Compose plugin are installed, along
with a non-root deploy user and a ufw firewall. If not, work through
Docker & Compose on Ubuntu first.
Open SSH and the reverse proxy ports. Checkmate's own port and MongoDB's stay internal:
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
sudo ufw status verbose
Port 52345 must never be open to the internet. That's the app's own port and it serves plain HTTP — no TLS of its own, at all. The reverse proxy is the only thing that should ever reach it. MongoDB should never be reachable from outside either; in the reference stack it only listens on the Compose network, which is exactly where it belongs.
Outbound access matters too, since the whole job is reaching the things you monitor. If your provider filters egress, allow ICMP before you configure ping checks.
Install Checkmate
Fetch the reference Compose file, fix the MongoDB tag, then bring it up with a generated secret:
mkdir ~/checkmate && cd ~/checkmate
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
sed -i 's|image: mongo:8.0|image: mongo:8|' docker-compose.yaml
That single sed is the kernel fix from the section above. Confirm it landed
before you go further:
grep 'image: mongo' docker-compose.yaml
You should see image: mongo:8. Now start the stack:
# put the secret in .env so every later compose command sees it too — the inline
# form (VAR=... docker compose up) applies to that one command and nothing else
echo "JWT_SECRET=$(openssl rand -hex 32)" >> .env
docker compose up -d
docker compose ps
docker compose logs --tail 50
docker compose ps should show both services, with mongodb reporting
healthy — the app has a dependency on that health check, so if Mongo isn't
healthy the app won't start at all. Once it's up, the dashboard answers on
http://SERVER_IP:52345.
Make the secret permanent
Passing JWT_SECRET inline on the command line works for the first boot and is
a trap for every boot after it. JWT_SECRET signs authentication tokens: if it
changes, every session is invalidated and everyone is logged out. Start it
inline once and the next docker compose up -d without it either fails or comes
up with a different secret.
Put it in a .env file next to the compose file, which Compose reads
automatically:
cat > .env <<ENV
JWT_SECRET=$(openssl rand -hex 32)
CLIENT_HOST=https://status.example.com
ENV
chmod 600 .env
Then recreate the stack so both variables are in the environment for good:
docker compose up -d
Two variables, both worth understanding:
JWT_SECRET— required. Signs auth tokens. Generate it once, keep it secret, keep it stable, and back it up with everything else.CLIENT_HOST— the public URL the application is reached at. It drives CORS and the absolute links in invitations, notifications and status pages. The default assumes localhost, so anything you reach at a domain needs this set or the front end will call an API origin that doesn't match and you'll get a dashboard that loads and then does nothing.
There's also DB_CONNECTION_STRING, which points at MongoDB. The reference file
sets it to the bundled service; change it only if you're bringing your own
MongoDB.
HTTPS + domain
Point an A record for status.example.com at the server's public IP and
wait for it to resolve, then terminate TLS in front of 127.0.0.1:52345. The
simplest path is
Automatic HTTPS with Caddy:
status.example.com {
reverse_proxy 127.0.0.1:52345
}
CLIENT_HOST must match this hostname exactly, scheme and all —
https://status.example.com, not http://, not a bare domain, not a trailing
slash. That coupling is the single most common cause of "the page loads but
nothing works" on this app, and the fix is always to align the two and recreate
the container.
If Caddy runs as a container rather than on the host, 127.0.0.1 is the proxy's
own loopback. Put both in one compose file and proxy to the service name
instead, dropping the host port publish.
While you're here, make sure the compose file publishes the app on loopback
only. If the port line reads 52345:52345, change it to
127.0.0.1:52345:52345 — otherwise the plain-HTTP app is reachable directly on
the public IP, bypassing your certificate entirely.
First login
Load https://status.example.com. On a fresh install you're redirected to the
registration page, and the first account created becomes the
superadmin. There is no default password shipped with the application.
Create your own account first, immediately. After that, additional people are added by invitation from the team settings — you enter an email address and pick a role — rather than by open sign-up, which is the behaviour you want on an internet-facing dashboard. Email invitations need working SMTP configured; if you haven't set that up yet, plan on the invite flow not delivering until you have.
Then do the usual hygiene: a strong unique password on the superadmin account, and a look through settings to confirm nothing is more open than you expect. This dashboard lists every hostname and endpoint you monitor — it's an infrastructure map.
Set up monitors
Configure notifications before monitors, so the first real failure actually reaches you. A monitor with no notification route is a dashboard.
Then add checks that test more than "the port answers":
- HTTP/HTTPS uptime against a real health endpoint rather than the homepage.
- Ping for raw host reachability, so you can tell "the box is gone" from "the app crashed".
- Port checks for databases and anything without an HTTP surface.
- SSL checks, which warn you before a certificate expires — the renewal that quietly stopped working is a classic outage.
- Docker checks for container state on hosts you can reach.
- PageSpeed reports on your public pages, which is the feature that pushes people towards Checkmate over lighter monitors. Run these on a slow schedule; they're much heavier than an uptime check.
Set intervals and retry counts that match reality. One failed check on a short interval is usually noise; several in a row is an incident. A monitor that cries wolf gets ignored.
Hardware metrics need Capture. If you want CPU, RAM and disk graphs for your servers, that comes from the separate Capture agent installed on each machine, not from Checkmate itself. It's optional — skip it entirely if uptime is all you need, or run a dedicated metrics tool like Beszel alongside for that half of the job.
Backups
Three things to preserve, and all three matter:
- The MongoDB data — your monitors, history and incidents.
.env— theJWT_SECRET, without which restored sessions are dead.docker-compose.yaml— including yourmongo:8fix.
Dump the database with Mongo's own tool rather than copying files out from under a running server:
cd ~/checkmate
docker compose exec -T mongodb mongodump --archive --gzip \
> checkmate-db-$(date +%F).archive.gz
tar czf checkmate-config-$(date +%F).tar.gz .env docker-compose.yaml
Restoring is the mirror image:
Substitute the archive you actually want to restore:
docker compose exec -T mongodb mongorestore --archive --gzip --drop \
< checkmate-db-YYYY-MM-DD.archive.gz
Copy both files off the box and encrypt the config archive — it contains
your JWT_SECRET. Automate the dump on a schedule, and test a restore at least
once against a throwaway stack. And note the irony this class of tool always
carries: the server that tells you other servers died needs its own recovery
story.
Upgrades
cd ~/checkmate
docker compose pull
docker compose up -d
Back up first — the database schema can migrate on startup and migrations are one-way.
Two upgrade-specific cautions for this stack. First, if you re-download the
reference compose file to pick up upstream changes, you will re-introduce the
mongo:8.0 pin and your stack will stop booting on a current kernel. Re-run
the sed, or keep your compose file under version control and merge changes in
deliberately. Second, don't let a major MongoDB version jump happen by accident —
check the release notes before changing that image tag beyond the patched line.
Troubleshooting
MongoDB won't start, and the app never starts either. Read the Mongo logs
first: docker compose logs mongodb. If you see "Linux kernel versions 6.19
and newer has a known incompatibility", that's the pinned tag. Apply the sed
above, then docker compose up -d to recreate the service. This also comes back
after any re-download of the upstream compose file.
The app container restarts in a loop. Usually a missing JWT_SECRET, or
MongoDB not being healthy yet. docker compose logs checkmate names which.
Because the app depends on Mongo's health check, a Mongo problem always presents
as both services being unavailable — always diagnose the database first.
The dashboard loads but nothing happens — no data, failing logins.
CLIENT_HOST doesn't match the URL you're actually using. Set it to the exact
public origin (https://status.example.com), recreate the container, and
hard-refresh. Your browser's dev tools will show the front end calling the wrong
origin and being blocked by CORS.
Everyone got logged out after a restart or a move. JWT_SECRET changed.
That happens when it was only ever passed inline on the command line. Put it in
.env, and restore that same value if you're rebuilding from a backup.
Certificate errors, or the site works on :52345 but not on the domain. DNS
isn't resolving to the box yet, or the proxy isn't running. Confirm with dig status.example.com, check ports 80 and 443 are open in ufw and in any cloud
firewall in front of it, and remember that the app's own port serves plain HTTP —
if http://SERVER_IP:52345 works from outside, that port is published too
broadly and should be bound to loopback.
Ping monitors all fail while HTTP monitors pass. ICMP is blocked, either by the provider's network or on the host. Allow it outbound, or use port checks instead.
Verification + next steps
You're done when you can: load https://status.example.com over a valid
certificate, log in as the superadmin you created, confirm mongo:8 is the
running image (docker compose ps), watch a monitor go green, then break
something deliberately and receive the notification on a real device. Then
restart the whole stack — docker compose down && docker compose up -d — and
confirm you are still logged in and your history is intact. That last step is
what proves JWT_SECRET and the database volume are both persistent.
From there: add SSL expiry checks to every certificate you own, put PageSpeed reports on your public pages, and decide whether you want Capture for hardware metrics or a dedicated tool like Beszel alongside. For the ranked host picks, see Best VPS for Monitoring & Uptime.