Bassam Ismail
Personal

Making a home server usable with one dashboard

15 min read

For about a year, using my home server meant remembering things. Media was on one port, photos on another, the finance numbers were a script I ran by hand, and whether the laptop was awake was a question I answered by trying it. A server you have to remember is a server only you can use, and barely. The fix was not another service. It was a front door: one page that answers "is everything fine, and what is going on" without me logging into anything. This part is about that surface, and the two systems behind it that turned a pile of containers into something the household actually uses.

This is the fifth part of a series on running a home server on a spare MacBook. The earlier parts built the machine, reached it, and filled it with background jobs and media. This one is about making all of that legible from a single tab.

TL;DR

A home server you have to remember is barely usable. The fix is a single read-only dashboard (Glance) that aggregates service state, launchd-written JSON files, and Home Assistant into one browser tab without owning any logic or data. Launchd background jobs write small JSON files overnight; Caddy serves them as static files on subdomains; Glance polls those URLs and renders the numbers through Go templates. The value of a home server is gated by its interface, and one read-only page closed that gap more than any service added to the stack.

A dashboard is a read model, not a feature

The front door is a self-hosted dashboard called Glance. It serves at the apex of my domain, home.example.dev, which Caddy reverse-proxies to the container on glance:8080. The whole thing is one YAML file at ~/selfhost/glance/config/glance.yml, and Glance watches that file: save it and the dashboard reloads without a restart. It is a single page of widgets, or rather several pages of them: the media stack's recent additions, the next few calendar events, the monitors' up/down lights, the weather, and a small panel of my own money numbers.

The important design decision is what it is not. It is not an app I log into, it is not a place I take actions, and it does not own any data. It is a read model: a fast, glanceable projection of state that lives authoritatively somewhere else.

ONE PANE OF GLASS, THREE LAYERS DOWNTHE GLASSdashboardone browser tabWIDGETSmediamoneyhomemonitorsSOURCESservice APIslaunchd JSONhome automation[ the page reads; it never writes ]

That distinction matters because it decides where complexity lives. Every widget either hits a service's existing API or reads a small JSON file that one of the background jobs from part three already produces. The dashboard does no computation of its own. When the money panel shows a spending figure, it is reading the file the expense job wrote at 2am, not querying my bank in the moment. This keeps the page instant and, more importantly, keeps it from becoming a second place where logic can be wrong. There is one source of truth per number, and the dashboard is never it.

The payoff is subtle but real: because the dashboard is just a view, I can rebuild it, restyle it, or throw it away without touching anything that matters. Treating the glanceable surface as disposable is what lets me keep it simple.

The read-model pattern: JSON files behind Caddy

The trick that makes the whole thing cheap is that the dashboard never integrates with my finance tooling, or my battery monitor, or anything else with logic in it. It only ever reads files. The launchd jobs from part three already write small JSON files as a side effect of doing their real work: the expense job leaves an expenses.json, the portfolio job leaves a portfolio.json, the laptop's power watcher leaves a battery JSON. Those files are the contract. The dashboard does not know or care how they were produced.

To get a file in front of Glance, I do not write an endpoint. I bind-mount the file's directory into Caddy as a static file_server and give it its own subdomain. The Caddy service in my compose file picks up volumes like this:

  caddy:
    image: caddy:2
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./expenses:/srv/expenses:ro
      - ./portfolio:/srv/portfolio:ro

The :ro is doing real work there. Caddy has no business writing to these directories, and mounting them read-only means a misconfiguration cannot corrupt the source files. The matching Caddyfile block is about as plain as static hosting gets:

expenses.home.example.dev {
    root * /srv/expenses
    file_server
}

Now https://expenses.home.example.dev/expenses.json is a real URL serving a real file, and the dashboard is just an HTTP client like any other. The launchd job writes; Caddy serves; Glance polls. Three pieces, each of which can be tested on its own with curl. If the dashboard shows a stale number, I can curl the JSON URL and know in one step whether the problem is the writer, the server, or the view.

This is more plumbing than a turnkey dashboard would need, and that is the point. The plumbing is generic. Every new number I want to surface is the same recipe: have a job write a small JSON file, mount its directory, add a subdomain, point a widget at it. Nothing in the chain knows what the number means.

The custom-api widget, and its template gotchas

The widget that reads those files is Glance's custom-api type. It fetches a URL, parses the JSON, and renders whatever a Go template tells it to. The expenses panel looks like this:

- type: custom-api
  title: Expenses
  cache: 1m
  url: https://expenses.home.example.dev/expenses.json
  template: |
    {{ $dayD := .JSON.Float "day.debit" }}{{ $dayN := .JSON.Int "day.count" }}
    {{ $byCat := .JSON "byCategory" }}
    {{ range $cat, $amt := $byCat.AsMap }}{{ $cat }}: {{ $amt }}{{ end }}

