Local DevHTTPSPWAViteDX
Localhost Lies: Testing Your PWA the Way Your Users Actually Will
By Binyam Techan • • 8 min read
Here's a scenario I lived through way too many times before I fixed it. You're building a web app, or a PWA with all the trimmings: service worker, geolocation, maybe camera access for a QR scanner. On your laptop, at http://localhost:5173, it's perfect. Buttery animations, install prompt working, everything green in the console. You feel like a genius.
Then you grab your phone, connect it to the same Wi-Fi, type in your laptop's IP address, and suddenly your app is a different, worse app. No install prompt. No location permission dialog. The service worker refuses to register and just silently gives up like a coworker who stopped caring around 4:45pm. You didn't break anything. You just found out localhost has been lying to you the whole time.
Why this happens
A bunch of powerful browser APIs (service workers, geolocation, camera and mic access) only work in what browsers call a secure context. A secure context is either https:// or, as a special exception, localhost itself. That exception is exactly what's been hiding this problem from you.
On your laptop, localhost gets a free pass. The instant your phone hits your laptop's IP address instead (which it has to, because phones don't know what your localhost is), you're on plain http://, over a network, with none of the secure-context exceptions. So the exact features that matter most for a PWA are the first ones to disappear, and they disappear silently. No error banner. No helpful message. Just missing behavior, which is somehow worse.
Note
Secure context = https:// or localhost. Your phone loading your laptop's IP address over plain http is neither, so anything that requires a secure context just quietly opts out.
What I tried before I fixed it properly
- Ignoring it. Told myself I'd "test properly before deploying." I did not test properly before deploying.
- Tunneling with ngrok. Works, but you get a new random URL every restart, an extra hop of latency, and a free-tier warning page your test users have to click through first.
- Deploying to a staging URL for every tiny check. Technically solves it. Also turns a 30-second UI tweak into a 4-minute feedback loop, which is a great way to lose an afternoon.
None of these were actually solving the problem: they were all just working around not having HTTPS locally. Here's what I actually did instead, running Vite + React on a Linux machine, testing against an Android phone on the same Wi-Fi.
Step 1: Find your actual local IP
This is the address your phone will use to reach your laptop. On Linux:
bash
hostname -IYou'll probably get more than one address back, something like 192.168.124.85 172.17.0.1 172.18.0.1, especially if Docker is installed:
192.168.x.x→ your actual local network IP. Use this one.172.x.x.x→ Docker's internal network, minding its own business. Ignore it: your phone can't see it and never will.
So the address I actually want is 192.168.124.85, and eventually I want to be opening https://192.168.124.85:5173 on my phone.
Step 2: Let Vite actually listen on the network
By default, Vite only exposes your dev server to localhost; your phone, sitting on the same Wi-Fi, still can't reach it. Fix that first, before worrying about certificates at all:
vite.config.ts
export default defineConfig({
server: {
// Allow access from other devices on the network
host: true,
port: 5173,
},
});At this point http://192.168.124.85:5173 loads on your phone. Progress! But it's still plain http://, which means it's still not a secure context, which means the service worker and geolocation prompts are still ghosting you. This step only solved reachability, not security. Time for certificates.
Step 3: Install mkcert
mkcert is the tool that fixes this properly. Instead of a self-signed certificate that every browser screams at you about, mkcert creates a local Certificate Authority (CA) and installs it into your machine's trust store. Any certificate that CA issues afterward is trusted automatically, no "Your connection is not private" wall, no clicking through red warnings.
bash
sudo apt install libnss3-tools
sudo apt install mkcertNote
If mkcert isn't in your distro's package manager, grab the binary directly from its GitHub releases page instead, same tool, just a different install path.
Step 4: Create your local Certificate Authority
bash
mkcert -installThen find where mkcert keeps that CA:
bash
mkcert -CAROOTInside that folder you'll find two files: rootCA.pem and rootCA-key.pem.
Heads up
rootCA.pem is the public certificate other devices need to trust: that's the one you'll copy to your phone. rootCA-key.pem is the private key for your CA. Never move that one anywhere. If it leaks, someone could mint certificates your machine trusts.
Step 5: Get rootCA.pem onto your phone
The doc says to "copy rootCA.pem to your phone" like it's obvious, but doesn't say how. Easiest way: send the file to yourself over WhatsApp or Telegram. If you'd rather skip an app, spin up a one-line file server instead and grab the cert over Wi-Fi:
bash
cd "$(mkcert -CAROOT)"
python3 -m http.server 8000
# then on your phone, visit:
# http://192.168.124.85:8000/rootCA.pemStep 6: Install the CA certificate on Android
- Go to Settings → Security → More Security Settings → Install from Device Storage → CA Certificate.
- Select the
rootCA.pemyou just downloaded. - Tap through Android's dramatic warning about trusting third-party certificates, and confirm.
Tip
The exact wording and depth of this menu shifts depending on your phone's manufacturer. Samsung especially enjoys hiding it one folder deeper than everyone else. After installing, Android shows a permanent notification that a certificate authority is installed on the device. That's expected: you did just tell your phone to trust an authority you invented in your terminal five minutes ago.
From this point on, your phone trusts any certificate mkcert creates.
Step 7: Generate the certificate for your local IP
bash
mkcert 192.168.124.85 localhostSwap in your own IP from Step 1. This creates two files:
192.168.124.85+1.pem→ the certificate192.168.124.85+1-key.pem→ the private key
Step 8: Wire the certificates into Vite
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import fs from "fs";
export default defineConfig({
plugins: [react()],
server: {
// Allow access from other devices
host: true,
// Enable HTTPS using mkcert certificates
https: {
key: fs.readFileSync("./192.168.124.85+1-key.pem"),
cert: fs.readFileSync("./192.168.124.85+1.pem"),
},
},
});Tip
If your backend API needs HTTPS too so your app is secure end-to-end, the same certificate files work there: swap app.listen(...) for https.createServer({ key, cert }, app).listen(...) using the exact same key/cert pair.
Step 9: Start it up
bash
npm run devYou should see something like:
text
Local: https://localhost:5173
Network: https://192.168.124.85:5173Open the Network URL on your phone, still on the same Wi-Fi.
The actual payoff
Padlock icon, no warnings, secure context achieved. The service worker registers. "Add to Home Screen" shows up unprompted. Location and camera permission dialogs actually fire. Everything behaves exactly like it will in production, hours before you've deployed anything, from your own couch. The whole setup ends up looking like this:
text
Linux Machine
│
│ HTTPS
▼
Vite React App
192.168.124.85:5173
│
▼
Phone Browser“You're not testing a simulation of the real experience anymore. You're testing the real experience, just early.”
A few closing notes
- If you switch Wi-Fi networks, your laptop's local IP changes, so you'll need to regenerate the leaf certificate for the new IP (no need to redo
mkcert -installor reinstall anything on your phone; the CA stays valid). - This same setup works for literally any local project, not just one app; set it up once per machine and reuse it everywhere.
- It costs you about 20 minutes, once, in exchange for never again being surprised by a feature that only breaks on real devices.
That's it. No tunnels, no staging deploys just to check a button, no more finding out your PWA doesn't quite work the way you thought after you've already told a client it does.