Skip to content
yf_
← Writing

WebSockets from a PWA to a Raspberry Pi on the local network

11 min readUpdated 13 Sept 2026
  • PWA
  • WebSockets
  • TLS
  • Raspberry Pi
  • Let's Encrypt

I needed a browser app to hold a live two-way connection to a Raspberry Pi over the local network, with no internet in the picture at all.

The WebSocket part is easy. Everything wrapped around it isn’t.

Browsers won’t open an insecure WebSocket from an HTTPS page. A secure one needs a certificate they already trust. And certificates get issued for names that resolve to addresses, which is awkward when the thing being certified sits on a private address no CA can reach.

Here’s how I got around it. Nearly all of it hangs off one decision, in section 2: the Pi runs its own Wi-Fi. Fix the address and there’s a name worth certifying. Skip that and nothing else below is available.

Written October 2025. Sections 11 and 12 were added in September 2026 to cover two developments that postdate the original.

1. Constraints

Four things chain together. I couldn’t drop any of them on its own.

  • It has to be a PWA. The app runs with no internet, because by section 3 the user is on a network that doesn’t have any. So: service worker. And service workers only run in a secure context.
  • So it’s served over HTTPS. That one follows for free.
  • An HTTPS page can’t open ws://. It counts as mixed content, and the browser kills it before a packet moves. Which leaves wss://.
  • wss:// needs a certificate the browser already trusts. This is the one that costs time, and it’s most of what follows.

There’s an exception for ws://localhost, which counts as a trustworthy origin. Doesn’t help me. The Pi is a different machine.

2. The Pi runs its own Wi-Fi

Everything else depends on this one.

The Pi runs in AP mode and broadcasts its own network, so clients join the Pi directly instead of going through a router somebody else owns. That makes the Pi the gateway, which pins it to a fixed address: 192.168.4.1, if you follow the usual Raspberry Pi convention. Same address on every unit I ever ship.

That’s the bit that makes certificates possible. A certificate covers a name, and the name has to land on the device. Put the Pi on someone’s home LAN and its address is whatever DHCP felt like that morning, so I’d need either a DNS record per device that goes stale without warning, or a scheme that smuggles the address into the hostname. Running the AP doesn’t work around that problem. It deletes it.

Owning the network brings a second obligation, and section 9 is about it. Nothing else on that network knows what the Pi’s name means, so the Pi has to answer DNS queries itself.

3. Connection flow

What the user actually goes through:

  1. They open the PWA, served over HTTPS from the internet, or out of the service worker cache if there’s no connection.
  2. The app pings https://robot.domain.com looking for the device.
  3. On a normal network that fails. The Pi isn’t there and the name goes nowhere useful.
  4. The app tells them to join the Pi’s Wi-Fi, and waits.
  5. They switch. The Pi’s DHCP hands out the Pi itself as the DNS server.
  6. robot.domain.com now resolves to 192.168.4.1.
  7. The app opens wss://robot.domain.com. Certificate checks out, browser is happy, socket connects.

Step 1 is why this can’t be a plain website. By step 5 there’s no route to the internet, so anything the app needs from then on has to already be sitting in the cache.

For the probe in step 2 I use a fetch() with a timeout rather than just opening a socket, because a failed WebSocket hands back an event with nothing useful in it:

async function deviceReachable(timeout = 2000) {
  try {
    const response = await fetch("https://robot.domain.com/health", {
      signal: AbortSignal.timeout(timeout),
      cache: "no-store",
    });
    return response.ok;
  } catch {
    return false;
  }
}

The Pi sends CORS headers on that endpoint, so I get a real response instead of an opaque one. Without them every outcome comes back as the same rejected promise, which is the thing I was trying to get away from.

4. Why a self-signed certificate is out

Generating a certificate on the Pi is the obvious shortcut. It doesn’t work, and when it fails it tells you nothing.

Over https:// an untrusted certificate gets you the “your connection is not private” page, and you can click through. A WebSocket opened from JavaScript has no such page. There’s nothing to click. The handshake dies and the error event is empty, on purpose, so a page can’t use connection failures to map out the network it landed on.

