Skip to content

How to Deploy Gatus on a VPS

Updated Sep 2026

verified on Ubuntu 26.04 · Aug 2026
We earn commissions when you shop through the links below. Full disclosure →

Self-host Gatus on a small VPS — a YAML-driven health dashboard and status page, with the storage setting you must change before you trust a single number on it.

Before you start
  • A small VPS — 1 vCPU / 512 MB–1 GB RAM is plenty
  • A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
  • A domain you can point at the server, on a box separate from what you monitor
  • Docker Engine + Compose installed (see the base guide below)
  • Comfort editing YAML — the monitor list is a file, not a form
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

What Gatus is

Gatus is a developer-oriented health dashboard and status page. It checks endpoints over HTTP, ICMP, TCP and DNS, evaluates each result against a list of conditions you write, and shows the outcome as a wall of coloured squares — one square per check, newest on the right. When a condition fails often enough, it fires an alert to Slack, Discord, PagerDuty, Teams, email, or one of a long list of other providers.

The thing that makes Gatus different from every other monitor on this site is where the configuration lives: config.yaml is the entire application. There is no "Add Monitor" button. The dashboard is read-only. Every endpoint, every condition, every alert route is a block of YAML you write, review, and deploy like any other code. That is either exactly what you want or exactly what you don't, and it's worth deciding before you spend thirty minutes on it.

If you want to click a button and be done, Uptime Kuma is the faster install and covers much the same protocols — Uptime Kuma vs Gatus lays the trade-off out. If you want your monitoring reviewed in a pull request, keep reading.

Read this before you install: storage is opt-in

This is the single most important fact about Gatus, and it catches people who skim the quick-start:

By default, Gatus stores results in memory. Restart the container and every result, every uptime percentage, and every past event is gone.

That is deliberate — storage.type defaults to memory, which makes the container trivially disposable — but it means an out-of-the-box Gatus is a live status light, not a record. You will restart it (an upgrade, a config change, a reboot) and the dashboard comes back showing 100% uptime and no history, which is worse than useless: it looks like nothing has ever gone wrong.

The fix is one block of YAML and a volume, and it is included in the install below. Gatus supports two persistent backends:

storage:
  type: sqlite
  path: /data/data.db

or, if you already run Postgres and want the history in it:

storage:
  type: postgres
  path: "postgres://user:password@127.0.0.1:5432/gatus?sslmode=disable"

For a single-box monitor, sqlite is the right answer — no extra service, one file to back up. Two related knobs are worth knowing while you're here: storage.maximum-number-of-results (how many check results each endpoint keeps, default 100) and storage.maximum-number-of-events (how many up/down transitions, default 50). Raise them if you want a longer window; they are per endpoint, so raising them a lot across many endpoints does grow the database.

Server sizing

Gatus is a single Go binary. It spends its life making small outbound requests and writing small rows, so it is one of the cheapest things you can run:

  • 512 MB RAM / 1 vCPU — genuinely enough for dozens of endpoints on short intervals.
  • 1 GB RAM — the comfortable default, with room for the reverse proxy and a couple of other small services on the same box.
  • 2 GB RAM+ — only for hundreds of endpoints or a much larger retention window than the defaults.

Disk is modest: 10–20 GB covers the OS, the image, and the SQLite database. Any entry-tier VPS has room to spare.

Put it somewhere else. The rule that applies to every monitor applies here: a monitor must not run on the machine it monitors. If your app server dies so does the monitor living on it, and you learn about the outage from a customer. Give Gatus its own small VPS, ideally with a different provider or region from your main workload.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
Vultralso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $5.00/mo
Get Vultr (opens in new tab)

Paid link — we earn a commission if you shop through it.

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 only — Gatus's own port stays internal:

sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
sudo ufw status verbose

Outbound matters more than inbound on a monitoring box: the checks have to reach what they watch. If your provider filters egress, make sure ICMP is permitted before you write any icmp:// endpoints, or every ping check will report down while the HTTP checks pass.

Write config.yaml first

Gatus reads its configuration at startup. There is no first-run wizard to fall back on, so the file has to exist before the container does. Create the project directory and write a minimal config:

mkdir ~/gatus && cd ~/gatus
cat > config.yaml <<'YAML'
endpoints:
  - name: website
    url: "https://example.com"
    interval: 60s
    conditions:
      - "[STATUS] == 200"
      - "[RESPONSE_TIME] < 300"
YAML

That is a complete, working configuration: one endpoint, checked every sixty seconds, healthy only when it returns a 200 and answers in under 300 ms. Both conditions must pass for the square to be green.

Now run it, mounting that file in at /config/config.yaml, which is where the image looks:

If you just want to see it work before committing to a compose file, this single command is enough. Stop and remove that container before the compose step below, or the two will fight over the same name and port:

