Real Screenshots, Zero Leaks: My Disposable Demo Lab

How this site captures real, publish-safe homelab screenshots — a throwaway demo lab plus headless Chrome, with no mockups and no leaked IPs.

On this page
  1. Why real screenshots (and why not the real lab)
  2. Task 1: Build the throwaway lab
  3. Task 2: Capture with headless Chrome
  4. What this buys you
  5. What’s next

Every screenshot on this site is real — real dashboards, real uptime bars, real graphs with a day of actual history in them. And none of them show my lab. That sounds like a contradiction, so this post is me opening the workshop door: the whole pipeline is a throwaway container running a small monitoring stack seeded with dummy values, left alone until it looks lived-in, then photographed by a script driving headless Chrome. When I have what I need, the environment gets deleted. Nothing real was ever in frame, so nothing real can leak.


Why real screenshots (and why not the real lab)

For a long time this site illustrated UI walkthroughs with hand-drawn mockups, and I’m still swapping the older ones out post by post. They were fine. They were also lies of a small kind — a mockup shows what I remember an interface looking like, not what it looks like. Real captures come with the texture that makes a tutorial trustworthy: believable timestamps, graphs with actual wobble, the exact button labels of the version I told you to install.

The obvious move — screenshot the production lab — is the one I won’t do, and I’d nudge you away from it too. A dashboard screenshot is a little map of your network: internal IP addresses, hostnames, service versions, sometimes a username in the corner. This is a public site, and my standing rule is that real infrastructure details never appear on it. I wrote about that boundary in the monitoring setup guide, and it applies double to images, because images are easy to forget during review.

So the answer is a third path: a disposable demo lab. Real software, fake identity.

The disposable demo-lab pipelinereal software, fake identity — then delete the evidence1 · Throwaway containerHomepageGrafana + PrometheusUptime Kumaseeded with dummy values10.0.0.x · homelab.lan · youradmin2 · Let it livehistory accrues: ~17 min3 · Capture scriptpuppeteer-coresystem Chrome, headlessviewport sized to content · 2× scale4 · Sanitized PNGs → post embedeyeball at full size · security scan · committhen: destroy the labnothing real ever existed here

With the idea on the table, let’s build one.


Task 1: Build the throwaway lab

First: make these values your own

Everything machine-specific in this post is a deliberate placeholder. Swap in your own values as you go: 10.0.0.x addresses and homelab.lan are documentation stand-ins for whatever your demo box actually uses; youradmin / CHANGE_ME_demo_only are the demo credentials (pick your own, and keep even demo passwords out of your notes); $DEMO_HOST is the IP or hostname of your demo container, which you set as an environment variable rather than writing into any file. Rule of thumb: if a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.

1Spin up a container you don't care about10 min

The demo lab wants to be the cheapest, most deletable thing you can make. On Proxmox that’s an LXC container — I covered the general recipe in creating your first LXC container. A VM, a spare mini PC, or a cloud VPS all work equally well. The only requirements: Docker installed, reachable from the machine where you’ll run the capture script, and no connection to anything real. No mounts from your NAS, no credentials from your password manager, no real DNS names.

That last part is the whole trick. Sanitizing screenshots after the fact means hunting for leaks in pixels; building the environment from dummy values means there’s nothing to hunt. Safety by construction beats safety by inspection.

2Compose a small monitoring stack with a fake identity15 min

Inside the container, a single Docker Compose file runs the cast: Homepage as the dashboard, Grafana with Prometheus behind it, and Uptime Kuma doing up/down checks — the same stack from my first Docker Compose stack walkthrough, wearing a costume.

Seed every config with the dummy identity and nothing else. Hostname labels like pve-node1.homelab.lan, addresses from 10.0.0.x, the admin user youradmin. When a Homepage widget wants a “server” to display, it gets a fake one. It feels silly typing in a made-up network — but that made-up network is exactly what makes every future capture publish-safe.

One detail pays off later: when Uptime Kuma needs to monitor its stack-mates, point it at Compose service names, not localhost. Compose puts every service on a shared network where each container is reachable by its service name through a built-in DNS — so a monitor URL like http://homepage:3000 works, survives container restarts, and reads perfectly in a screenshot. A localhost URL, on the other hand, points at Kuma’s own container and quietly fails forever. I learned that the embarrassing way and wrote it up in the Docker localhost trap.

3Let it accrue real history17 min – 1 day

A freshly deployed dashboard looks freshly deployed: empty graphs, stubby uptime bars, suspicious perfection. The fix costs nothing — walk away.

Uptime Kuma builds each monitor’s heartbeat bar from its check history, so at a 20-second interval a full-width bar accrues in about 17 minutes. Grafana panels querying Prometheus want longer; I let the stack idle for a day so the 24-hour views show genuine curves with genuine wobble. This waiting is what separates “real screenshot” from “technically-not-a-mockup.” The pixels you’re waiting for can’t be faked quickly, and readers’ eyes know it.


Task 2: Capture with headless Chrome

