A self-managed GitLab instance is a platform wearing one hostname. Behind it: Puma serving the web UI and API, Gitaly serving repositories, Workhorse and sshd serving Git over HTTPS and SSH, Sidekiq running every background job including the pipeline scheduler, a container registry on its own subdomain, Pages on another, runners that are separate processes on separate machines, and a nightly gitlab-backup that was set up once and never looked at again. They fail independently, and the ones that matter most to developers fail without touching the web UI at all. The merge request page loads fine while every pipeline has been "pending" for two hours because the runner pool went offline after a reboot.
This guide monitors the layers separately, from outside, so an alert names the layer. It assumes an Omnibus or Docker install; the endpoints are the same on Helm.
The web layer: /-/health, not the sign-in page
GitLab exposes three health endpoints. /-/health returns 200 with the text GitLab OK whenever Puma and Workhorse are up, with no authentication and no IP restriction; it exists for load balancers, and it's the one an external monitor should use. /-/readiness and /-/liveness go deeper (database, Redis, Gitaly, with ?all=1), but they're restricted to the monitoring IP allowlist, gitlab_rails['monitoring_whitelist'], which is localhost by default. CronAlert checks run from Cloudflare's network with no fixed probe addresses to allowlist, so leave those two to an on-host script and monitor /-/health from outside.
Do not monitor the root URL. On a private instance, https://gitlab.example.com/ redirects to /users/sign_in; CronAlert follows redirects, the sign-in page returns 200, and the monitor is green through outages that only affect signed-in pages. Create an HTTP monitor on https://gitlab.example.com/-/health expecting 200, on the free plan; it also tracks the certificate, which matters if you turned off Omnibus's automatic Let's Encrypt renewal or terminate TLS somewhere else. On Pro, add a keyword check for GitLab OK so Nginx's own 200 maintenance page can't impersonate a healthy instance.
Prove Git is serving
Puma can be healthy while Gitaly isn't: a full disk on the repository storage, a Gitaly process restart loop, a storage path that didn't mount after reboot. The cheapest proof that repositories are actually served is the handshake every clone starts with. Create a tiny public project named canary and monitor https://gitlab.example.com/<group>/canary.git/info/refs?service=git-upload-pack expecting 200; Workhorse forwards it to Gitaly, which lists the refs. If your instance disallows public projects, the same URL returns 401, and a monitor expecting 401 still proves Workhorse and the Git HTTP backend answered. With a Pro keyword on refs/heads/main, you also know it served real refs rather than an empty response.
SSH: a TCP check on the Git port
Most developers push over SSH, which has nothing to do with the web layer: it's the host's sshd with gitlab-shell behind it, on 22, or on 2222 in Docker installs that leave 22 for the host. A TCP port monitor on gitlab.example.com:22 alerts when nothing is listening: a firewall rule change, a Docker port mapping lost on recreate, sshd failing to start after a config edit. It proves reachability, not authentication, and paired with the smart-HTTP canary above you have both Git transports covered. TCP monitors are on Pro.
Runners and Sidekiq: a scheduled pipeline as heartbeat
This is the layer that fails most quietly. A runner is a separate process registered with the instance; when it dies, loses its registration token, or wedges on a hung job, the Admin area shows it as offline, nobody looks at the Admin area, and jobs queue forever. Rather than scrape the runners page, make the runner produce a signal. In any project, add a pipeline schedule (Build → Pipeline schedules) with a cron of */15 * * * *, store a heartbeat URL as a masked CI/CD variable named CRONALERT_HEARTBEAT, and give the project this pipeline:
# .gitlab-ci.yml — runs only from the schedule; one job, one curl
runner-heartbeat:
image: curlimages/curl:latest
tags: [docker] # the tag your production jobs use
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- curl -fsS -m 10 -X POST "$CRONALERT_HEARTBEAT" Create the heartbeat monitor with a 15-minute expected interval; with the default grace it alerts after about 30 minutes of silence. A ping now arrives only if Sidekiq ran the schedule worker, a runner carrying that tag picked the job up, the executor started the image, and the job could reach the internet: every link a real pipeline needs. That first link is a bonus. Pipeline schedules are Sidekiq jobs, so a dead or backlogged Sidekiq, which also stops emails, mirror pulls, and merge-request pipelines, shows up here before anyone notices the symptoms. One schedule per runner tag tells you which pool is down. The GitHub Actions and Gitea versions of this pattern work the same way; the GitLab version has the advantage of exercising the scheduler you operate.
Registry and Pages
The container registry lives on its own hostname and is often a different process, and a stalled registry breaks every deploy that pulls an image. Its API root, https://registry.example.com/v2/, answers an unauthenticated request with 401 and a WWW-Authenticate challenge pointing at /jwt/auth on the main instance. That 401 is the registry saying it's alive, so a monitor expecting 401 catches it being down without any credentials. If it starts returning 502, Nginx is up but the registry process behind it isn't.
If you use GitLab Pages, monitor one published site (https://<group>.pages.example.com/<project>/) expecting 200. Pages is its own daemon with its own wildcard certificate, and it can be down while GitLab is fine. A Pro keyword on a string from the site's content catches the Pages daemon serving its own 404 page with a 200 after a project is deleted or moved.
Backups: the job that only counts if it ran
Omnibus doesn't schedule backups for you. The documented cron runs gitlab-backup create nightly, and it's only a backup if it finished. Make the ping conditional on success:
# /etc/cron.d/gitlab-backup — 02:00 nightly; ping only when both steps succeed
0 2 * * * root /opt/gitlab/bin/gitlab-backup create CRON=1 STRATEGY=copy \
&& tar -czf /var/opt/gitlab/backups/etc-gitlab-$(date +\%F).tgz /etc/gitlab \
&& curl -fsS -m 10 -X POST https://cronalert.com/api/heartbeat/<token> >/dev/null Set the expected interval to 24 hours and the grace to a few hours; a growing instance's backup takes longer every month, and STRATEGY=copy adds time for the copy. The second step matters more than it looks: the application backup is encrypted with keys in /etc/gitlab/gitlab-secrets.json, and gitlab-backup deliberately excludes that file. A restore without it loses CI variables, two-factor secrets, and runner tokens. The backup monitoring guide covers verifying the archive and testing a restore before pinging. In Docker, the same line is docker exec -t gitlab gitlab-backup create.
Reading the alerts
| /-/health | Canary refs | SSH TCP | Runner heartbeat | Most likely |
|---|---|---|---|---|
| Down | Down | Down | Silent | Host or network. Nothing is reachable. |
| 502 | Down | Up | Silent | Puma is down or restarting: a reconfigure, an upgrade, an out-of-memory kill. Nginx is answering for it. |
| Up | Down | Up | Fine | Gitaly or repository storage: disk full, storage unmounted, Gitaly restarting. |
| Up | Up | Down | Fine | SSH only: sshd, firewall, Docker port mapping. |
| Up | Up | Up | Silent | The runner pool for that tag, or Sidekiq. Check Admin → Runners, then Admin → Monitoring → Background jobs. |
| Up, keyword failing | Any | Any | Any | Something else is answering with a 200: Nginx's maintenance page, or a reverse proxy error page. |
What outages look like from outside
- Upgrades and reconfigure.
gitlab-ctl reconfigureand package upgrades restart Puma, and the instance serves 502 ("GitLab is taking too much time to respond") for anywhere from thirty seconds to several minutes. Put a maintenance window over your upgrade slot, and pin thegitlab-eeorgitlab-cepackage so unattended upgrades can't do it at 4 a.m. without one. - Memory. GitLab wants eight gigabytes and uses them. On a smaller host the kernel's OOM killer takes Puma workers (502s) or Sidekiq (silent), and Sidekiq is the one that doesn't show in the web UI.
- Disk. Repositories, the registry, artifacts, and backups all grow on the same volume by default. A full disk shows first as a failed canary (Gitaly can't write), then as a missed backup heartbeat, then as 502s.
- Registry garbage collection.
registry-garbage-collectstops the registry while it runs; schedule it inside a maintenance window or expect a 401-monitor incident. - Certificate renewal. Omnibus renews Let's Encrypt certificates automatically, but only if port 80 still reaches it and the hostname still resolves to this host; the HTTP monitor's certificate tracking catches the renewals that stopped. Monitoring Let's Encrypt renewals has the details.
Frequently asked questions
Can I monitor /-/readiness from outside?
Only by adding the probe's IP to the monitoring allowlist, and CronAlert has no fixed probe IPs. Use /-/health externally and run readiness checks on the host.
Why not monitor the sign-in page?
Because it's a 200 that only proves Puma rendered a static form; it's up during most of the outages you care about. /-/health is the same cost and more honest.
How does this differ from GitLab's bundled Prometheus?
The bundled Prometheus sees far more, from inside, and it goes down with the host. External checks and heartbeats are the layer that tells you when the inside view is gone. If you also run your own Prometheus, give it a heartbeat of its own.
Does the runner heartbeat cost CI minutes?
On self-managed runners, no; it's a few seconds of a container you own, 96 times a day.
Which plan?
The HTTP checks (health, canary, registry, Pages) are free. SSH TCP, the heartbeats, and keywords are Pro ($5/mo).
Monitor the layers, not the hostname
One check on the hostname tells you GitLab is reachable, which is the one thing GitLab rarely fails at. Six checks on the layers tell you which half of the platform broke, and two of them, the scheduled-pipeline heartbeat and the backup heartbeat, cover the failures that no web check can see. Create a free account, add the health, canary, and registry checks, and upgrade to Pro for the SSH check and the heartbeats before the next runner outage. Related reading: uptime monitoring in CI/CD pipelines, monitoring Docker and self-hosted apps, monitoring the VPS it runs on, monitoring internal tools behind sign-in, and choosing between HTTP, TCP, and heartbeat monitors. See also monitoring Jenkins: controller, agents, scheduled jobs, and quiet mode.