docker run -d --name gatus -p 8080:8080 \
  --mount type=bind,source="$(pwd)"/config.yaml,target=/config/config.yaml \
  ghcr.io/twin/gatus:stable

Load http://SERVER_IP:8080 and you should see one endpoint with its first squares appearing. That's the smoke test — the image runs and your YAML parses. Don't leave it like this (no persistence, port wide open): docker rm -f gatus and move to the real deployment.

Install Gatus (Docker Compose, with persistence)

Same image, plus the storage block that makes the history real and a loopback bind so the reverse proxy is the only public door:

cat > docker-compose.yml <<'YAML'
# docker-compose.yml
services:
  gatus:
    image: ghcr.io/twin/gatus:stable
    container_name: gatus
    restart: unless-stopped
    volumes:
      - ./config.yaml:/config/config.yaml:ro
      - gatus-data:/data
    ports:
      # Loopback only — Caddy is the sole route in from outside.
      - "127.0.0.1:8080:8080"

volumes:
  gatus-data:
YAML

Then extend config.yaml with the storage block and a slightly more realistic set of endpoints:

storage:
  type: sqlite
  path: /data/data.db

ui:
  title: "Status | example.com"
  header: "example.com"

endpoints:
  - name: website
    group: public
    url: "https://example.com"
    interval: 60s
    conditions:
      - "[STATUS] == 200"
      - "[RESPONSE_TIME] < 300"
      - "[CERTIFICATE_EXPIRATION] > 168h"

  - name: api-health
    group: public
    url: "https://api.example.com/health"
    interval: 30s
    conditions:
      - "[STATUS] == 200"
      - "[BODY].status == UP"

  - name: postgres
    group: internal
    url: "tcp://10.0.0.5:5432"
    interval: 60s
    conditions:
      - "[CONNECTED] == true"

Bring it up and watch the logs for parse errors:

docker compose up -d
docker compose logs --tail 50

A few things in that config are worth naming, because they're the parts that make Gatus more than a ping:

  • group buckets endpoints on the dashboard. Use it — a flat list of thirty squares tells you nothing at a glance.
  • [CERTIFICATE_EXPIRATION] > 168h turns the endpoint red a week before a TLS certificate expires. This is the check that catches the renewal that quietly stopped working, and it costs you one line.
  • [BODY].status == UP reads a field out of a JSON response, so a 200-with-an-error-payload still counts as down. A status code alone is a weak health check.
  • tcp:// and icmp:// prefixes switch the check type. Only [CONNECTED], [IP] and [RESPONSE_TIME] are available for ICMP endpoints — there's no body or status code to assert on.

HTTPS + domain

Point an A record for status.example.com at the server's public IP, wait for it to resolve, then terminate TLS in front of 127.0.0.1:8080. The straightforward path is Automatic HTTPS with Caddy:

status.example.com {
    reverse_proxy 127.0.0.1:8080
}

That's the whole thing — Gatus is a plain HTTP service with no WebSocket requirement for the dashboard, so there are no special proxy headers to set.

If you run Caddy as a container, 127.0.0.1 is the proxy's own loopback and won't reach Gatus. Put both services in one compose file and use reverse_proxy gatus:8080, dropping the host port publish entirely.

Gatus can also terminate TLS itself (web.tls.certificate-file / web.tls.private-key-file), but then you own certificate renewal. Let the reverse proxy do it.

Lock the dashboard down

Here is the second thing that surprises people: a fresh Gatus dashboard is public. Anyone who loads the URL sees every endpoint name, every hostname, and every group you configured — which is a fairly detailed map of your infrastructure. That is intentional (it doubles as a public status page), but it is almost certainly not what you want for a config that includes tcp://10.0.0.5:5432.

Two options, both under security:

Basic auth, for a personal dashboard. The password goes in as a bcrypt hash, base64-encoded — never as plaintext:

sudo apt install -y apache2-utils
# Produces the bcrypt hash, then base64-encodes it for the config
htpasswd -bnBC 9 "" 'your-password' | tr -d ':\n' | base64 -w 0
security:
  basic:
    username: "john.doe"
    password-bcrypt-base64: "JDJhJDA5JC..."

Keep the bcrypt cost sensible (around 9). Basic auth verifies the password against the hash on every request, so a very high cost makes the dashboard crawl.

OIDC, the better answer for a team and a natural pair with a self-hosted SSO like Authentik. Set security.oidc with issuer-url, client-id, client-secret, scopes: ["openid"], and a redirect-url that must end in /authorization-code/callback — that path is fixed by the application. allowed-subjects restricts which identities may sign in; leave it empty and anyone your IdP authenticates gets in.

If you genuinely want a public status page, split it: public endpoints with friendly names on one instance, internal hostnames on a separate authenticated one.

Alerts

A dashboard nobody is looking at is not monitoring. Alerts hang off each endpoint under alerts, and the provider credentials live once under alerting:

