🔐
Local DevHTTPSPWAViteDX

Localhost Lies: Testing Your PWA the Way Your Users Actually Will

July 20, 2026 • 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 -I

You'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 mkcert
Note

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 -install

Then find where mkcert keeps that CA:

bash
mkcert -CAROOT

Inside 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.pem

Step 6: Install the CA certificate on Android

  1. Go to Settings → Security → More Security Settings → Install from Device Storage → CA Certificate.
  2. Select the rootCA.pem you just downloaded.
  3. 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 localhost

Swap in your own IP from Step 1. This creates two files:

  • 192.168.124.85+1.pem → the certificate
  • 192.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 dev

You should see something like:

shell
Local:   https://localhost:5173
Network: https://192.168.124.85:5173

Open 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:

shell
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 -install or 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.

BACK TO ALL POSTS