# Local HTTPS (/docs/infra/local-https)



`lpm dev --https` serves your dev server over `https://localhost`. Browsers trust the certificate. No `--insecure` flag, no warnings, no per-project setup beyond a one-time CA install.

This page covers the design — what gets generated, where it lives, how trust-store installation works on each OS, and how LPM CLI terminates TLS in front of the dev server.

## How it works [#how-it-works]

```text
1. Generate a root CA (one time, machine-global)
   └─ ~/.lpm/certs/rootCA.pem        ← cert
   └─ ~/.lpm/certs/rootCA-key.pem    ← private key (mode 0o600)

2. Install the CA into the OS trust store
   └─ macOS:   security add-trusted-cert (user login keychain, no sudo)
   └─ Linux:   /usr/local/share/ca-certificates/ + update-ca-certificates (sudo)
   └─ Windows: certutil -addstore Root (UAC)

3. Generate a per-project certificate
   └─ <project>/.lpm/certs/cert.pem
   └─ <project>/.lpm/certs/key.pem (mode 0o600)
   └─ SAN includes localhost, 127.0.0.1, and any --host arguments
   └─ Custom-host cert.pem files include a constrained project intermediate

4. Discover and verify the child server endpoint
   └─ Child can stay on plain HTTP
   └─ Listener must belong to the launched process tree

5. Start an LPM CLI-owned TLS frontend
   └─ Browser HTTPS/WSS → LPM CLI frontend → child HTTP/WS
   └─ Browser trusts the frontend certificate because the CA is trusted
```

You only do steps 1 and 2 once per machine (`lpm cert trust`). Steps 3–5 happen automatically on every `lpm dev --https` (or any project where `lpm.json > https: true`). The child must serve plain HTTP in this mode; disable framework-level HTTPS and let LPM CLI own TLS termination.

## File layout [#file-layout]

```text
~/.lpm/certs/                         ← global, one per machine
├── rootCA.pem                        ← root CA certificate
└── rootCA-key.pem                    ← private key, mode 0o600

<project>/.lpm/certs/                 ← per-project
├── cert.pem                          ← server certificate, or leaf + intermediate chain
└── key.pem                           ← private key, mode 0o600
```

Project certs live under `.lpm/certs/` next to `package.json`. Add `.lpm/` to `.gitignore` if you don't already — generated certs are environment-specific and shouldn't be committed.

## Key file safety [#key-file-safety]

Private keys are written with **mode `0o600` from creation** (`create_new` + `OpenOptionsExt::mode()`), not chmod-after-write. This eliminates the TOCTOU window where the file would briefly be world-readable.

If a key file already exists, it's removed first so the `create_new` succeeds on regeneration. This means `lpm cert generate` always produces a fresh key — there's no "preserve existing key" path.

## Trust store install [#trust-store-install]

`lpm cert trust` installs `rootCA.pem` into the OS trust store. Per-OS:

| OS      | Mechanism                                                                                                          | Elevation?        |
| ------- | ------------------------------------------------------------------------------------------------------------------ | ----------------- |
| macOS   | `security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db rootCA.pem` (user login keychain) | No sudo           |
| Linux   | Copy to `/usr/local/share/ca-certificates/lpm-local-ca.crt`, then `update-ca-certificates`                         | Sudo (both steps) |
| Windows | `certutil -addstore Root rootCA.pem`                                                                               | UAC prompt        |

Untrust:

```bash
lpm cert uninstall
```

Removes the CA from the trust store. Doesn't delete the on-disk CA — re-install with `lpm cert trust`. To wipe the CA itself: `rm -rf ~/.lpm/certs/`.

## Browser support [#browser-support]

Once the CA is trusted at the OS level, browsers that use the system trust store (Chrome, Edge, Safari, most Linux browsers) accept LPM CLI-generated certs without warnings.

**Firefox uses its own trust store** by default. To trust the LPM CLI CA in Firefox, either:

* Set `security.enterprise_roots.enabled = true` in `about:config` (Firefox then reads the system trust store), or
* Manually import `~/.lpm/certs/rootCA.pem` via Settings → Privacy → Certificates → View Certificates → Authorities → Import.

## Adding hostnames [#adding-hostnames]

By default, the project cert's Subject Alternative Name (SAN) includes `localhost` and `127.0.0.1`. To serve under a custom hostname:

```bash
lpm cert generate --host my-app.local
lpm cert generate --host a.local --host b.local      # repeatable
```

Pair with a `/etc/hosts` entry:

```text title="/etc/hosts"
127.0.0.1 my-app.local
```

Now `https://my-app.local:<resolved-port>` works in any browser. Useful for projects that need a real hostname (cookies scoped to a domain, OAuth callbacks, etc.).

`lpm cert generate` regenerates the project cert from scratch — there's no "add a hostname to the existing SAN" path. The default `localhost`, `127.0.0.1`, and `::1` SANs are always included.

When custom hostnames or `lpm.json > cert.extraPermittedDns` entries are present, `cert.pem` is written as a leaf-first TLS chain with a project-scoped constrained intermediate. `extraPermittedDns` adds validated NameConstraints subtrees; it does not add browser SANs. If your root CA was created before intermediate support, LPM CLI may ask you to run `lpm cert rotate` before issuing custom-host certificates.

## Framework independence [#framework-independence]

LPM CLI does not inject `HTTPS`, `SSL_CRT_FILE`, `SSL_KEY_FILE`, or framework-specific certificate variables into the child. It discovers the child endpoint first, then terminates HTTPS and secure WebSockets at its own frontend.

Port selection is separate from certificate setup. Vite receives a strict managed port when LPM CLI must assign one; Next.js, Nuxt, SvelteKit, Remix, and Astro receive their native port argument; generic servers receive `PORT`. If a child ignores an explicit managed port, startup fails before the browser, proxy, or tunnel can target the wrong listener.

With `lpm dev --https --port 4000`, `4000` is the browser-facing TLS port and the plain-HTTP child receives a separate internal port. Without `--port`, the frontend asks the OS for an available port, avoiding a probe-then-bind race.

## Status check [#status-check]

```bash
lpm cert status
```

Reports:

* **Root CA** — exists / trusted, subject, expiry
* **Project cert** — exists, expiry, hostnames in SAN, whether renewal is recommended

`--json` returns the same structurally.

## Renewal [#renewal]

Project certs have a long-ish but bounded lifetime. `lpm cert status` flags `needs_renewal: true` when expiry is within \~30 days. Regenerate:

```bash
lpm cert generate
```

The CA itself has a much longer lifetime — once you `lpm cert trust`, it lasts years. Re-running `lpm cert trust` is safe (idempotent — installs are deduped on cert serial).

## Why a per-machine root CA [#why-a-per-machine-root-ca]

LPM CLI doesn't ship a baked-in CA. Every machine generates its own root CA on first `lpm cert trust`. Reasons:

* The private key never leaves the machine. A compromised shared CA distributed by LPM CLI would be a supply-chain attack against every user.
* Removing trust is a local operation — `lpm cert uninstall` doesn't have to coordinate with anything.
* Project certs are signed by your local CA, so they're only trusted on machines that have run `lpm cert trust`. Sharing a project cert across machines doesn't accidentally trust its issuer everywhere.

## See also [#see-also]

* [`lpm cert`](/docs/infra/cert) — CLI command reference
* [`lpm dev --https`](/docs/dev/dev) — most common consumer
* [`lpm.json` https](/docs/reference/lpm-json#https) — persistent config