There’s a half-workaround: load https://192.168.4.1:8443 in a tab, accept the warning by hand, and wss:// to that origin works afterwards. I dropped it anyway.

  • It makes clicking past a security warning a setup step.
  • The exception vanishes the moment anything clears site data.
  • Mobile browsers honour it inconsistently.

5. mDNS and .local

Reaching the Pi at robot.local doesn’t get me anywhere either. Public CAs haven’t been allowed to issue for internal names since 2016, so there’s no trusted certificate for a .local host, full stop. mDNS is a discovery tool. My problem is trust.

6. ACME validation: only DNS-01 works

HTTP-01 and TLS-ALPN-01 both need the CA to connect back to whatever the name points at, on port 80 and 443. The CA is out on the internet, the Pi is behind its own AP on a private address, and no amount of config fixes that.

There’s a workaround floating around: point the domain at a public box, run the challenge, then repoint the record at the private address. It does produce a real certificate. I skipped it.

  • One name at a time.
  • A DNS edit on every single renewal.
  • Two TTL waits per cycle, with the record pointing somewhere useless in between.

DNS-01 just wants a TXT record at _acme-challenge.robot.domain.com. The CA reads public DNS and never tries to touch the device. That’s the whole trick: validation stops caring about reachability, and my device is unreachable by design.

certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d robot.domain.com

Worth being clear about what the domain is for here. It lives in public DNS so the CA can check I own it. Finding the device is a different job, and section 9 handles that.

7. One name, one certificate

The AP pins the address, so there’s exactly one name to certify:

robot.domain.com.    A    192.168.4.1

That record is right for every unit at once, because every unit is the gateway of its own network and every one of those networks is numbered the same. One certificate for robot.domain.com, copied onto all of them.

The alternative is much worse, which is the real argument for the AP. A device on some arbitrary LAN at an arbitrary address needs a wildcard certificate plus a scheme that packs the address into the hostname, so 192-168-1-50.devices.example.com resolves to 192.168.1.50. That’s what Plex does with *.plex.direct. It works, and it’s the right call if you can’t control the network. I could, so I didn’t need it.

8. Issuance and distribution

DNS-01 needs API credentials for the DNS provider. My first working version ran certbot on the Pi, which put a token that can rewrite my entire zone inside every unit I ship. Including the ones that end up in a drawer, and the ones somebody eventually pries open. I moved issuance off the device about a day later.

How it works now:

  • One service holds the DNS credentials and does all issuance and renewal.
  • Each device gets its certificate over the channel the app already uses to talk to it.
  • The device holds no DNS credentials and runs no ACME code.

That fixed a lifetime problem too. Let’s Encrypt’s default profile is 90 days and is heading for 45. Hardware that sits powered off for a month comes back with a dead certificate and no chance to have renewed it, so renewal was never going to be the device’s job. Handing it a fresh one when it turns up is.

9. The Pi runs its own DNS resolver

This isn’t a nice-to-have, it’s load-bearing. The certificate is issued for robot.domain.com, so the client has to resolve that name to the Pi or none of the rest matters. And on the Pi’s own network, the Pi is the only thing in a position to answer.

So it runs a resolver:

  • It serves DHCP to everything that joins its AP, and the lease advertises the Pi as the DNS server.
  • Its dnsmasq answers robot.domain.com locally and authoritatively, no upstream involved.
  • The answer is its own fixed address, which is why section 2 has to come first.
# /etc/NetworkManager/dnsmasq-shared.d/robot.conf
address=/robot.domain.com/192.168.4.1

Without this the name doesn’t resolve at all once you’re on the Pi’s network, because that network can’t reach the internet and nothing else on it has any idea what robot.domain.com is.

Why not let the client’s resolver handle it

Because it gets refused often enough to matter.

A public DNS record answering with an RFC 1918 address is exactly what a DNS rebinding attack looks like, where a hostile name resolves inward so a page can start poking at things behind your firewall. Resolvers defend against it by throwing those answers away, and that defence is on by default all over the place:

  • dnsmasq ships --stop-dns-rebind.
  • OpenWrt and pfSense turn on rebind protection.
  • Consumer routers filter private answers.
  • Some ISP resolvers drop them upstream, before the query even reaches the router.

