14 min read

How to Set Up a Digital Signage System on a Raspberry Pi and Manage It Remotely

Turn a Raspberry Pi into a digital signage player with Chromium kiosk mode and a Node.js content server. Push content updates and manage the display remotely from any browser using Localtonet.

📺 Digital Signage · Raspberry Pi · Kiosk Mode · Remote Management · Chromium

How to Set Up a Digital Signage System on a Raspberry Pi and Manage It Remotely

A Raspberry Pi behind a screen makes a capable, low-cost digital signage player. Chromium in kiosk mode fills the display with any web page a dashboard, a menu board, a slide deck, a live production feed, or an announcement screen. The tricky part has always been updating the content and managing the device when it is mounted behind a screen in a factory, a shop, or a meeting room. This guide covers the full setup: kiosk mode on Raspberry Pi OS, a simple Node.js content server that serves the signage page, a management API for remote content updates, and a Localtonet HTTP tunnel so you can push new content and restart the display from any browser without touching the Pi.

🖥️ Chromium kiosk mode setup 🔄 Remote content updates 🔁 Auto-rotating slides 🌍 Manage from anywhere

Where This Setup Is Used

Any screen that needs to show live or regularly updated content without a person sitting in front of it is a candidate for this kind of setup. The hardware cost is low, the power consumption is minimal, and the management approach in this guide means you never need physical access to the Pi after initial installation.

🏭 Factory floor dashboards Production targets, OEE metrics, safety notices, and shift information displayed on screens at the line. Content updated from the office without walking to each screen.
🍽️ Restaurant and cafe menu boards Daily specials and menu items shown on screens above the counter. Updated from a phone when the menu changes.
🏢 Office reception and meeting rooms Visitor welcome screens, meeting room schedules, and company announcements. Synced from a central server or calendar integration.
🏪 Retail and point of sale Promotional content, product highlights, and offers rotated on shop floor screens. Campaign changes pushed remotely on schedule.
🏫 Schools and public spaces Timetable displays, event announcements, and emergency notices. Multiple screens across a campus managed centrally.
🔧 Maintenance and status boards Live equipment status, maintenance schedules, and alert feeds from a SCADA or monitoring system displayed at the point of work.

Step 1: Prepare the Raspberry Pi

Use Raspberry Pi Imager to flash Raspberry Pi OS (64-bit) with the desktop environment to your SD card. In the Imager's advanced settings, configure a hostname, enable SSH, and set your Wi-Fi credentials. This lets you manage the Pi over SSH without attaching a keyboard or mouse.

# Connect over SSH after first boot
ssh pi@raspberrypi.local

# Update the system
sudo apt update && sudo apt upgrade -y

# Install required tools
sudo apt install -y nodejs npm curl unclutter
Raspberry Pi OS version note

Recent versions of Raspberry Pi OS (Bookworm, based on Debian 12, and the newer Trixie release) use the labwc Wayland compositor instead of the older X11-based LXDE desktop. The autostart file location changed to ~/.config/labwc/autostart. The kiosk setup in this guide targets Bookworm and newer. If you are running Bullseye or an older release, the autostart path is /etc/xdg/lxsession/LXDE-pi/autostart.

Step 2: Build the Local Content Server

Chromium in kiosk mode opens a URL. The cleanest architecture for a manageable signage system is to serve the display content from a local Node.js server running on the Pi. Chromium opens http://localhost:3000 at boot. The server decides what to show. When you push new content remotely, the server updates its state and the display refreshes automatically on the next browser reload no Chromium restart needed.

mkdir ~/signage && cd ~/signage
npm init -y
npm install express

Directory structure

signage/
├── server.js
├── content.json
└── public/
    ├── display.html      (shown on the screen)
    ├── admin.html        (management interface)
    └── slides/
        ├── slide1.html
        └── slide2.html

server.js

const express = require('express');
const fs      = require('fs');
const path    = require('path');

const app         = express();
const CONTENT_FILE = path.join(__dirname, 'content.json');

app.use(express.json());
app.use(express.static('public'));

// Load or create default content configuration
function loadContent() {
    if (!fs.existsSync(CONTENT_FILE)) {
        const defaults = {
            mode:     'url',           // 'url' or 'slides'
            url:      'http://localhost:3000/slides/slide1.html',
            slides:   [
                '/slides/slide1.html',
                '/slides/slide2.html'
            ],
            interval: 10              // seconds between slide transitions
        };
        fs.writeFileSync(CONTENT_FILE, JSON.stringify(defaults, null, 2));
    }
    return JSON.parse(fs.readFileSync(CONTENT_FILE));
}

// Main display page — Chromium loads this at boot
app.get('/', (req, res) => {
    const content = loadContent();
    res.send(`<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta http-equiv="refresh" content="300">
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body, html { width: 100%; height: 100%; overflow: hidden; background: #000; }
        iframe { width: 100%; height: 100%; border: none; }
    </style>
</head>
<body>
    ${content.mode === 'slides'
        ? `<iframe id="frame" src="${content.slides[0]}"></iframe>
           <script>
               var slides = ${JSON.stringify(content.slides)};
               var idx = 0;
               setInterval(function() {
                   idx = (idx + 1) % slides.length;
                   document.getElementById('frame').src = slides[idx];
               }, ${content.interval * 1000});
           </script>`
        : `<iframe src="${content.url}"></iframe>`
    }
</body>
</html>`);
});

// GET current content configuration
app.get('/api/content', (req, res) => {
    res.json(loadContent());
});

// POST to update content configuration
app.post('/api/content', (req, res) => {
    const current = loadContent();
    const updated = { ...current, ...req.body };
    fs.writeFileSync(CONTENT_FILE, JSON.stringify(updated, null, 2));
    res.json({ status: 'updated', content: updated });
});

// POST to reload the display page (triggers meta refresh)
app.post('/api/reload', (req, res) => {
    res.json({ status: 'ok', message: 'Display will reload on next refresh cycle' });
});

app.listen(3000, () => console.log('Signage server running on http://localhost:3000'));

content.json (initial)

{
  "mode": "slides",
  "url": "http://localhost:3000/slides/slide1.html",
  "slides": [
    "/slides/slide1.html",
    "/slides/slide2.html"
  ],
  "interval": 10
}

public/slides/slide1.html (example slide)

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        body {
            margin: 0;
            background: #1a1a2e;
            color: #fff;
            font-family: system-ui, sans-serif;
            display: flex;
            align-items: center;
            justify-content: center;
            height: 100vh;
            flex-direction: column;
            gap: 1rem;
        }
        h1 { font-size: 4rem; }
        p  { font-size: 2rem; opacity: .7; }
    </style>
</head>
<body>
    <h1>Welcome</h1>
    <p>Production target today: 450 units</p>
</body>
</html>

Register the content server as a systemd service so it starts at boot:

sudo tee /etc/systemd/system/signage.service <<'EOF'
[Unit]
Description=Digital Signage Content Server
After=network.target

[Service]
ExecStart=/usr/bin/node /home/pi/signage/server.js
WorkingDirectory=/home/pi/signage
Restart=always
User=pi
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl enable signage
sudo systemctl start signage

Verify it is running:

curl http://localhost:3000/api/content

Step 3: Configure Chromium Kiosk Mode

On Raspberry Pi OS Bookworm and newer, the desktop uses the labwc Wayland compositor. The autostart file at ~/.config/labwc/autostart runs commands when the desktop session starts. Add the Chromium kiosk launch command there.

# Create the labwc config directory if it does not exist
mkdir -p ~/.config/labwc

nano ~/.config/labwc/autostart

Add the following lines:

# Hide the mouse cursor after 0.1 seconds of inactivity
unclutter -idle 0.1 -root &

# Launch Chromium in kiosk mode pointing at the local content server
chromium-browser \
  --kiosk \
  --noerrdialogs \
  --disable-infobars \
  --no-first-run \
  --password-store=basic \
  --disable-features=TranslateUI \
  --enable-features=OverlayScrollbar \
  http://localhost:3000 &
The --password-store=basic flag

Without this flag, Chromium prompts for a keyring password on startup on some Bookworm systems. Since there is no keyboard attached to the signage Pi, this prompt would block the browser from launching. The flag tells Chromium to use a simple file-based password store instead of the system keyring, which starts without any prompt.

Reboot the Pi and confirm Chromium starts automatically showing the content server page:

sudo reboot

After the Pi boots, the display should show the slide content served by the Node.js server. If the screen is black or Chromium shows an error, SSH into the Pi and check the signage service:

sudo systemctl status signage
journalctl -u signage -n 30

Step 4: Prevent Screen Blanking

By default, Raspberry Pi OS blanks the screen after a period of inactivity. For a signage display that should always be on during operating hours, this must be disabled.

On Bookworm with labwc, add the following to the autostart file above the Chromium line:

# Disable screen saver and power management
xset s off &
xset -dpms &
xset s noblank &

For the Wayland compositor approach on newer systems, add this to ~/.config/labwc/environment:

DISPLAY=:0
XAUTHORITY=/home/pi/.Xauthority

If the display still blanks, disable it via the desktop configuration:

sudo raspi-config nonint do_blanking 1

Step 5: Remote Management with Localtonet

The content server API runs on port 3000. With a Localtonet HTTP tunnel, you can reach that API and a management web interface from any browser anywhere. Update the displayed URL, change the slide deck, or adjust the rotation interval from your phone or office computer without SSH access or physical contact with the Pi.

curl -fsSL https://localtonet.com/install.sh | sh
localtonet --authtoken <YOUR_TOKEN>

Go to the HTTP tunnel page, set local IP to 127.0.0.1 and port to 3000. Click Create and start the tunnel. The dashboard shows a public HTTPS URL such as https://abc123.localto.net.

Register Localtonet as a service so the tunnel returns after every reboot:

sudo localtonet --install-service --authtoken <YOUR_TOKEN>
sudo localtonet --start-service --authtoken <YOUR_TOKEN>

You can now manage the display remotely from the tunnel URL. Use the API directly or build a simple admin page served by the Node.js server.

Remote management API examples

# Switch to a specific external URL (show a live Grafana dashboard)
curl -X POST https://abc123.localto.net/api/content \
  -H "Content-Type: application/json" \
  -d '{"mode":"url","url":"http://grafana.yourdomain.com/d/abc"}'

# Switch back to local slides with 15-second interval
curl -X POST https://abc123.localto.net/api/content \
  -H "Content-Type: application/json" \
  -d '{"mode":"slides","interval":15}'

# Check current display configuration
curl https://abc123.localto.net/api/content
Secure the management API

The API above has no authentication. Anyone with the tunnel URL can change the display content. Before sharing the tunnel URL or leaving it running continuously, add authentication. The quickest approach is to enable Username/Password protection on the tunnel in the Localtonet dashboard see the Username/Password SSO documentation. Alternatively, add a simple API key check in the Express middleware.

Managing Multiple Displays

Each Pi runs its own content server and Localtonet tunnel. Each tunnel gets its own public URL from the same Localtonet account. You manage each display independently by calling its own tunnel URL.

Example: three displays in a factory

Display Location Tunnel URL Current content
Pi 01 Production line A abc123.localto.net OEE dashboard
Pi 02 Staff canteen def456.localto.net Menu and announcements
Pi 03 Reception ghi789.localto.net Visitor welcome slides

To push the same content update to all displays at once, write a small shell script that loops through each tunnel URL and calls the API:

#!/bin/bash
# update-all-displays.sh

DISPLAYS=(
    "https://abc123.localto.net"
    "https://def456.localto.net"
    "https://ghi789.localto.net"
)

NEW_CONTENT='{"mode":"slides","slides":["/slides/safety_notice.html"],"interval":8}'

for url in "${DISPLAYS[@]}"; do
    echo "Updating: $url"
    curl -s -X POST "$url/api/content" \
      -H "Content-Type: application/json" \
      -d "$NEW_CONTENT"
    echo ""
done

echo "All displays updated."

Frequently Asked Questions

Chromium shows "Connection refused" at boot. What is wrong?

The most common cause is that the Node.js content server has not finished starting before Chromium tries to load the page. Add a short delay before the Chromium launch line in the autostart file: sleep 5 && before the chromium-browser command. If the issue persists, check that the signage systemd service is running: sudo systemctl status signage. The browser's meta http-equiv="refresh" tag in the display page means it retries automatically every 5 minutes even if the first load fails.

Can I display content that requires internet access, like a Google Slides presentation?

Yes. Set the mode to url in the content configuration and point it at the published Google Slides URL with the /embed path and the loop=true&delayms=5000 parameters. The Pi needs a working internet connection. The meta refresh tag in the display page will reload if the connection drops and recovers.

The display screen turns off at night. How do I control the display schedule?

Use cron jobs to turn the display on and off on a schedule. For HDMI displays connected to the Pi, vcgencmd display_power 0 turns the display off and vcgencmd display_power 1 turns it on. Add cron entries: 0 8 * * 1-5 vcgencmd display_power 1 to turn on at 08:00 on weekdays and 0 18 * * 1-5 vcgencmd display_power 0 to turn off at 18:00. Edit crontab with crontab -e.

Can I show different content at different times of day automatically?

Yes. Add a schedule to the content server by extending server.js to check the current time when generating the display page, and serve different slides or URLs depending on the time. Alternatively, add a cron job that calls the local API to switch content at scheduled times: 0 12 * * * curl -s -X POST http://localhost:3000/api/content -H 'Content-Type: application/json' -d '{"mode":"url","url":"http://localhost:3000/slides/lunch_menu.html"}'.

The Pi becomes unresponsive after running for several days. What causes this?

Long-running Chromium sessions can leak memory over time, especially when displaying pages with live data or animations. Add a daily Chromium restart to the crontab: 0 4 * * * DISPLAY=:0 pkill chromium; sleep 2; DISPLAY=:0 chromium-browser --kiosk http://localhost:3000 &. Scheduling the restart during the night at 04:00 means it never interrupts a display during operating hours. For severe memory issues, a full Pi reboot at the same time is a cleaner solution.

Can I display a live video stream on the signage screen?

Yes, with caveats. Chromium can play HLS streams via an HTML5 video tag or an embedded player. RTSP streams require a transcoding step first use ffmpeg on the Pi to transcode RTSP to HLS, then serve the HLS stream from the local Node.js server. A Pi 4 handles 1080p H.264 transcoding comfortably. A Pi 5 handles it with headroom to spare. Keep the stream resolution at 720p or below on a Pi 3.

Deploy a Display, Manage It from Anywhere

Set up the content server, configure kiosk mode, and open a Localtonet tunnel. Every display in your building becomes remotely manageable from a single browser tab | no SSH, no physical access, no proprietary management platform.

Create Free Localtonet Account →

Localtonet is a secure multi-protocol tunneling and proxy platform designed to expose localhost, devices, private services, and AI agents to the public internet supporting HTTP/HTTPS tunnels, TCP/UDP forwarding, mobile proxy infrastructure, file server publishing, latency-optimized game connectivity, and developer-ready AI agent endpoint exposure from a single unified control plane.

support