zond: a probe bridge so Gatus can see through your SSO proxy
Behind Authelia or Authentik, Gatus gets the same 302 to the login page from every service, up or down. zond runs beside your containers, probes them directly and answers 200 ok or 503 unreachable, with no monitoring login.
Anton ShubinUpdated 7 min read
The tool: ZondCode: github.com/spy4x/zond (opens in a new tab)
TL;DR
- Behind an SSO proxy, Gatus gets the same 302 to the login page from every service, whether the service runs or not.
- zond runs on the same Docker network as your services, sends each one a plain HTTP GET and answers 200 ok or 503 unreachable. No login, no auth bypass.
- Its image is a 3.7 MB download. On my laptop it used about 3 MB of memory idle and stayed under 14 MB through 1,000 requests.
- I rewrote it from Deno to Go in July 2026. The last Deno version, rebuilt for comparison, is a 117 MB image that idles at 19 MB.
You put Authelia or Authentik in front of your self-hosted services, and now nobody gets in without logging in. That includes your monitor.
This post is for people who run their own services and want honest health checks for them. I'll show what zond does, the whole setup, why it has no login of its own, and what it costs to run, with numbers you can check yourself.
Why your monitor sees only the login page
Link to section: Why your monitor sees only the login pageGatus asks https://metube.example.com/ and gets a 302 to the login page. It
gets the same 302 when metube runs, when it crashes on every request, and when
its container is gone, because the proxy checks the session before it forwards
anything to metube.
That leaves two bad options:
- Count the redirect as healthy. Now a dead service shows a green check.
- Give the monitor its own account. Now you keep a password alive on every service, for a robot.
A TCP check from inside the network is not the answer either.
tcp://hl-metube:8081 tells you a port is open. A service can listen on its
port and still answer 500 to every request.
What zond does
Link to section: What zond doeszond is a small Go service that runs beside your services, on the same Docker network. Your monitor asks it about a service on a public URL that sits outside the SSO. zond sends that service a plain HTTP GET by its Docker name and answers with a status code. The name is Russian for "probe".
Nothing gets around the login. The proxy still guards metube for everyone. zond never talks to the proxy: it reaches metube the way metube's neighbours on that network do, by its container name, and tells the outside world one word.
This is the rule it applies, from
internal/probe/probe.go:
return Result{Name: name, OK: resp.StatusCode >= 200 && resp.StatusCode < 400}
A 2xx or 3xx answer is up. A 4xx, a 5xx, a timeout, a refused connection
or a failed DNS lookup is down. zond doesn't follow redirects, so a 302 from
metube to its own /login counts as up. That is the same 302 I called useless
above, with one difference: here metube sent it, so metube is running.
This is all a monitor ever sees. The run is from the README, with one service up and one down:
$ curl -i localhost:8080/health/metube
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
ok
$ curl -i localhost:8080/health/jellyfin
HTTP/1.1 503 Service Unavailable
Content-Type: text/plain; charset=utf-8
unreachable
$ curl -i localhost:8080/health
HTTP/1.1 503 Service Unavailable
Content-Type: text/plain; charset=utf-8
OK metube
KO jellyfin
So the answer about one service is ok or unreachable. OK and KO appear
only in the list that /health returns, one line per service, and that list
answers 503 when any service on it is down. /health checks every service at
once, in parallel, so the list takes as long as the slowest service, not the sum
of all of them.
The setup: one Compose entry and one file
Link to section: The setup: one Compose entry and one fileAdd zond to the Compose file that runs your services, so it shares their network. Both blocks are from the README:
# compose.yml
services:
zond:
image: ghcr.io/spy4x/zond:latest
restart: unless-stopped
ports: ["8080:8080"]
volumes: ["./zond.yml:/app/zond.yml:ro,z"]
# zond.yml
targets:
- name: metube
url: http://hl-metube:8081/
A fuller zond.yml, from
docs/configuration.md:
# zond.yml
port: 8080
targets:
- name: metube
url: http://hl-metube:8081/
- name: ollama
url: http://hl-ollama:11434/api/tags
timeout: 10000 # ms, default 5000
- name: grafana
url: http://hl-grafana:3000/api/health
timeout: 3000
portis where zond listens, 8080 by default. TheZOND_PORTenvironment variable overrides it.nameis the last part of the URL:/health/metube. zond refuses to start when two targets share a name.urlis the address zond probes, usually the container's name on the Docker network.timeoutis how long zond waits for that service, in milliseconds. The default is 5000.
No file? One environment variable does the same, and it wins over a file if both
are set, with the default timeout for every service:
ZOND_TARGETS="metube=http://hl-metube:8081/,ollama=http://hl-ollama:11434/api/tags".
Then publish zond through your reverse proxy, outside the SSO, and point Gatus at it:
# Gatus
- name: Metube
url: "https://zond.example.com/health/metube"
conditions:
- "[STATUS] == 200"
Why zond has no login
Link to section: Why zond has no loginA login on zond would bring back the exact problem it solves: a credential for the monitor to keep alive. What can an outsider learn from it instead?
- Whether each service is up. Your status page says that already.
- The names you gave your targets, because
/healthlists them. Name them the way you'd name them on a public status page.
That is all. zond never returns an internal URL, a response body or a header
from the service. It is not an open proxy either: it looks the name up in its
own config
(internal/server/server.go),
answers 404 for a name it doesn't know, and only ever sends a GET to the URLs
you wrote down.
Where it runs
Link to section: Where it runszond runs on my home server. It feeds Gatus, and oko, my one-page dashboard, reads Gatus to draw my status page. The monitoring lane on how I run production shows that chain: zond, Gatus, oko, the dashboard.
What it costs to run
Link to section: What it costs to runThese numbers are not from my server. They come from a laptop, in a run you can repeat on any machine.
On 30 October 2026 I pulled the published image (ghcr.io/spy4x/zond:latest,
digest sha256:1ac711d5…, built 20 August 2026) and ran it with Podman beside
two test containers: a BusyBox web server as metube, and a container with
nothing listening as grafana, so every check had one service up and one down.
Then I sent 1,000 requests to /health, ten at a time. Each request probes both
services, so that is 2,000 probes. Memory comes from podman stats and the
container's cgroup memory.peak, three runs each.
For comparison I rebuilt the last Deno version (the
v1.0.0 tag)
from its own Dockerfile, which runs the code with deno run on the Deno Alpine
image, and measured it the same way.
| Measure | Go, published image | Deno, last version rebuilt |
|---|---|---|
| Download | 3.7 MB | about 49 MB |
| On disk | 10.1 MB | 117.2 MB |
| Memory, idle | 2.8 to 2.9 MB | 19.0 to 19.2 MB |
Memory after 1,000 /health |
9.4 to 11.2 MB | 46.8 to 48.0 MB |
| Peak memory | 12.7 to 13.1 MB | 49.7 to 50.1 MB |
Docker reports the same Go image as 9.1 MB on disk; the table uses Podman's
figures for both columns. The sample Compose file in
docs/self-hosting.md
caps zond at 32M of memory, and in these runs it never needed half of that.
Why I rewrote it in Go
Link to section: Why I rewrote it in GoThe first version was Deno and TypeScript. I rewrote it in Go in PR #1, merged on 14 July 2026, and kept the API and the config format as they were.
The PR's reason was Go's standard library, which covers everything zond does: routing by method and path, structured logs, parallel probes, timeouts and a clean shutdown. The only dependency left is the YAML parser. The result is one static binary in a distroless image that runs as a non-root user, with no runtime inside. The table above shows what that changes for a service that sits idle most of the day and answers one word.
Try it
Link to section: Try itThe source is at github.com/spy4x/zond, and the
tool page has a one-minute demo. The published image is
ghcr.io/spy4x/zond:latest, built for linux/amd64. On another architecture,
docker build . makes your own from the same Dockerfile.
Have a service behind your SSO that your monitor can't see? Add zond to its
Compose file, ask it /health/<name>, and see whether you get ok.
If you'd rather have someone set up monitoring like this for your product and keep an eye on it, that is part of Ongoing: 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.
Questions, or a different result? Reply by email: hi@antonshubin.com. It comes straight to me.
