Target hardware: Raspberry Pi 3B (primary), Pi Zero 2W (compatible) Base project: Splines/raspi-captive-portal Stack: hostapd + dnsmasq + iptables + Node.js/Express
The Pi broadcasts a WiFi network. When anyone connects, their device automatically shows a captive portal — a full webpage served from the Pi. No internet required. Serves HTML, CSS, JS, images, audio, and video (including seek/scrub). Content and WiFi settings are editable over SFTP without touching the hardware.
- Raspberry Pi 3B or Zero 2W
- MicroSD card (16GB+ recommended for media)
- Ethernet cable (needed during setup)
- Mac or Linux machine for flashing + SSH
- Download and open Raspberry Pi Imager
- Device: Raspberry Pi 3 (or Zero 2W)
- OS:
Raspberry Pi OS (other)→Raspberry Pi OS Lite (64-bit)— Bookworm (Debian 12) - Storage: your SD card
- Click the gear icon (⚙) and configure:
Hostname: artportal (or any name you like) SSH: Enable → password authentication Username: pi Password: (choose something) WiFi: your home network ← for initial setup only Locale: your timezone + keyboard layout - Write to SD card, insert into Pi, power on
Note: Use
Bookworm (Debian 12). Do not useTrixie (Debian 13)— the setup script is untested on it and the networking stack differs in ways that break the install.
ssh pi@artportal.localIf .local doesn't resolve, find the Pi's IP on your router's device list and use that directly.
Important: Plug an ethernet cable into the Pi before continuing. Once the captive portal is set up, the Pi's WiFi radio becomes an access point and you'll lose WiFi SSH access. Ethernet gives you a reliable path back in during setup and development.
sudo apt update && sudo apt upgrade -yInstall Node.js 20 LTS (the Raspbian default is outdated):
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs gitVerify:
node -v # should show v20.x.x
npm -vgit clone https://github.com/Splines/raspi-captive-portal.git
cd raspi-captive-portal
sudo python3 setup.pyThe script installs and configures:
hostapd— WiFi access pointdnsmasq— DHCP + DNS hijacking (all DNS queries → Pi IP)iptables— redirects all port 80 traffic → Node.js on port 3000netfilter-persistent— makes iptables rules survive reboots
When prompted, set your country code (e.g. US, GB, DE). This is required — without it hostapd won't start.
After setup completes, reboot:
sudo rebootSSH back in over ethernet.
The setup script disables wpa_supplicant but on Bookworm, NetworkManager will also try to manage wlan0, blocking hostapd. Fix both:
Mask wpa_supplicant (if not already done by setup script):
sudo systemctl stop wpa_supplicant
sudo systemctl disable wpa_supplicant
sudo systemctl mask wpa_supplicantTell NetworkManager to leave wlan0 alone:
sudo tee /etc/NetworkManager/conf.d/unmanaged.conf > /dev/null << 'EOF'
[keyfile]
unmanaged-devices=interface-name:wlan0
EOFsudo systemctl restart NetworkManager
sudo systemctl restart hostapdVerify hostapd is running:
sudo systemctl status hostapdYou should see wlan0: AP-ENABLED in the log. The SSID Splines Raspi AP (default) should now appear on nearby devices.
sudo chown -R pi:pi ~/raspi-captive-portal
cd ~/raspi-captive-portal/server
npm installTest it manually first:
npm start
# should print: ⚡ Raspberry Pi Server listening on port 3000Connect a phone to the WiFi — the portal page should appear. Press Ctrl+C to stop.
Create a systemd service so the Node.js server starts automatically on boot:
sudo tee /etc/systemd/system/captive-portal.service > /dev/null << 'EOF'
[Unit]
Description=Captive Portal Node.js Server
After=network.target hostapd.service dnsmasq.service
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/raspi-captive-portal/server
ExecStart=/usr/bin/node build/server.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOFsudo systemctl daemon-reload
sudo systemctl enable captive-portal
sudo systemctl start captive-portal
sudo systemctl status captive-portalsudo rebootSSH back in over ethernet, then check all three services:
sudo systemctl status hostapd captive-portal dnsmasqAll three should show active (running). Connect a phone to the WiFi — the portal should appear without any manual steps.
Create portal.json in the home directory. This file controls the WiFi network name and password and is editable over SFTP.
cat > /home/pi/portal.json << 'EOF'
{
"ssid": "Art Portal",
"password": "",
"country": "US",
"channel": 10
}
EOFLeave
passwordempty for an open (passwordless) network.
Create the script that reads this file and writes /etc/hostapd/hostapd.conf:
cat > /home/pi/raspi-captive-portal/update-hostapd.py << 'EOF'
#!/usr/bin/env python3
import json
CONFIG_FILE = '/home/pi/portal.json'
HOSTAPD_CONF = '/etc/hostapd/hostapd.conf'
defaults = {
'ssid': 'Art Portal',
'password': '',
'country': 'US',
'channel': 10
}
try:
with open(CONFIG_FILE) as f:
config = {**defaults, **json.load(f)}
except Exception as e:
print(f"Warning: could not read portal.json ({e}), using defaults")
config = defaults
lines = [
'interface=wlan0',
'driver=nl80211',
'hw_mode=g',
f'channel={config["channel"]}',
'ieee80211d=1',
f'country_code={config["country"]}',
'ieee80211n=1',
'wmm_enabled=1',
f'ssid={config["ssid"]}',
'auth_algs=1',
]
if config['password']:
lines += [
'wpa=2',
'wpa_key_mgmt=WPA-PSK',
'rsn_pairwise=CCMP',
f'wpa_passphrase={config["password"]}',
]
with open(HOSTAPD_CONF, 'w') as f:
f.write('\n'.join(lines) + '\n')
print(f"hostapd config updated — SSID: {config['ssid']}, {'WPA2' if config['password'] else 'Open (no password)'}")
EOFchmod +x /home/pi/raspi-captive-portal/update-hostapd.pyCreate a systemd service that runs this script before hostapd on every boot:
sudo tee /etc/systemd/system/portal-config.service > /dev/null << 'EOF'
[Unit]
Description=Update hostapd config from portal.json
Before=hostapd.service
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /home/pi/raspi-captive-portal/update-hostapd.py
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOFsudo systemctl daemon-reload
sudo systemctl enable portal-config
sudo systemctl start portal-config
sudo systemctl restart hostapdThe Pi is accessible over SFTP from any machine on the same network — including devices connected to the captive portal WiFi.
In Finder:
Go → Connect to Server → sftp://pi@192.168.4.1
In Cyberduck:
- Protocol: SFTP
- Server:
192.168.4.1(over portal WiFi) orartportal.local/ Pi's ethernet IP (over home network) - Username:
pi - Password: your Pi password
Drop your files into:
/home/pi/raspi-captive-portal/server/public/
index.html is the entry point. Organize media into subfolders as needed:
public/
index.html
style.css
script.js
images/
audio/
video/
Reference them in HTML as normal relative paths. Video seek and audio scrub work out of the box — Express handles HTTP range requests natively.
- Edit
/home/pi/portal.jsonin Cyberduck - Apply the change:
Or reboot:
sudo systemctl restart portal-config hostapd
sudo reboot
sudo systemctl status hostapd
sudo journalctl -u hostapd -n 30 --no-pager
iw devIf wlan0 shows type managed, something is holding the interface. Check:
sudo systemctl status NetworkManager
sudo systemctl status wpa_supplicantsudo systemctl status captive-portal
sudo systemctl status dnsmasq
sudo iptables -t nat -L PREROUTING -n -vThe iptables output should show a DNAT rule for tcp dpt:80 to:192.168.4.1:3000.
sudo ss -tlnp | grep 3000
sudo kill -9 <PID>
sudo systemctl restart captive-portalRefresh the directory listing: Cmd+Shift+R
| Service | Role | Config |
|---|---|---|
hostapd |
WiFi access point | /etc/hostapd/hostapd.conf (generated from portal.json) |
dnsmasq |
DHCP + DNS hijacking | /etc/dnsmasq.conf |
captive-portal |
Node.js content server | /home/pi/raspi-captive-portal/server/ |
portal-config |
Writes hostapd config from portal.json | /home/pi/raspi-captive-portal/update-hostapd.py |
NetworkManager |
Network management (wlan0 excluded) | /etc/NetworkManager/conf.d/unmanaged.conf |