The cache: 1m is what keeps the page honest about load. The file changes once a night, so polling it more than once a minute is wasted work; a short cache means several open tabs do not hammer Caddy. The template pulls a couple of scalars off the top, grabs a nested object, and ranges over it. That reads cleanly enough on the page. Getting there cost me an evening, because the template language has sharp edges that are obvious only in hindsight.

The first and most expensive lesson: the url: field does not support templates. I assumed I could interpolate a date or an account into the URL and write url: https://.../{{ ... }}.json. Glance treats the URL as a literal string, so the braces went out over the wire verbatim and the request 404'd. If you genuinely need a dynamic URL, you build the request in the template body instead, with the request-builder helpers: newRequest, withParameter, getResponse. The URL field is for the static base only.

The second lesson is about numbers. Use .JSON.Float for numeric fields, not .JSON.String. If you pull a number as a string and then try to do arithmetic on it (multiply a holding by a price, subtract one figure from another), you get string-concatenation bugs that look like wildly wrong totals rather than errors. Decide a field's type at the point you read it and the rest of the template behaves.

The third lesson is to know which helpers actually exist, because the ones you reach for by reflex from other template ecosystems are mostly absent. The helpers Glance does ship are enough to do real work:

parseTime, formatTime, toRelativeTime, offsetNow, now,
mul, div, sub, add, len,
findMatch, findSubmatch, sortByFloat,
replaceAll, printf, trimPrefix

The ones I instinctively tried and which do not exist:

list, dict, formatNumber, formatBytes, parseFloat, hasPrefix, abs

There is no formatNumber, so currency formatting is printf "%.2f". There is no parseFloat, because .JSON.Float already gave you a float. There is no hasPrefix, but trimPrefix plus a comparison covers the same ground, and findMatch covers the rest. There is no abs, so a sign flip is a mul by negative one wrapped in a condition. None of this is hard once you know the boundary. The trap is that the template fails quietly, rendering a blank panel rather than a stack trace, so an unknown helper looks exactly like a data problem until you go looking.

More than one kind of widget, more than one page

Not everything is a JSON file. Glance ships a range of widget types, and the dashboard uses most of them where they fit better than a hand-rolled custom-api:

WidgetWhat it showsSource
custom-apimy money numbers, batterylaunchd JSON over Caddy
monitorup/down for each servicedirect health checks
marketsindex and ticker quotesGlance's market feed
server-statsCPU, memory, disk on the boxlocal metrics
docker-containersrunning containers and statethe Docker socket
calendarthe next few eventscalendar feed
weatherlocal forecastweather provider
rssa couple of feeds I followRSS
bookmarkslinks to the services themselvesstatic config

These are spread across several pages rather than crammed into one. The layout, as of now, is a Home page, a Server page, a Media page, a family page, and a Reading page, with a Money page, a Smart Home page, and a Habits page added later as I had reasons for them. Pages cost nothing in Glance (they are just top-level keys in the same YAML), so the rule I follow is one page per question I actually ask: "is the box healthy" is the Server page, "what did I spend" is the Money page, and I do not mix them.

Home Assistant earns its place by being the hard one

The dashboard handles information. The physical home is a different problem, and it gets its own system: Home Assistant, running as one more compose service on homeassistant:8123 with its config bind-mounted at ./homeassistant:/config, reachable at ha.home.example.dev. I resisted it for a long time because home automation has a reputation for being a hobby that consumes the hobbyist. That reputation is earned. It is the most demanding service on the box, the one most likely to want an integration that needs a cloud account, and the one whose failure is most visible because a light that does not respond is more annoying than a chart that is stale.

The hardware it actually controls is modest, and deliberately so: a smart bulb and a smart plug, which show up as light.* and switch.* entities. I did not want a house full of automated everything. I wanted a couple of things I could toggle from the same tab I already had open.

The connection between Glance and Home Assistant is just the REST API. Home Assistant exposes GET /api/states to read the current state of every entity, and POST /api/services/light/toggle (also switch and scene) to act on them, all authed with a long-lived token I keep in the environment as HA_TOKEN. Flipping the desk lamp is one request:

curl -s -X POST https://ha.home.example.dev/api/services/light/toggle \
  -H "Authorization: Bearer $HA_TOKEN" -H 'Content-Type: application/json' \
  -d '{"entity_id":"light.desk"}'

On the dashboard, the Smart Home tab has inline toggle buttons wired to exactly those service calls. The state shown next to each button comes from /api/states; the button itself POSTs to the toggle service. This is the one place the dashboard appears to take an action, and it is worth being precise about why that does not violate the read-model rule. Glance is not deciding anything or storing anything; the button is a thin trigger that hands the decision straight to Home Assistant, which owns the state. The dashboard still reads. It just also has a couple of buttons that ask another system to do something.

