Live numbers on a static site with cron-generated JSON
Blog · self-hosting

Live numbers on a static site with cron-generated JSON

One JSON file, one small loader, and four mistakes: a constant that beat the live value, a leaked error, invented facts and a guessed figure

On my homepage, the number of running Kubernetes pods read 141, 159 and 154 within a single hour. Any figure I typed into the HTML was wrong before I had finished deploying it.

My public site, www.shuffle-on.com, is plain static HTML served by nginx. It describes a lab that changes all day: a research library, honeypot sessions, threat-intelligence dossiers, models, cluster size. Since June 2026 every number on it comes from one JSON file that a cron job regenerates every 30 minutes, and a 51-line script fills the numbers into the page. No framework, no API server, no database connection from the browser.

It sounds trivial. It still produced four mistakes worth writing up: a live value silently replaced by a constant for a month, an internal error message published to the internet, AI-written copy that invented facts, and a hardware figure I got wrong on a page that asks for donations.

Contents · 10 sections

TL;DR #

  • Generate one JSON file of public numbers on a schedule; let a tiny script put them into elements marked with data-stat.
  • Keep a fallback value in the HTML, so the page reads correctly without JavaScript or when the JSON fails.
  • Write the JSON atomically and serve it with Cache-Control: no-store.
  • When a constant and a live value share a key, the merge order decides who wins. Constants must be fallbacks only.
  • Publish error types, never error messages.
  • Every number and claim on a public page must be checked against live data, especially text written by an AI.

The design #

Three pieces.

A generator. A Python script queries the systems behind the site: vector collections, the dossier database, the web-threat API, the model server, the cluster. It merges the results into one dictionary and writes site-stats.json into the web root. Cron runs it every 30 minutes. It started with 47 keys in June; today it has 102 keys.

Markup. Every number on a page is a span with a key and a fallback:

<p>The library holds <span data-stat="library_works" data-format="short">120,000</span> works,
   with <span data-stat="osint_documents">0</span> OSINT documents indexed.</p>

A loader. One shared script fetches the JSON and replaces each fallback:

(async () => {
  try {
    const r = await fetch('/site-stats.json?t=' + Date.now(), { cache: 'no-store' });
    if (!r.ok) return;
    const d = await r.json();
    document.querySelectorAll('[data-stat]').forEach(el => {
      const v = d[el.dataset.stat];
      if (v === undefined) return;
      let s = typeof v === 'number' ? v.toLocaleString('en-US') : String(v);
      if (el.dataset.format === 'short' && typeof v === 'number' && v >= 10000)
        s = v >= 1e6 ? (v / 1e6).toFixed(1) + 'M' : Math.round(v / 1e3) + 'K';
      if (el.dataset.suffix) s += el.dataset.suffix;
      el.textContent = s;
    });
  } catch (_) { /* keep the fallback values */ }
})();

nginx serves the JSON uncached:

location = /site-stats.json {
    add_header Cache-Control "no-store, max-age=0" always;
    default_type application/json;
}

And the generator never leaves a half-written file for a visitor to fetch:

tmp = OUT_PATH + ".tmp"
with open(tmp, "w") as f:
    json.dump(stats, f, indent=2, ensure_ascii=False)
os.replace(tmp, OUT_PATH)   # atomic on the same filesystem

That is the whole system. Social share images use the same JSON: a script renders 1200 by 630 cards with the current numbers, so a link shared on LinkedIn shows today’s figures, not last spring’s.

Mistake 1: the constant that beat the live value #

Some numbers cannot be queried cheaply, so the generator has a SNAPSHOT dictionary of hand-maintained values. The site marks them with a small camera icon so readers know they are point-in-time.

In August I found that a live query for the number of MCP tools had been sitting in the code for a month, undeployed, and was also broken. The code read:

stats.update(live_values())
stats.update(SNAPSHOT)      # "fallback" -- but update() overwrites

The comment said the snapshot was a fallback. The order made it an override: any key present in both was replaced by the old constant. To make it worse, two copies of the script had drifted apart.

The fix is one word, and the habit behind it:

