Guide · Custom dashboard

How to build your own dashboard page for a TV and keep it private

Sometimes the best dashboard for the wall is one page you wrote yourself: six big numbers, a name, a clock. No BI tool, no login, no menus to hide. This guide covers the design rules for a screen three metres away, a starter page you can copy, a one-minute data feed, hosting it with Caddy behind basic auth, and adding it to DisplayOps so the screen signs in by itself.

Updated 2026-09-0811 minute readAny web server; examples use Caddy 2.8 or newer (2.11 is current)

When to write your own page

Grafana, Metabase and the rest are built for a person at a desk with a mouse. Their dashboards on a TV are a compromise: small text, legends nobody can read, filters nobody can change. If what the wall needs is a handful of numbers from three different systems and a line saying who is on call, a single HTML file is quicker to write than the dashboard would be to tame, and it will look exactly the way you want.

The catch was always privacy and upkeep. A page with your order numbers cannot sit on the open internet, and a screen cannot type a password. That is the part DisplayOps solves: you put the page behind HTTP basic auth, store the credential on the content item, and the screen answers the browser's prompt for the page and everything it loads.

Before you start

  • Somewhere to host static files that the screens can reach: a NAS, a VPS, a server you already run.
  • A hostname for it, with HTTPS if screens reach it over the internet.
  • A way to get your numbers: an API, a database, a script.
  • A supported Raspberry Pi and a free DisplayOps account.

When you are done

  • A dark, TV-sized page with six tiles and an "updated" stamp.
  • A script that refreshes its data every minute.
  • The page behind a username and password only your screens know.
  • A screen that signs in, reloads and recovers by itself.

Step 1Design for a TV, not a laptop

Everything that makes a web page good on a laptop is wrong on a wall. These eight rules are the difference between a dashboard people read and a screen they stop noticing.

RuleWhy and how
Size for the distanceA 1080p TV viewed from three metres needs body text of at least 28 px and headline numbers of 100 px or more. Use vh units so the page scales with the screen: 3vh is about 32 px on a 1080p TV.
Never scrollLay the page out on a grid that fills exactly 100vh and set overflow: hidden. If it does not fit, it is two pages; rotate them with a slideshow.
Respect overscanMany TVs crop 2 to 5 percent of the edges. Keep a 3vh / 3vw margin, and set the TV's picture mode to "Just scan", "Screen fit" or "1:1" if it offers one.
Dark and high contrastA dark background with light text is easier on a bright TV and hides dust on the panel. Avoid pure white at full size; use an off-white such as #E6EAF2.
Colour plus a wordRed and green alone fail for one in twelve men. Pair colour with a label or a shape: "3 down", not just a red number.
Five-second readThe wall is glanced at, not studied. One message per tile, no legends, no hover, no animation that needs attention.
Show the timeAn "updated 14:32" stamp is the difference between a dashboard and a frozen picture. Turn the stamp amber when the data is older than it should be.
Own your assetsSelf-host fonts and scripts instead of loading them from a CDN. Behind basic auth and on a flaky network, third-party requests are the first thing to break.

Step 2The starter page

