Skip to content

Instantly share code, notes, and snippets.

@bmoren
Last active March 5, 2026 04:47
Show Gist options
  • Select an option

  • Save bmoren/7e112211d96381b4924bd4605a91cc68 to your computer and use it in GitHub Desktop.

Select an option

Save bmoren/7e112211d96381b4924bd4605a91cc68 to your computer and use it in GitHub Desktop.
networkPoeticsPiSetup.md

Raspberry Pi Captive Portal Setup

Arts Installation / Classroom Use

Target hardware: Raspberry Pi 3B (primary), Pi Zero 2W (compatible) Base project: Splines/raspi-captive-portal Stack: hostapd + dnsmasq + iptables + Node.js/Express


What This Does

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.


Requirements

  • Raspberry Pi 3B or Zero 2W
  • MicroSD card (16GB+ recommended for media)
  • Ethernet cable (needed during setup)
  • Mac or Linux machine for flashing + SSH

Step 1 — Flash Pi OS

  1. Download and open Raspberry Pi Imager
  2. Device: Raspberry Pi 3 (or Zero 2W)
  3. OS: Raspberry Pi OS (other)Raspberry Pi OS Lite (64-bit) — Bookworm (Debian 12)
  4. Storage: your SD card
  5. 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
    
  6. Write to SD card, insert into Pi, power on

Note: Use Bookworm (Debian 12). Do not use Trixie (Debian 13) — the setup script is untested on it and the networking stack differs in ways that break the install.


Step 2 — First SSH Connection

ssh pi@artportal.local

If .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.


Step 3 — System Update + Node.js

sudo apt update && sudo apt upgrade -y

Install 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 git

Verify:

node -v   # should show v20.x.x
npm -v

Step 4 — Clone and Run Captive Portal Setup

git clone https://github.com/Splines/raspi-captive-portal.git
cd raspi-captive-portal
sudo python3 setup.py

The script installs and configures:

  • hostapd — WiFi access point
  • dnsmasq — DHCP + DNS hijacking (all DNS queries → Pi IP)
  • iptables — redirects all port 80 traffic → Node.js on port 3000
  • netfilter-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 reboot

SSH back in over ethernet.


Step 5 — Fix wpa_supplicant Conflict

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_supplicant

Tell NetworkManager to leave wlan0 alone:

sudo tee /etc/NetworkManager/conf.d/unmanaged.conf > /dev/null << 'EOF'
[keyfile]
unmanaged-devices=interface-name:wlan0
EOF
sudo systemctl restart NetworkManager
sudo systemctl restart hostapd

Verify hostapd is running:

sudo systemctl status hostapd

You should see wlan0: AP-ENABLED in the log. The SSID Splines Raspi AP (default) should now appear on nearby devices.


Step 6 — Install Node.js Dependencies + Start the Server

sudo chown -R pi:pi ~/raspi-captive-portal
cd ~/raspi-captive-portal/server
npm install

Test it manually first:

npm start
# should print: ⚡ Raspberry Pi Server listening on port 3000

Connect a phone to the WiFi — the portal page should appear. Press Ctrl+C to stop.


Step 7 — Set Up the Server as a System Service

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
EOF
sudo systemctl daemon-reload
sudo systemctl enable captive-portal
sudo systemctl start captive-portal
sudo systemctl status captive-portal

Step 8 — Reboot Test

sudo reboot

SSH back in over ethernet, then check all three services:

sudo systemctl status hostapd captive-portal dnsmasq

All three should show active (running). Connect a phone to the WiFi — the portal should appear without any manual steps.


Step 9 — SSID / Password Config File

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
}
EOF

Leave password empty 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)'}")
EOF
chmod +x /home/pi/raspi-captive-portal/update-hostapd.py

Create 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
EOF
sudo systemctl daemon-reload
sudo systemctl enable portal-config
sudo systemctl start portal-config
sudo systemctl restart hostapd

Day-to-Day: Editing Content

Connecting via SFTP (Cyberduck / Finder)

The 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) or artportal.local / Pi's ethernet IP (over home network)
  • Username: pi
  • Password: your Pi password

Putting Content on the Portal

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.

Changing the SSID or Password

  1. Edit /home/pi/portal.json in Cyberduck
  2. Apply the change:
    sudo systemctl restart portal-config hostapd
    Or reboot:
    sudo reboot

Troubleshooting

SSID not appearing

sudo systemctl status hostapd
sudo journalctl -u hostapd -n 30 --no-pager
iw dev

If wlan0 shows type managed, something is holding the interface. Check:

sudo systemctl status NetworkManager
sudo systemctl status wpa_supplicant

Portal page not appearing (SSID visible but no popup)

sudo systemctl status captive-portal
sudo systemctl status dnsmasq
sudo iptables -t nat -L PREROUTING -n -v

The iptables output should show a DNAT rule for tcp dpt:80 to:192.168.4.1:3000.

Node.js port already in use

sudo ss -tlnp | grep 3000
sudo kill -9 <PID>
sudo systemctl restart captive-portal

portal.json not visible in Cyberduck

Refresh the directory listing: Cmd+Shift+R


Service Summary

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment