A raspberry pi kiosk dashboard turns a cheap board and any HDMI monitor into a wall display that shows live sensor values without a keyboard, mouse or login. In this tutorial you will write a single-file HTML dashboard that subscribes to MQTT over secure WebSocket, serve it from the Pi, and configure Chromium to start full screen on boot with screen blanking disabled. The result survives power cuts and needs no maintenance beyond the occasional OS update.
How the Kiosk Gets Its Data
The browser on the Pi connects straight to the MQTT broker over WebSocket. There is no backend server, no database and no polling. When a sensor publishes, the broker pushes the message to every subscriber, including the kiosk page, and the value on screen changes within moments.
That design depends on the broker offering MQTT over WebSocket, because browsers cannot open raw TCP sockets. The public MQTT test broker at broker.connectmq.com listens on port 8084 with TLS (wss://, path /mqtt) and on 8083 without TLS. Always use the secure port: Chromium blocks insecure WebSocket connections from pages served over HTTPS, and encryption costs nothing noticeable here. The MQTT over WebSocket guide explains the protocol details.
Retained messages make the kiosk feel instant. If sensors publish their latest state with the retain flag, the broker hands those values to the page the moment it subscribes, so the screen is fully populated a second after boot instead of waiting for the next reading.
| Piece | What it does | Where it runs |
|---|---|---|
| Sensors | Publish JSON, ideally retained | Anywhere with internet |
| MQTT broker | Routes messages, stores retained values | Cloud |
dashboard.html |
Subscribes over WSS, renders tiles | Chromium on the Pi |
| Local web server | Serves the HTML file | Pi, port 8080 |
| Chromium kiosk | Full-screen browser, no UI | Pi desktop session |
What You Need
- Raspberry Pi 4 or 5 (a Pi 3B+ works for simple pages) with Raspberry Pi OS with desktop, Bookworm.
- A monitor or TV over HDMI, or the official touch display.
- Wi-Fi or Ethernet with outbound access to port 8084.
- Something publishing MQTT data. For a quick start, the Raspberry Pi system stats publisher produces real values from any Pi.
Recent Bookworm images run a Wayland desktop (labwc on current releases, Wayfire on earlier Bookworm builds). The autostart steps below cover labwc and note the alternative.
Step 1: Write the Dashboard Page
Create a folder and the page:
mkdir -p ~/kiosk && nano ~/kiosk/dashboard.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Site Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: dark; }
body { margin: 0; font-family: system-ui, sans-serif; background: #0f1419; color: #e6e6e6; }
header { display: flex; justify-content: space-between; padding: 16px 28px; font-size: 22px; }
#status.ok { color: #4caf50; } #status.bad { color: #f44336; }
main { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; padding: 0 28px 28px; }
.tile { background: #1b2229; border-radius: 12px; padding: 24px; }
.label { font-size: 20px; opacity: .7; }
.value { font-size: 72px; font-weight: 600; margin-top: 8px; }
.age { font-size: 16px; opacity: .5; margin-top: 6px; }
.stale .value { opacity: .35; }
</style>
</head>
<body>
<header><span>Site Dashboard</span><span id="clock"></span><span id="status" class="bad">offline</span></header>
<main id="tiles"></main>
<script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
<script>
const PREFIX = "connectmq/demo/c81f3d";
const TILES = [
{ topic: PREFIX + "/office/climate", field: "t", label: "Office temperature", unit: " °C" },
{ topic: PREFIX + "/office/climate", field: "rh", label: "Office humidity", unit: " %" },
{ topic: PREFIX + "/pi01/stats", field: "cpu_temp", label: "Gateway CPU", unit: " °C" },
{ topic: PREFIX + "/line1/state", field: null, label: "Line 1", unit: "" }
];
const STALE_MS = 5 * 60 * 1000;
const tilesEl = document.getElementById("tiles");
const lastSeen = new Map();
TILES.forEach((t, i) => {
const el = document.createElement("div");
el.className = "tile stale"; el.id = "tile" + i;
el.innerHTML = '<div class="label"></div><div class="value">--</div><div class="age">waiting</div>';
el.querySelector(".label").textContent = t.label;
tilesEl.appendChild(el);
});
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "kiosk-c81f3d-" + Math.random().toString(16).slice(2, 8),
protocolVersion: 5,
clean: true,
reconnectPeriod: 5000,
connectTimeout: 10000
// Private broker: add username: "YOUR_USERNAME", password: "YOUR_PASSWORD"
// and use clientId: "YOUR_CLIENT_ID"
});
const statusEl = document.getElementById("status");
client.on("connect", () => {
statusEl.textContent = "live"; statusEl.className = "ok";
const topics = [...new Set(TILES.map(t => t.topic))];
client.subscribe(topics, { qos: 1 });
});
client.on("close", () => { statusEl.textContent = "reconnecting"; statusEl.className = "bad"; });
client.on("message", (topic, payload) => {
const text = payload.toString();
let data = null;
try { data = JSON.parse(text); } catch (e) { /* plain string payload */ }
TILES.forEach((t, i) => {
if (t.topic !== topic) return;
let v = t.field ? (data ? data[t.field] : undefined) : text;
if (v === undefined || v === null) return;
if (typeof v === "number") v = v.toFixed(1);
const el = document.getElementById("tile" + i);
el.querySelector(".value").textContent = v + t.unit;
el.classList.remove("stale");
lastSeen.set(i, Date.now());
});
});
setInterval(() => {
document.getElementById("clock").textContent = new Date().toLocaleTimeString();
TILES.forEach((t, i) => {
const seen = lastSeen.get(i);
const el = document.getElementById("tile" + i);
if (!seen) return;
const age = Math.round((Date.now() - seen) / 1000);
el.querySelector(".age").textContent = age < 60 ? "updated " + age + " s ago" : "updated " + Math.round(age / 60) + " min ago";
el.classList.toggle("stale", Date.now() - seen > STALE_MS);
});
}, 1000);
</script>
</body>
</html>
The page does three things that matter on an unattended screen. It shows a connection indicator so anyone walking past knows whether the numbers are live. It greys out tiles that have not updated within five minutes, which prevents a dead sensor from silently displaying an old value as if it were current. And it reconnects on its own every five seconds after a network drop.
If you would rather not load MQTT.js from a CDN every boot, download the file once into ~/kiosk/ and change the src to mqtt.min.js. The kiosk then needs internet only for the broker connection itself.
Step 2: Serve the Page Locally
Chromium can open file:// URLs, but serving over HTTP from the Pi avoids odd browser restrictions. Python's built-in server is enough. Create a systemd service so it starts on boot:
sudo tee /etc/systemd/system/kiosk-web.service >/dev/null <<'EOF'
[Unit]
Description=Kiosk dashboard web server
After=network-online.target
Wants=network-online.target
[Service]
User=pi
WorkingDirectory=/home/pi/kiosk
ExecStart=/usr/bin/python3 -m http.server 8080 --bind 127.0.0.1
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now kiosk-web
Replace pi with your username if it differs. Binding to 127.0.0.1 keeps the page private to the Pi itself.
Step 3: Start Chromium in Raspberry Pi Kiosk Mode
First make sure the Pi boots straight into the desktop with auto-login: run sudo raspi-config, open System Options, Boot / Auto Login and choose Desktop Autologin. While you are there, open Display Options, Screen Blanking and disable it.
Create a small launcher script:
nano ~/kiosk/start-kiosk.sh
#!/bin/bash
# Wait for the local web server and network
for i in $(seq 1 30); do
curl -s -o /dev/null http://127.0.0.1:8080/dashboard.html && break
sleep 2
done
BROWSER=$(command -v chromium-browser || command -v chromium)
exec "$BROWSER" \
--kiosk \
--noerrdialogs \
--disable-infobars \
--disable-session-crashed-bubble \
--check-for-update-interval=31536000 \
--ozone-platform=wayland \
http://127.0.0.1:8080/dashboard.html
chmod +x ~/kiosk/start-kiosk.sh
On labwc, add the launcher to the compositor's autostart file:
mkdir -p ~/.config/labwc
echo "/home/pi/kiosk/start-kiosk.sh &" >> ~/.config/labwc/autostart
On an older Bookworm build running Wayfire, add this to ~/.config/wayfire.ini instead:
[autostart]
kiosk = /home/pi/kiosk/start-kiosk.sh
If you switched the Pi back to X11 in raspi-config, remove the --ozone-platform=wayland flag and use ~/.config/autostart/kiosk.desktop with an Exec= line pointing to the script. Reboot and the dashboard should fill the screen.
Hardening an Unattended Raspberry Pi Kiosk Dashboard
A kiosk runs for months without anyone looking at it, so plan for the boring failures:
- Crashes. The
--disable-session-crashed-bubbleflag stops the "restore pages" bar after a power cut. For extra safety, wrap theexecin a loop so Chromium restarts if it exits. - Memory creep. Long-running browser tabs can grow. A nightly reboot via
sudo crontab -ewith0 4 * * * /sbin/rebootis a blunt but reliable fix. - SD card wear. Chromium writes a profile constantly. Consider enabling the overlay file system in raspi-config once the setup is final, which makes the root file system read-only.
- Screen burn-in. Static layouts on OLED or plasma screens can leave ghost images. Use dark backgrounds and turn the display off at night with
wlr-randr --output HDMI-A-1 --offfrom a scheduled script. - Credentials on screen. Anything in the page source is visible to anyone with physical access. Use a dedicated client with its own credentials for the kiosk.
Production: A Private Broker for the Kiosk
The public test broker is ideal while building, but anyone can read and write to topics there, so a real wall display should subscribe to your own broker. A fully managed MQTT service such as ConnectMQ gives you a private broker with WebSocket and secure WebSocket enabled out of the box. In the portal you create a broker and receive a username, password and client ID; put them into the mqtt.connect options and use the issued client ID exactly, because ConnectMQ binds client IDs to credentials. Since the page uses only one connection, it fits easily into the free Serverless plan alongside your sensors.
Before you wire everything to the kiosk, the ConnectMQ portal's in-browser MQTT client and live topic explorer are handy for checking that sensors publish the topics and JSON fields your tiles expect.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Status stays "offline" | Port 8084 blocked or wrong path | Use wss://...:8084/mqtt and check the firewall |
Tiles show -- forever |
Topic or field mismatch | Subscribe with mosquitto_sub -v and compare |
| Values only appear after minutes | Sensors do not retain | Publish the latest value with the retain flag |
| Black screen after boot | Autostart file not read | Check which compositor runs with echo $XDG_SESSION_DESKTOP |
| Screen turns off after 10 minutes | Blanking enabled | Disable in raspi-config, Display Options |
Get Started with ConnectMQ
Give your kiosk a private, TLS-secured feed: create a broker at app.connectmq.com on the free Serverless plan, no credit card required, and paste the credentials into dashboard.html. To build a touchscreen version that also sends commands, continue with the Raspberry Pi MQTT display tutorial, or go back to basics with the Raspberry Pi MQTT tutorial.
Frequently Asked Questions
Why does my kiosk need MQTT over WebSocket instead of port 1883?
Browsers can only open HTTP and WebSocket connections, not raw TCP sockets, so a web page cannot use port 1883 directly. Secure WebSocket on port 8084 is the standard way to subscribe from the browser.
Can I use Grafana instead of a custom HTML page?
Yes, open a Grafana dashboard URL in kiosk mode the same way. A custom page is lighter and updates instantly, while Grafana is better when you need history and charts.
How do I exit Chromium kiosk mode?
Connect a keyboard and press Alt+F4, or SSH into the Pi and run pkill chromium. Remove the autostart line if you want the normal desktop back.
Does the kiosk keep working if the internet drops?
The page stays on screen, marks itself as reconnecting, and greys out tiles as they go stale. When the connection returns, MQTT.js reconnects and retained values refresh the screen.



