Skip to main content
Photo of Anton ShubinAnton ShubinContact

Self-hosting

oko: a homelab dashboard that takes its status from Gatus

oko is a small self-hosted dashboard: one JSON file lists your services, and each card shows its status and 30-day uptime from Gatus. How it compares with Homepage, Homarr and Dashy.

Anton Shubin7 min read

The tool: okoOpen the live dashboardCode: github.com/spy4x/oko (opens in a new tab)

TL;DR

  • oko is a homelab dashboard: you list services in one JSON file, and each card shows whether Gatus sees it up and its 30-day uptime.
  • It never checks a service itself. When Gatus has no answer, the card says unknown, never green.
  • Homepage, Homarr and Dashy do far more, but none puts the Gatus status on each card: Homepage and Homarr show totals in a widget, Dashy runs its own checks.
  • It is one Go binary in an 11 MB image. The server renders the whole page; the only script is the search box.
The oko demo dashboard, with example services

Once you self-host more than a handful of services, you need two things every day: a page to find them, and a way to see that they are fine. The well-known homelab dashboards do the first job well, and much more. oko does both jobs, and nothing else.

You list your services in one JSON file. oko renders them as one searchable page, and for each service that Gatus watches, the card shows whether it is up and its uptime over the last 30 days. I built it for my own servers and run it at dash.antonshubin.com.

This post is for you if you already run Gatus, or plan to. If you don't, oko shows your services as links with no status pill, and one of the dashboards below will suit you better.

Here is what Homepage, Homarr and Dashy do in their latest releases as of today. Each does far more than oko.

  • Homepage (v2.7.0) is a Next.js app, configured through YAML files or Docker labels, with more than 100 service integrations and widgets for weather, search and system stats. It can check a service itself: siteMonitor requests a URL you choose, ping pings a host. Its Gatus widget is one block that counts how many Gatus endpoints are up and down, plus the share of successful checks among the results Gatus returns.
  • Homarr (v2.9.1) is set up in the browser, with a drag-and-drop grid and "no YAML involved", as its README says. It has users, permissions and sign-in through OIDC or LDAP, and keeps everything in a database: SQLite by default, or PostgreSQL. Its Gatus integration feeds a Stats widget with four numbers: up, down, unknown and total.
  • Dashy (4.9.0) reads one YAML file, conf.yml, which you can also edit in the browser. It has themes, widgets and optional sign-in. Its status indicators are its own checks: Dashy's server calls each URL and reads the HTTP code, or pings the host, on page load or on an interval.

So if Gatus is already the place where you decide what "up" means, none of the three puts its answer on each service's card. You either get a total in one widget, or a second set of checks that can disagree with Gatus. That gap is what oko fills.

  • Every service on one page, grouped by server, in the order you wrote them. Press / to search by name, product or description, and Esc to clear.
  • Status from Gatus, per card. A red border means Gatus says the service is down. The pill in the corner is its 30-day uptime. Each server's heading shows how many services are healthy out of the total, and how many are unknown.
  • Unknown stays unknown. When Gatus gives no answer for a service, the card gets a dashed "unknown" marker. It is not counted as healthy and not drawn as down.
  • Edit a file, reload the page. oko rereads config.json when the file changes, so the next page load shows your edit. No restart, no database, no accounts.
A down service and an unknown one, from oko's demo video

oko does one job, and the list of things it leaves out is as important as the list above. From its how it works page:

  1. Not a status page. It is a dashboard for you, not a public page for your users.
  2. Not a metrics dashboard. It reads two Gatus badges per service, not Prometheus. Use Grafana for graphs.
  3. Not a monitor. It never checks a service itself. Gatus checks, oko shows.
  4. Not multi-tenant. One process serves one dashboard.
  5. No sign-in. Put it behind your reverse proxy, with your SSO in front if the page should not be public.

If you need widgets, integrations with your media apps or a page your family can rearrange in the browser, Homepage, Homarr or Dashy are the better choice.

oko does not need the Gatus API to return any special format. For each service, it fetches the two badge SVGs Gatus already serves, in parallel: the health badge and the 30-day uptime badge. Then it reads the text inside them, from internal/gatus/client.go:

var (
	// healthTextRe matches the badge's value text: "up", "down" or "?".
	healthTextRe = regexp.MustCompile(`>\s*(up|down|\?)\s*<`)
	// Fill colours, used only when the text is missing. Current gatus
	// paints "down" #c7130a; #e05d44 is the shields.io red older
	// setups used.
	healthGreen = "#40cc11"
	healthReds  = []string{"#c7130a", "#e05d44"}
	uptimeRe    = regexp.MustCompile(`>\s*([\d.]+)%\s*<`)
)

A ? badge, a failed request or a badge oko cannot read all end the same way: the service is unknown. That is the rule behind the dashed card above. A broken network or a stopped Gatus should never paint your whole dashboard red, or green.

The results sit in an in-memory cache for 60 seconds by default (CACHE_TTL_SECS), and concurrent requests share one fetch, so ten people opening the page at once cost Gatus one round of requests. Add ?refresh=1 to the URL to skip the cache once.