for k, v in SNAPSHOT.items():
    stats.setdefault(k, v)  # only fills keys the live queries did not produce

Both copies are now identical. And after adding a live key, I check that its value actually lands in the published JSON, instead of trusting the code that should put it there.

Mistake 2: an error message on the internet #

In October a database query in the generator failed, and the exception text went straight into the public JSON. Exception text is written for developers: it contained an internal DNS name and a database username. Anyone opening /site-stats.json could read it.

Now every query is wrapped the same way. The public file gets the exception type; the full text goes to a private log with mode 600:

def _private_err(where, e):
    fd = os.open(os.path.expanduser("~/.local/state/site-stats.log"),
                 os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
    with os.fdopen(fd, "a") as f:
        f.write(f"{datetime.now().isoformat()} {where}: {type(e).__name__}: {e}\n")
    return type(e).__name__          # e.g. "OperationalError", safe to publish

try:
    out.update(dossier_stats())
except Exception as e:
    out["dossier_error"] = _private_err("dossier", e)

The rule generalises: anything you publish automatically must be built from fields you chose, never from free text a library produced.

Mistake 3: plausible facts, invented #

The October redesign of the site was partly written with AI agents. Reviewing their copy, I found claims that read well and were false: a dossier count of 8,969 where the real number was 817, the wrong model family named as the one running the agent, a location detail that was simply made up, and a “$0 cloud bill” line nobody had measured.

None of these were malicious. A writing agent fills gaps with text that fits the paragraph. That is exactly what you do not want on a page about verifiable infrastructure.

Two rules came out of it. Numbers on public pages must be bound to data-stat, so the page cannot state a figure the data does not contain. And every sentence of agent-written copy is reviewed line by line against live data before deployment.

Mistake 4: the figure I inferred #

The support page asks visitors to help pay for the hardware. In August I published the graphics card’s memory as 16 GB. The card had 8 GB.

I had run the GPU tool for the card’s name and power limit, but never queried the memory field. The model ships in both sizes, and I filled in the one I expected. The lesson I wrote down that day: having run the tool is not the same as having queried the field. A number that was inferred must not be presented in the same voice as one that was measured, least of all on a page that asks for money.

Facts or counters? #

Not every number should be a live counter. When I added sections about younger services (OCR, translation), a live tile reading “148 pages” undersold the machinery behind it. Those sections now carry short fact tiles (“two models”, “page-exact proof”) that explain how the service works, and the big live numbers stay in the hero strip, where they are impressive and stable. A small, honest, live number is still better than a large, stale, typed one; but you can choose where numbers appear at all.

Checklist #

  • One generated JSON for all public numbers, on a fixed schedule.
  • Fallback text in the HTML for every number.
  • Atomic writes and no-store caching for the JSON.
  • Constants merged with setdefault, never update, and marked as snapshots on the page.
  • A private log for errors; only error types in public files.
  • After adding a key, verify it appears in the published file.
  • Review AI-written copy line by line; bind its numbers to data.
  • Query every figure you publish; never infer one.

Frequently asked questions #

How do I show live numbers on a static website without a backend? #

Generate a JSON file on a schedule (cron or a systemd timer) and serve it as a static file next to the pages. A small script fetches it and fills elements marked with a data attribute. The site stays static and cacheable; only the JSON changes.

Why not query the database directly from the page? #

It exposes an API to the internet, multiplies load by visitors, and turns every outage into a broken page. A pre-generated file shows the last good numbers, and the page still renders without it.

How do I avoid leaking secrets in generated public files? #

Build the output only from fields you chose. Catch exceptions and publish their type, not their message. Keep the full error in a private, permission-restricted log.

Is it bad SEO if numbers are filled in by JavaScript? #

The fallback text is in the HTML, so crawlers that do not run scripts still see a sensible number. Keep fallbacks reasonably current.

The lesson #

Static sites are wonderfully boring, and that is the point. The interesting failures were never in nginx or in the 51-line loader. They were in the places where a human or an agent decided what a number should be: a merge order, a copied exception, a confident paragraph, a memory size guessed from a product name. Let the data speak for itself on the page, and make every exception to that rule visible.