Where you are willing to debug at 11pm

I keep Home Assistant tractable with one rule: it observes and switches, it does not decide anything consequential on its own. The genuinely important logic, the things that touch money or data, lives in the boring launchd scripts where I can read it top to bottom. Home Assistant gets the jobs where the worst case is a light staying on.

That division is not about capability. Home Assistant is perfectly able to run elaborate conditional automations, and that is exactly the temptation I am avoiding. The split is about where I am willing to debug at 11pm. A misfiring automation that only controls a lamp is a minor irritation I can fix in the morning. The same class of bug deciding whether to move money is a different category of problem, and I keep those categories physically separate: one lives in a system designed for automation, the other lives in scripts plain enough that I can audit them by reading.

The rule that keeps home automation from eating the weekend: let it observe and switch, never let it be the source of truth for anything you would be upset to get wrong.

The money view is the one I did not expect to trust

The panel I check most is the money one, and it is worth dwelling on because it is the clearest example of the whole pattern paying off. It shows three things: roughly what I have spent this month, my investment position, and whether the books balance. None of it is computed when I look. The expense job parsed the bank texts overnight and wrote expenses.json. The portfolio job pulled my holdings, marked them to market, and wrote portfolio.json. The ledger job reconciled receipts into double-entry. The dashboard just renders the three files they left behind, through three custom-api widgets that differ only in URL and template.

I was skeptical that a pile of regexes and cron-like jobs could produce numbers I would act on. What changed my mind was the double-entry ledger underneath. Because every transaction has to balance, a parsing mistake does not quietly produce a slightly wrong total. It produces books that do not balance, which is loud. The accounting discipline turns the fragile part (scraping messy real-world inputs) into something self-checking: I trust the figure not because the scraper is perfect but because the structure refuses to hide an error. That is the same lesson as the deletion guardrails from part four, in a different key. The trick is rarely to make the risky step reliable. It is to make its failures impossible to ignore.

There is a real cost here I should name. This whole arrangement is bespoke and undocumented in the way only a personal system can be. If I got hit by a bus, no one could pick it up. It works because I hold the whole shape in my head, the way the JSON contracts fit the templates, which subdomain serves which file, which token authorizes the toggles, and that is a single point of failure no amount of dashboard polish fixes. For a tool that serves exactly one household, I have decided that is an acceptable trade. For anything with more than one user, it would not be.

What the front door changed

Before the dashboard, the server was a thing I administered. After it, it became a thing we use. My partner checks the calendar widget. I check the money panel more than I check my actual bank app, because it is faster and tells me what I want to know without a login. The monitors panel means "is the server okay" is a glance, not an investigation. The Smart Home tab means the desk lamp is a button, not a walk across the room. None of the underlying services changed. What changed was that their state became visible in one place, and visibility is most of what "usable" means.

The mental model I keep from this: the value of a home server is gated by its interface, not its capabilities. I had all of this running for months and got a fraction of the use out of it, because using it required remembering it. One read-only page closed that gap more than any service I ever installed. The last part of the series is about the two things that let me actually rely on the machine: a local language model that keeps my data on the box, and the monitoring and backups that mean I find out about a problem before the household does.

FAQ

How do I set up a self-hosted home server dashboard with Glance?

Run Glance as a Docker container, reverse-proxy it with Caddy to your apex subdomain, and configure everything in a single glance.yml file. Glance hot-reloads on save, so you can iterate without restarting the container.

Why does the Glance custom-api widget show a blank panel instead of an error?

Glance templates fail silently: an unknown helper or a type mismatch renders a blank panel rather than a stack trace. Check that you are using .JSON.Float for numeric fields and that every helper you call (like formatNumber or hasPrefix) is actually in Glance's shipped list, not borrowed from another template ecosystem.

Can I use a dynamic URL in a Glance custom-api widget?

No. The url field is treated as a literal string, so template braces are sent over the wire verbatim and will 404. If you need a dynamic request, use the request-builder helpers (newRequest, withParameter, getResponse) inside the template body instead.

How do I expose a local JSON file to a Glance dashboard widget?

Bind-mount the file's directory into Caddy as read-only, add a file_server block in your Caddyfile for a dedicated subdomain, then point the Glance custom-api widget's url field at that subdomain. The launchd job writes the file, Caddy serves it statically, and Glance polls it, so each piece can be tested independently with curl.

How do I connect Home Assistant to a Glance dashboard without violating the read-model pattern?

Use Home Assistant's REST API: GET /api/states to read entity state into a custom-api widget, and POST /api/services/light/toggle (or switch/scene) for inline toggle buttons, authenticated with a long-lived token. Glance is still just an HTTP client; Home Assistant owns the state and makes the decision.