Every page view is one Go handler: read the catalog, get the statuses from the cache, fill one html/template, send the HTML. From internal/render/handler.go:

	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		file, err := cfg.File()
		if err != nil {
			logger.Error("config load failed", slog.Any("err", err))
			http.Error(w, "config load error: "+err.Error(), http.StatusInternalServerError)
			return
		}

		status, err := fetchStatuses(r.Context(), c, file.EndpointKeys(), r.URL.Query().Get("refresh") == "1")
		if err != nil {
			logger.Warn("gatus fetch failed; rendering with empty status", slog.Any("err", err))
			status = map[string]gatus.Status{}
		}

		data := buildPage(cfg.Domain, file, status)
		if len(cfg.UptimeHosts) > 0 {
			data.UptimeURL = "https://" + cfg.UptimeHosts[0]
		}

		var buf bytes.Buffer
		if err := parsed.ExecuteTemplate(&buf, tmplName, data); err != nil {
			logger.Error("template execute failed", slog.Any("err", err))
			http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError)
			return
		}

		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.Header().Set("Cache-Control", "public, max-age=30")
		_, _ = w.Write(buf.Bytes())
	})

What this gives you:

  1. The status is in the HTML. There is no loading spinner, and the browser makes no request to learn the status: the first response already says what is up.
  2. Your browser never talks to Gatus. The server fetches the badges, so Gatus does not need to be reachable from the device you open the dashboard on.
  3. A failed Gatus fetch still renders the page. The handler logs a warning and draws every card linked to Gatus as unknown, so you can still find and open your services.
  4. Almost no JavaScript. The page ships one inline script: the search filter and its / and Esc keys. No client framework and no build step.

The whole program uses only Go's standard library: the go.mod has no require line. It ships as a static binary in a distroless image that runs as a non-root user. The image I pulled today is about 11 MB on disk, a 4.8 MB download.

Two things configure oko: a JSON catalog of your services, and a few environment variables. Here is a catalog with one service:

{
  "title": "Jane Doe's servers",
  "servers": [
    {
      "name": "Home",
      "services": [
        {
          "name": "Photos",
          "url": "https://photos.${DOMAIN}",
          "description": "Self-hosted photo and video backup",
          "product": "Immich",
          "product_url": "https://immich.app/",
          "endpoint": "home_photos",
          "gatus_host": "uptime"
        }
      ]
    }
  ]
}
  1. title is the page heading. Leave it out and you get "Service dashboard".
  2. servers are the sections, in the order you list them. Each needs a name.
  3. name and url are the only required fields of a service.
  4. ${DOMAIN} in a url is replaced with the DOMAIN variable, so the same file works for a test domain and your real one. No other variable is replaced.
  5. description, product and product_url fill the card. The product shows as a small link under the description, only when product_url is set. An icon field takes an emoji or short text.
  6. endpoint and gatus_host connect the card to Gatus. endpoint is the Gatus endpoint's key, its group and name joined by _ (here home_photos). gatus_host is the first label of a host in UPTIME_HOSTS (here uptime, for uptime.example.com). Set both or neither: oko rejects a service with only one. With neither, the card is a plain link with no status.

The full list, including hidden: true and every environment variable with its default, is in docs/configuration.md.

oko has no tagged release yet, so I pin the image by its digest: the build of commit d93c6b3, which is what latest points at today. A digest never moves, so an update is always your decision.

# compose.yml
services:
  oko:
    image: ghcr.io/spy4x/oko@sha256:4bc56c2c4693c2e4c1856cb2308b0bc2318e042830cd63f878617b238c05b882
    restart: unless-stopped
    ports: ["8080:8080"]
    environment:
      DOMAIN: example.com
      UPTIME_HOSTS: uptime.example.com
      CONFIG_PATH: /config/config.json
    volumes: ["./oko:/config:ro,z"]

Save the catalog above as oko/config.json, then:

docker compose up -d
docker compose exec oko /usr/local/bin/oko -healthcheck && echo healthy

Open http://localhost:8080. Until UPTIME_HOSTS points at your own Gatus, every card says unknown, which is the rule from above doing its job.

Mount the folder, not the file. Many editors save by replacing the file, and a single-file bind mount keeps showing the old one until the container restarts. If you run your homelab with rostok, its catalog has an oko stack with this already written.

My dashboard is dash.antonshubin.com. On How I run production you can see where it sits: oko reads Gatus and draws the dashboard, and zond feeds Gatus the checks for services behind my SSO proxy.

If you run Gatus, give oko ten minutes: write three services into config.json, start it, and see whether the page tells you the truth. If something is missing or wrong, open an issue, or tell me on the oko page.

And if it's your product's production you want watched like this, that is part of what I do on ongoing work: monitoring, backups and security patches after launch.

Get the next post by email

Anton Shubin

Senior Full-Stack Engineer & Tech Lead

I build greenfield SaaS on a modern, lightweight stack, alone or with a team of senior developers from my own pool, and I cover full-stack, DevOps and architecture.

New posts on decisions for founders, developer craft, AI and MCP, self-hosting, and my own mistakes, about once a week at most, and only when there is one. Unsubscribe with one click.

Or follow the RSS feed

Questions, or a different result? Reply by email: hi@antonshubin.com. It comes straight to me.