Lean on a third-party resolver to hand back a private address and you’re betting on equipment nobody in the project has ever seen. It’ll work on one network and fall over on the next. Running the resolver on the Pi kills that dependency, because nothing between the client and the answer belongs to anyone else.

The public A record from section 7 still earns its place as a fallback and returns the same address, so both paths agree. Only the public one can get filtered.

One caveat. A client on DNS-over-HTTPS, or Android’s Private DNS, skips the DHCP-supplied resolver entirely. With no internet on the network those queries just fail, and the app should say so rather than reporting “device not found”.

10. Captive portal detection

Phones test every network they join for internet access. Android hits connectivitycheck.gstatic.com, iOS hits captive.apple.com. A network that flunks gets flagged as having none, and the handset may quietly start preferring cellular for everything, taking my app’s traffic with it.

That’s a direct cost of the AP approach and it needs handling on purpose, because when it happens the app looks broken. Two options: answer the probe endpoints from the Pi, or tell the user plainly that they’re staying on this network and won’t have internet while they do.

The same probe is what powers a captive portal, and answering it deliberately is the nicer version, since the handset pops open the Pi’s page instead of writing the network off. I split that part out into its own project: pi-wifi-provision brings a Pi up in AP mode with hostapd and dnsmasq when it can’t find a known network, and serves a captive portal for entering Wi-Fi credentials. It’s solving provisioning rather than this, but it’s the same NetworkManager, hostapd and dnsmasq setup section 2 leans on, and the portal behaviour is the reusable bit.

11. IP address certificates

Let’s Encrypt made IP address certificates generally available in January 2026 on the 160-hour shortlived profile. My address is fixed and known, so certifying 192.168.4.1 directly would drop the domain out of the design entirely. Can’t be done, for two separate reasons:

  • RFC 8738 doesn’t allow DNS-01 for IP identifiers. That leaves HTTP-01 and TLS-ALPN-01, and both need the CA to reach the address.
  • No public CA will issue for a reserved range, and 192.168.4.1 is in one.

A fixed address makes the design simple. It doesn’t make it certifiable. I still need the name.

12. Local Network Access

Chrome 142 shipped Local Network Access, a permission prompt covering requests from public sites to local devices. Granting it exempts those requests from mixed content checks, which would wipe out the certificate requirement completely: a plain ws://192.168.4.1:8080 behind a permission prompt, no CA anywhere.

It doesn’t cover my case yet. Chrome’s docs list it as a known limitation, that local WebSocket connections aren’t gated on the LNA permission, so they’re still blocked as mixed content. The spec says WebSockets ought to fall under the same rules and points at why they don’t: fetch() takes a targetAddressSpace option to declare intent, and the WebSocket constructor has nowhere to put one.

It’s also one browser. Something to watch, not something to use.

13. Trade-offs

It works. It isn’t free.

  • The user switches Wi-Fi by hand. No web API can do it, so it’s a prompt and an instruction, and it’s the clumsiest part of the whole flow.
  • They lose internet while connected. Everything a session does has to work against the Pi alone, which is a real limit on what the app can be, and why the offline requirement in section 1 wasn’t optional.
  • The Pi holds a real certificate and key. Publicly trusted, sitting on hardware end users physically own, and the key is per-fleet rather than per-device. That’s a deliberate exposure I decided I could live with.

Where the Wi-Fi switch isn’t acceptable, Bluetooth skips this whole document by never touching the IP stack, at the cost of bandwidth and a fresh set of browser support headaches.

Summary

The certificate problem was never really a certificate problem. It’s an addressing problem wearing a disguise. A device on a network I don’t control has no stable address, so no name I can certify, so no wss://, so no way for an HTTPS page to talk to it. Self-signed certificates, .local, repointing DNS around a challenge, packing addresses into hostnames: all of them are attempts to live with an address that won’t sit still.

Give the Pi its own Wi-Fi and the address stops moving. After that it’s straightforward. One name, one certificate via DNS-01, issued somewhere safe and handed over on connect, resolved by the Pi itself on the network it owns.