One file, no build step, no dependencies. It draws six tiles on a 1080p grid, fetches data.json from the same folder every minute, and turns the header amber when the data is older than five minutes or the fetch fails.

  1. Save it as index.html

    /srv/wall/index.html
    <!doctype html>
    <html lang="en">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>Ops wall</title>
    <style>
      html, body { margin: 0; height: 100%; overflow: hidden; background: #0B1220; color: #E6EAF2;
                   font-family: system-ui, sans-serif; }
      main { box-sizing: border-box; height: 100vh; padding: 3vh 3vw; display: grid; gap: 2vh 2vw;
             grid-template-columns: repeat(3, 1fr); grid-template-rows: auto 1fr 1fr; }
      header { grid-column: 1 / -1; display: flex; justify-content: space-between; font-size: 3vh; color: #8593AD; }
      .tile { background: #111A2E; border-radius: 2vh; padding: 3vh; display: grid; align-content: center; }
      .tile b { font-size: 3vh; font-weight: 600; color: #8593AD; }
      .tile span { font-size: 12vh; font-weight: 800; line-height: 1; }
      .ok { color: #22C55E; } .bad { color: #EF4444; }
      body.stale header { color: #F59E0B; }
    </style>
    <main>
      <header><div>Ops wall</div><div id="updated">loading…</div></header>
      <div class="tile"><b>Open tickets</b><span id="tickets">–</span></div>
      <div class="tile"><b>Orders today</b><span id="orders">–</span></div>
      <div class="tile"><b>Uptime, 30 days</b><span id="uptime">–</span></div>
      <div class="tile"><b>On call</b><span id="oncall" style="font-size:6vh">–</span></div>
      <div class="tile"><b>Builds</b><span id="builds">–</span></div>
      <div class="tile"><b>Queue</b><span id="queue">–</span></div>
    </main>
    <script>
      async function load() {
        try {
          const r = await fetch('/data.json', { cache: 'no-store' });
          const d = await r.json();
          for (const k of ['tickets', 'orders', 'uptime', 'oncall', 'builds', 'queue']) {
            document.getElementById(k).textContent = d[k];
          }
          document.getElementById('builds').className = d.builds_failing ? 'bad' : 'ok';
          document.getElementById('updated').textContent = 'updated ' + new Date(d.updated_at).toLocaleTimeString([], { timeStyle: 'short' });
          document.body.classList.toggle('stale', Date.now() - Date.parse(d.updated_at) > 5 * 60 * 1000);
        } catch (e) {
          document.body.classList.add('stale');
        }
      }
      load();
      setInterval(load, 60 * 1000);
    </script>
  2. Make it yours

    Rename the tiles, change the grid to two by two for fewer numbers, or drop in a logo. Keep font sizes in vh so the page scales to any TV, and keep overflow: hidden on the body.

  3. Preview it at TV size

    Open it in a browser and resize the window to 1920 by 1080 (the browser's device toolbar does this), then stand back from your monitor. If you cannot read it from two metres, the TV audience cannot either.

    Six tiles fill the window with no scrollbar; the header says "loading…" until the data file exists.

Step 3Feed it data

The page reads one JSON file. Something on the server writes that file, which keeps every API key and database password off the wall and out of the browser. A cron job is enough.

  1. Write the update script

    Replace the placeholder values with your own lookups; anything Python can reach works, or use a shell script that writes the same JSON.

    /srv/wall/update.py
    #!/usr/bin/env python3
    # Writes /srv/wall/data.json. Run it from cron every minute:
    #   * * * * * /usr/bin/python3 /srv/wall/update.py
    import datetime, json, os
    
    data = {
        "tickets": 7,          # replace each value with your own lookup:
        "orders": 128,         # an API call, a SQL query, a file, a shell command
        "uptime": "99.98%",
        "oncall": "Sam",
        "builds": "12 green",
        "builds_failing": False,
        "queue": 3,
        "updated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    }
    tmp = "/srv/wall/data.json.tmp"
    with open(tmp, "w") as f:
        json.dump(data, f)
    os.replace(tmp, "/srv/wall/data.json")   # atomic: the page never reads a half-written file
  2. Run it every minute

    crontab -e and add the line from the script's header. The atomic rename means the page never sees a half-written file.

    Reload the page: the tiles show your numbers and the header shows the time of the last write.

Tip

Keep data.json on the same hostname as the page. A file on another host needs CORS headers and, behind basic auth, a second credential the screen cannot supply.

Step 4Host it behind basic auth

Caddy serves the folder, gets an HTTPS certificate on its own, and adds the password in four lines. Create one username per screen or group of screens with a long random password; a screen never needs a person's account.

  1. Hash the password

    Run caddy hash-password and paste the result into the Caddyfile. Caddy 2.8 renamed the directive from basicauth to basic_auth; older versions use the old name.

  2. Write the Caddyfile

    Caddyfile
    wall.example.com {
      root * /srv/wall
      encode gzip
      basic_auth {
        lobby-tv $2a$14$Yu3…hashed-bcrypt-password…
      }
      header /data.json Cache-Control "no-store"
      file_server
    }

    The no-store header on the data file stops any cache between the server and the screen from serving an old copy.

  3. Run it (Docker version)

    Put the Caddyfile and the wall folder next to this compose file, point the hostname's DNS at the machine, and docker compose up -d. Caddy issues the certificate on first request.

    compose.yaml
    services:
      caddy:
        image: caddy:2
        restart: unless-stopped
        ports: ["80:80", "443:443"]
        volumes:
          - ./Caddyfile:/etc/caddy/Caddyfile:ro
          - ./wall:/srv/wall:ro
          - caddy_data:/data
    volumes:
      caddy_data:
  4. Confirm the prompt

    In a private window the page asks for a username and password, then renders with live numbers. Using nginx instead? The HTTP authentication guide has the equivalent block.

On the office network only?

If the screens and the server share a LAN and nothing leaves the building, you can skip the password and serve plain HTTP on a private address; the screen can show any URL it can reach. Add basic auth the day a screen goes to another site or the page starts showing numbers you would not print on the wall of a café.

Step 5Put it on the screen

  1. Flash and pair

    Write the DisplayOps image to a microSD card, plug the Pi into the TV and the network, and enter the pairing code shown on screen under DisplaysPair in the portal.

  2. Add the page as content, with its credential

    Content+ New content, type Website, URL https://wall.example.com/. Tick The page asks for a username and password (HTTP authentication), enter the Caddy user and password, and save. The content list shows a lock badge on the item.

  3. Set the display

    SettingsAuto refresh: Every 15 min, which clears browser memory and picks up edits to the page. Rotation for a portrait screen. Screen on and Screen off for office hours.

  4. Assign it and check

    Open the display or a group and choose the content. The screen switches within seconds and shows the same page after any reboot.

    Take a screenshot from the display page: six tiles, real numbers, no password box. A "http auth rejected" note on the Now showing card means the username or password is wrong.

Three screens are free on the Personal plan. The managed browser display use case covers watchdogs, screenshots and health alerts.

Updating the page later

Edit the files on the server; the screens pick up the change on their next auto refresh. To see it now, send Refresh page from the display page, or do it from a deploy script:

after deploying a new index.html
curl -X POST https://app.simpledisplayops.com/api/v1/displays/DISPLAY_ID/commands \
  -H "Authorization: Bearer sdo_live_…" -H "Content-Type: application/json" \
  -d '{"type":"refresh"}'

Password rotation is two steps: change the hash in the Caddyfile, then edit the content item with the new password and send Restart player so the browser drops the old one.

Troubleshooting

  • The edges are cut off on the TV. Overscan. Increase the page margin, or set the TV's aspect mode to "Just scan", "Screen fit" or "1:1 pixel".
  • The header stays amber. The data file is old or unreadable: the cron job is not running (grep CRON /var/log/syslog), the script fails, or data.json sits on a different hostname than the page.
  • The page loads but the numbers never appear. Open the page in a normal browser and look at the console: a mixed-content block (http data on an https page), a CORS error, or a JSON syntax error from the script.
  • The screen shows the browser's sign-in box. The content item has no credential ticked, or the agent is older than 0.2.2 (the Device card shows the version; screens update on their own).
  • "http auth rejected by …" on the Now showing card. The username or password does not match the Caddyfile. Test them in a private window; watch for trailing spaces.
  • Fonts look different on the TV. The screen has no Google Fonts and no internet detour behind basic auth. Self-host the font file next to the page, or stick with system fonts.
  • The page gets slower after a few days. A timer keeps adding DOM nodes or listeners. Replace text instead of appending, and let the display's Auto refresh reload the page periodically.

Security checklist

  • API keys and database credentials only in the server-side script, never in the page or the JSON.
  • HTTPS on the hostname; basic auth on plain HTTP is readable on the wire.
  • One username per screen or group, bcrypt-hashed in the Caddyfile, stored only on the content item.
  • Nothing in data.json that should not be on a wall; it is readable by anyone who knows the password.
  • Rotate at the proxy, update the item, restart the player; delete the user when a screen is retired.
  • Viewer-only roles in DisplayOps for people who should not edit content, since editing an item can change where its credential is sent.

Your own dashboard on a TV: questions we get

Can the page live on the Raspberry Pi that shows it?

No. The DisplayOps image is a managed player, not a web server, and a page that lives on one screen cannot be shown on the next one. Host it on any machine the screens can reach: a NAS, a small VPS, the server that already runs your tools.

Do I have to use plain HTML?

Anything a browser renders works: a React or Svelte build, a Flask or Rails view, a Streamlit or Dash app. The rules are the same: fit 100vh, size for the distance, show a timestamp, keep every request on the same origin so one credential covers it.

How do I show data from an API that needs its own key?

Never put the key in the page; anyone on the wall's network could read it. Fetch on the server instead (the update.py pattern) and write the result to data.json, or proxy the API behind the same hostname with the key added server-side.

What happens when the network drops?

The screen keeps showing the last rendered page. Your stale indicator turns amber because data.json cannot be fetched, and DisplayOps emails you if the screen itself stays offline. When the network returns the next fetch succeeds and the stamp turns grey again.

Can I run the page in portrait?

Yes. Set the display's Rotation to Portrait in DisplayOps and design the grid for 1080 by 1920: swap the grid to one column and use vw for font sizes instead of vh.

Why basic auth rather than a login form?

A login form needs someone to type into it and a session that eventually expires. Basic auth is one line in the proxy, the screen answers it on every request, and the password never appears in the URL or on the page.

Your page, your numbers, on the wall by lunchtime.

Three screens free. Flash, pair, paste the URL, tick the box.