alerting:
  discord:
    webhook-url: "https://discord.com/api/webhooks/**********/**********"
  slack:
    webhook-url: "https://hooks.slack.com/services/**********/**********"

endpoints:
  - name: website
    url: "https://example.com"
    interval: 60s
    conditions:
      - "[STATUS] == 200"
    alerts:
      - type: discord
        description: "healthcheck failed"
        failure-threshold: 3
        success-threshold: 2
        send-on-resolved: true

The two thresholds are what separate an alert from a nuisance: failure-threshold is how many checks in a row must fail before you're paged (default 3) and success-threshold how many must pass before the incident is marked resolved (default 2). One missed check on a sixty-second interval is noise; three in a row is an incident. Set send-on-resolved: true — an alerting system that tells you things broke but never that they recovered trains you to ignore it.

Wire a second, independent channel for anything that must wake you up — the useful failure mode is the one where your main chat provider is also down. And give cron jobs a heartbeat via external-endpoints: the job gets a token and an expected heartbeat.interval, pushes a success when it finishes, and Gatus alerts when the push doesn't arrive. That is how you find out a nightly backup has been failing for a week.

Monitoring as code

The payoff for the YAML-only design shows up now. Point GATUS_CONFIG_PATH at a directory instead of a single file and Gatus merges every *.yaml and *.yml inside it — including subdirectories — into one configuration at startup:

    environment:
      GATUS_CONFIG_PATH: /config
    volumes:
      - ./config:/config:ro

That lets each service own its own checks in its own file (config/api.yaml, config/db.yaml), so adding monitoring to a new service is a file in a pull request next to the code it watches. Maps deep-merge and lists append; a primitive value may only be defined once across all files, so alerting.slack.webhook-url belongs in exactly one place — define it twice and startup fails rather than silently picking a winner.

Keep the whole directory in git. That's the feature: your monitoring is reviewable, diffable, and restorable.

Backups

Two things to preserve, and only one of them is precious:

cd ~/gatus
tar czf gatus-config-$(date +%F).tar.gz config.yaml docker-compose.yml

docker compose stop
docker run --rm -v gatus_gatus-data:/data -v $(pwd):/backup alpine \
  tar czf /backup/gatus-data-$(date +%F).tar.gz -C /data .
docker compose start

Stopping the container gives a clean copy of the SQLite file; Gatus is light enough that a few seconds of downtime costs nothing. (The volume's real name is prefixed with the compose project directory — confirm with docker volume ls.) Copy both archives off the box. If the config is in git the second command is optional: losing the results database costs you history, not capability. Losing the config costs you the whole setup.

Upgrades

cd ~/gatus
docker compose pull
docker compose up -d

The stable tag tracks current releases. Because the configuration is a file you own, upgrades are low-drama: the container is replaced, the config and /data are re-mounted, and history survives — provided you set a persistent storage type. On the memory default, every upgrade silently wipes your history. Skim the release notes before a major jump, and back up the database first.

Troubleshooting

The container starts and immediately exits. Almost always a YAML error or a missing config. Read docker compose logs gatus — Gatus reports the offending key on startup. The usual causes are a mount that landed as a directory because the file didn't exist when the container first ran (delete the stray directory, create the file, recreate the container) and indentation drift under conditions.

Everything shows 100% uptime with no history after a restart. You're on the memory default. Add the storage block and the /data volume from the install section.

Every ICMP endpoint is down, HTTP endpoints are fine. Ping is blocked somewhere. Check the provider's network firewall and ufw first. Current versions send unprivileged pings, but a hardened host or a container runtime can still refuse them — testing with docker compose exec gatus ping -c1 10.0.0.1 tells you quickly which layer is saying no.

A condition never passes and you can't see why. Expand the result — the dashboard shows the condition with the actual values substituted in, which spots [STATUS] == 200 receiving a 301 or a [BODY] path that isn't in the response. Response-time conditions are in milliseconds: [RESPONSE_TIME] < 300 on a cross-continent check fails forever.

Alerts never arrive. Check that a provider block under alerting exists for the type used on the endpoint — an alert of type slack with no alerting.slack is ignored. Then check failure-threshold: 3 failures on a 5-minute interval is fifteen minutes before you hear anything.

Verification + next steps

You're done when you can: load https://status.example.com over a valid certificate, get prompted for credentials, see your endpoints grouped and green, restart the container and watch the history still be there, then break something on purpose and receive the alert — and the recovery notice — on a real device.

From there: move the config into a directory in git so each service ships its own checks, add a [CERTIFICATE_EXPIRATION] condition to every HTTPS endpoint you own, and give every cron job a heartbeat. For CPU and memory graphs rather than up/down you want a different tool — Beszel for machine metrics, Grafana for a full metrics stack. For the ranked host picks, see Best VPS for Monitoring & Uptime.

Next steps

How to self-host Gatus →More self-hosted monitoring tools →Best VPS for Monitoring & Uptime →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.