
How I built a public-facing live dashboard for my homelab — Prometheus scrape pipeline, custom Grafana panels, cyberpunk neon theming, and the small disasters along the way.
Published on May 05, 2026 by Kyle S
grafana prometheus observability monitoring proxmox truenas devops dashboarding cyberpunk css
8 min READ
▸ See it live: grafana.kyhomelab.com — real metrics from my actual lab, updating every 30 seconds.
I had a problem with my portfolio site. The projects looked fine, the writing was solid, but everything was static — screenshots, descriptions, links to GitHub. None of it actually did anything. A recruiter clicking through couldn’t see whether the lab I wrote about even existed anymore, let alone whether it was healthy.
So I decided to build something that solved that: a live, public-facing dashboard that pulls real-time metrics from my homelab and renders them on a page anyone can visit. No login. No screenshots. Just current numbers, updating every 30 seconds, straight from the actual hardware.
This post is how I built it, why I made the design choices I did, and a few of the small fires I had to put out along the way.
A screenshot tells you what something looked like at one point in time. A live dashboard tells you what’s true right now. For a cybersecurity portfolio, that distinction matters — anybody can stage a clean screenshot, but a dashboard that’s been running continuously for 78 days is harder to fake.
There’s also a practical angle: I wanted to demonstrate the full stack of skills that go into modern infrastructure observability. PromQL, exporter selection, dashboard design, public-vs-private security boundaries, alert engineering. A dashboard is a single artifact that touches every one of those.
And honestly? It was the most fun project I’ve worked on in months.
Architecture turned out to be straightforward:
[Proxmox host]
└─ node_exporter ← system metrics (CPU, RAM, disk, net)
└─ PVE API token ← read-only, auditor role
[TrueNAS — docker compose stack]
├─ prometheus-pve-exporter ← translates PVE API → Prometheus metrics
├─ prometheus ← time series database, 30s scrape, 90d retention
├─ node-exporter (host PID) ← TrueNAS host metrics including ZFS
└─ grafana ← visualization + public dashboard sharing
Three exporters feeding one Prometheus server feeding one Grafana instance. Standard.
The whole thing lives in /mnt/HDD-18tb/Docker/monitoring/ on TrueNAS as a single docker-compose.yml — easy to back up, version, and recreate. The Proxmox API token has only the PVEAuditor role, so even if the token leaks, all anyone can do is read metrics.
I wanted the dashboard to tell a story top to bottom, not just be a pile of charts. So I structured it like a status page:
Building this in Grafana was satisfying in a way I haven’t felt since the first time a SIEM dashboard lit up correctly. Every PromQL query I got right unlocked another panel. By the end I had something like 30 panels working off four scrape jobs.
The default Grafana theme is fine. But this is a cybersecurity portfolio, and the dashboard had to look the part.
I built a six-color cyberpunk palette: neon cyan for healthy/Proxmox, hot magenta for TrueNAS, electric yellow for highlights, neon green for OK states, amber for warnings, hot red for critical. Then I mapped every threshold in the dashboard to those colors.
For visual punch I added:
text-shadow: 0 0 20px currentColorTo inject custom CSS at all, I had to flip Grafana’s GF_PANELS_DISABLE_SANITIZE_HTML=true env var. That sounds scarier than it is — it just lets text panels include raw <style> and <script> tags. For a single-author dashboard with no user-submitted content, the risk is fine.
Nothing this layered comes together without a few facepalm moments.
The retention setting that almost ate my data. First night the stack ran, I configured Prometheus with --storage.tsdb.retention.time=1d because I was copy-pasting examples and didn’t read carefully. The next morning my “longest runtime” panel showed 0s. It turned out my Prometheus had been faithfully deleting everything older than 24 hours, including the boot-time samples I needed for the SLO calculations. Fixed with --storage.tsdb.retention.time=90d and a restart. Now I have three months of history, which is plenty.
The gauge that thought 94 GB was 94 percent. I built the memory utilization gauge with the query node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes and set the gauge max to 100. The gauge showed 94,000,000,000 and pegged the needle. Took me ten minutes of staring before I realized I’d skipped the percentage math entirely. The actual query needs to be 100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes), which I now have memorized at the muscle level.
Public dashboards have their own rules. I built the dashboard, looked great, hit “share publicly”, opened the URL, and watched every panel render NULL. Turns out Grafana’s public dashboard renderer requires an explicit datasource: { uid: "..." } reference on every panel and every target — even though the authenticated UI auto-resolves the dashboard’s default datasource. The error message in the Grafana logs said invalid data source identifier datasources=[public-ds], which finally pointed me at the fix. Patched all 30+ panels with explicit datasource refs and everything came alive.
The grey box around my gradient text. This was the most annoying bug. I added background-clip: text to make my gradient title text show through, and immediately every styled element grew a grey rectangle around it. Spent thirty minutes in the browser inspector convinced it was a box-shadow problem. It wasn’t. It was that background-image: linear-gradient(...) doesn’t reset the element’s background-color, so the inherited grey from Grafana’s default styling was filling everything outside the text glyphs. One line fix: background-color: transparent !important. Lesson learned about CSS specificity, again.
The CSS-in-JS class name lottery. Grafana 13 uses Emotion for styling, which generates random class names at runtime. My first round of CSS selectors targeted things like .dashboard-row__title — a class that exists in the source code but never makes it to the browser as a stable selector. After trying half a dozen attribute selectors with [class*="..."] patterns, I gave up and replaced the row dividers with plain text panels containing inline-styled HTML. Way more control, zero specificity battles.
For a portfolio dashboard, the technical surface I wanted to show:
max_over_time((expr)[30d:5m])), regex matching, aggregations, range vectorsavg_over_time(up[24h]) * 100 for rolling availabilitynode_zfs_zpool_state{state="online"}The dashboard is live at grafana.kyhomelab.com, exposed via a Cloudflare tunnel from TrueNAS. The root URL redirects to the public dashboard share token, so anybody hitting that URL lands directly on the metrics view — no login, no admin exposure. Admin paths (/login, /admin) are gated behind a Cloudflare Access policy that only authorizes my email.
A few follow-ups on the list:
If you want the live view, click the Live HomeLab button on the portfolio homepage or hit grafana.kyhomelab.com directly. If you’re building something similar and want to compare notes, my GitHub is kyhomelab.
Most fun I’ve had with infrastructure code in a while.