Screenshots by hand are a chore you’ll do once and never redo — and screenshots go stale every time a UI ships a redesign. So the captures are a script. Mine is checked into the site repo (tools/screenshots/), and the shape of it is small enough to describe completely.

1Drive the Chrome you already have10 min

The script uses Puppeteer — the Chrome team’s browser-automation library — in its puppeteer-core flavor. The difference matters on a laptop: the full puppeteer package downloads its own private copy of Chrome, while puppeteer-core downloads nothing and drives a browser you point it at with an explicit executable path. Since my machine already has Chrome, the whole dependency is one small npm package.

Modern Chrome also makes the “headless” part boring, in the best way: headless and headful are now the same unified browser, not the separate stripped-down implementation of years past — so what the script captures is what a reader’s actual Chrome would render.

The launch, condensed from tools/screenshots/shots.mjs

import puppeteer from 'puppeteer-core';

const browser = await puppeteer.launch({
executablePath: '/usr/bin/google-chrome',
headless: 'new',
args: ['--no-sandbox', '--disable-gpu',
       '--force-device-scale-factor=2', '--hide-scrollbars'],
});

const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 700, deviceScaleFactor: 2 });
await page.goto('http://$DEMO_HOST:3000/', { waitUntil: 'networkidle2' });
await new Promise(r => setTimeout(r, 4000)); // widgets hydrate
await page.screenshot({ path: 'homepage-dashboard.png' });

Two choices in there carry most of the quality:

  • deviceScaleFactor: 2 renders at double pixel density, so the PNG stays crisp when the site serves it on high-DPI displays.
  • The viewport is sized to the content, not to a monitor. A 1000-pixel-tall capture of a 500-pixel-tall UI is half blank background inside the frame. My targets each get their own height — 700 for Homepage and Prometheus, 640 for Kuma, 1000 for a full Grafana dashboard.
2Script the login flows too15 min

Grafana and Uptime Kuma sit behind login screens, so the script types the demo credentials into the form fields, submits, waits for the app to settle, then navigates to the page worth photographing. For Grafana that’s a dashboard URL with an explicit time range (from=now-24h&to=now) — pinning the range means re-running the script next month produces the same framing. Then a deliberate pause: Grafana needs several seconds to run its panel queries and draw, and a screenshot taken too eagerly captures spinners instead of graphs.

There’s a companion script for seeding Uptime Kuma itself (kuma-admin.mjs), because clicking “add monitor” eight times per rebuilt demo lab gets old. Kuma has no REST API for this — its REST endpoints are read-only, and write access remains an open feature request — while the web UI talks to the server over socket.io. So the script speaks that protocol directly: connect, authenticate, emit add events for each monitor. One hard-won detail from building it: the version I ran (Uptime Kuma 2) pushes its monitor list during login, so a listener attached after login has already missed the event and waits forever. Attach first, then log in. That one cost me a hung script and a debugging loop.

3Review, embed, destroy10 min

Before any capture is committed, three checks, in order:

  1. Eyeball every image at full size. Not thumbnails — actual size. You’re looking for anything you didn’t deliberately plant: a hostname in a corner, a browser autofill, a notification toast.
  2. Run the repo’s security scan, which greps for the real subnets, usernames, and domains that must never appear. The scan is the backstop; the dummy-valued environment is why it comes up empty.
  3. Confirm the visible URLs tell the dummy story. Kuma’s monitor URLs read http://homepage:3000 — a service name, leaking nothing. The browser-chrome frame around each embedded image gets a hand-written dummy URL, never the capture address.

Then the images land in the post through a small Screenshot component that wraps each one in a browser-style frame with a caption. And when the shot list is done, the demo lab is deleted. Rebuilding it later from the compose file and the seeding script takes minutes — the scripts are the lab; the container is just where they ran last.


What this buys you

The pipeline sounds like ceremony for a few PNGs, so here’s the honest accounting. Cost: one small container, two short scripts, and some waiting. In return —

  • Publish-safe by construction. No real value ever entered the environment, so no review slip can leak one.
  • Repeatable. UI redesigns stop being a re-screenshotting chore; rebuild the lab, run the script, get a fresh consistent set.
  • Honest. Every capture is a working deployment with real history — if the screenshot exists, the tutorial ran.

If you write about your own lab anywhere public — a blog, a wiki, even a Discord — the demo-lab pattern is worth stealing. Your future self, staring at a screenshot at midnight wondering whether that corner label was real, will thank you.


What’s next

The same throwaway-environment idea scales beyond screenshots: it’s a safe place to rehearse upgrades and test tutorials before running them against machines you care about. If you’re building the monitoring stack itself, start with Uptime Kuma and graduate to Grafana and Prometheus.


Related posts:

Sources: Puppeteer — installation and puppeteer-core, Puppeteer — Page.setViewport, Chrome headless mode (Chrome for Developers), Docker Compose networking, Uptime Kuma (GitHub), Socket.IO client API, Proxmox Linux Container documentation.

Comments

Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.