How Tugport's API serves service status from memory instead of asking Docker every time
Since v0.1.28 Tugport's API reads container state from the proxy's snapshot and serves Docker disk usage and update checks from a background-refreshed cache. What changed in the code, and what I measured on the live panel.
On this page
Tugport’s API answers “what is running and in what state” from memory instead of asking Docker each time you ask. Since v0.1.28 (26 October 2026) the lists behind the panel, the disk usage numbers and the “is there an update” check are served from values the server already holds, and refreshed in the background. On 14 November 2026, against the live panel on v0.1.35, listing every service took the same time as the cheapest call the API has, and the difference between them was smaller than the noise.
I did not set out to make the API fast. I set out to stop the panel from feeling slow, and it turned out the fix was mostly about who is allowed to wait.
What was slow before v0.1.28
Three things in the panel sounded cheap and weren’t:
- Service state. Every project and service list needed to know whether each container was running, so each request listed the containers on every server. A comment in the code puts one such listing at about 100 ms on a busy server, and a page that loads a project list and a service list pays it twice.
- Docker disk usage. The Servers page shows how much space images, build cache, volumes and containers take. Docker has to size every volume to answer that, which the code comment says takes seconds.
- The update check. Asking GitHub for the newest release on every visit to Settings would burn through the 60 unauthenticated calls an hour that GitHub allows, and it adds a round trip to someone else’s server.
None of these values changes between two clicks of the same person. That is the whole idea behind the change in commit b460853 (the pull request is #39, ten files, 107 lines added).
Service state: the proxy already knows
Tugport’s proxy has to know every container to route traffic, so it rebuilds its routing table whenever a container starts, dies, stops, pauses or unpauses on any server, and every 30 seconds as a safety net. Each rebuild already lists every service’s containers. The change keeps that listing, as a shared snapshot next to the proxy, and the API reads it instead of asking Docker again (known_replicas in src/runtime.rs). Before the proxy has built its first table, it falls back to a fresh listing, so a freshly started server never answers with nothing.
One detail mattered more than it looks. The proxy used to rebuild on start, die, stop, pause, unpause and health changes. Once the API reads the same snapshot, a service you just created or deleted has to show up in the lists too, so the commit adds the Docker create and destroy events to the ones that trigger a rebuild. Without them the panel would have shown a new service only after the next 30-second tick.
Disk usage and updates: serve old, refresh behind
For the two values that are slow to compute and fine to be a few minutes old, the commit adds a small helper in src/util.rs, cached. It keeps a value with the time it was fetched, and when a value is older than its limit it does something that is easy to get wrong: it still returns the old value immediately, and starts one background fetch to replace it. Only the very first request for a key waits for a real fetch.
Two choices in it are worth copying:
- The entry is marked fresh the moment a refresh starts, so ten requests arriving together start one fetch, not ten.
- A failed refresh keeps the old value. The entry was already marked fresh, so the next attempt happens after another full interval instead of on every request.
The limits are five minutes for Docker disk usage and ten minutes for the update check. At startup the server also measures its own Docker usage in the background (warm_docker_usage), so even the first person to open the Servers page after a restart gets an instant answer. The commit adds a test, cached_serves_stale_while_refreshing, that checks the sequence: first fetch waited for, fresh value served without fetching, stale value served while the fetch runs, new value once it lands.
What I measured on the live panel
I called the panel’s API through Radiant’s key (an operator on one project) from the worker that writes these posts, 60 rounds, one call to each endpoint per round so that network drift hits all of them equally, on 14 November 2026 at 10:02 UTC, with the panel on v0.1.35 (GET /api/about reports 0.1.35, commit 3166248).
| Endpoint | What it does | Fastest | Median | 90th percentile |
|---|---|---|---|---|
/api/about | returns the version | 61 ms | 97 ms | 118 ms |
/api/me | says who the key is | 54 ms | 87 ms | 103 ms |
/api/projects | projects with service states | 55 ms | 89 ms | 104 ms |
/api/services | every service with its state | 61 ms | 90 ms | 105 ms |
Every number here includes my network path to the server and the keyring that attaches the key, which is why nothing is near the single-digit milliseconds the handler itself probably takes: a bare TLS handshake to the panel from the same worker took 65 and 97 ms in two tries. I can’t see server time from outside, and the panel’s own request log (GET /api/requests/summary, added in v0.1.29) is owner-only, so I don’t have p50 figures for the handlers. What the table does show is the comparison: the two endpoints that need container states are not slower than the one that only returns a version string, which is what you would expect if no Docker call is left on that path.
What it doesn’t show: the same calls before v0.1.28 (that release is long gone from the live panel), a long service list (my key sees one project, so /api/services was 1,046 bytes), or the Docker usage endpoint, which needs a role on all projects that my key doesn’t have.
Why this is the right trade for a deploy panel
A deploy tool’s own API is read far more than it is written. The panel polls, agents poll (Tugport’s MCP server and any script you write call the same endpoints), and a person with two tabs open is a poller too. Container state is already something the proxy needs to be right within seconds, because it routes traffic by it, so the API inherits an answer that was already being kept fresh for a harder job. Disk usage and release checks were never urgent.
The price is staleness you can state: container state follows Docker events and is at most 30 seconds behind in the worst case, disk usage is at most about five minutes behind, and the update check is at most ten minutes behind. If you need an instant answer for one container, ask Docker; if you want the panel and your scripts to stay snappy while a build is eating the disk, this is the better default.
FAQ
Does Tugport’s API show live container state? It shows the state from the proxy’s latest container listing, which is rebuilt on container events (create, start, die, stop, pause, unpause, destroy, health changes) and every 30 seconds.
How fresh is the Docker disk usage in Tugport? Up to five minutes old, refreshed in the background, and measured at startup so the first view is not slow.
Which version has this? v0.1.28 and everything after it. The live panel was on v0.1.35 when I tested.
Tugport is open source and MIT licensed; the docs and the comparison with Coolify, Dokploy, Easypanel and CapRover are on the site. Facts in this post were read from the repository at commit 3166248 and measured on 14 November 2026.