How people reach the app over HTTPS, and what to set up where. Read this when installing the app on a new machine, on a remote server, or when a new phone, tablet or computer starts using it. Getting the code running at all is SETUP.md; running the built apps under pm2 is the README, "Commands".
The decision behind it: PLAN §13, 2026-10-26 and 2026-10-29. The code:
deploy/https/ (the Caddyfile, and host.cjs, which decides when Caddy
runs), scripts/https-setup.mjs and scripts/https.mjs.
Contents
- Which setup do you need?
- Why other devices need HTTPS
- The shop's network: how Caddy does it
- The shop's network: first-time installation on the server
- The shop's network: registering a device — Android · iPhone and iPad · Mac · Windows · Firefox
- Installing the app on a phone
- A remote server with a domain (no Caddy)
- Another computer becomes the server
- Day to day
- Troubleshooting
- Known limits
Caddy is optional. It has one job: giving the devices on the shop's own
network HTTPS when there is no domain. The app does not depend on it:
both apps run on their own ports (API :8080, web app :8081) with or
without it, and nothing in the app's code knows it exists.
| Where the app runs, and who uses it | HTTPS from | Set up | Each device |
|---|---|---|---|
One computer, used only on that computer (http://localhost:8081) |
not needed — localhost counts as secure |
nothing | nothing |
| The shop's network: one computer serves, phones, tablets and other computers on the Wi-Fi use it | Caddy, with its own local certificate authority | sections 3–4 | trusts the certificate once (section 5) |
A remote server with a domain (https://pos.yourshop.com) |
the host: the platform, Cloudflare, or nginx with Let's Encrypt — no Caddy | section 7 | nothing: a public certificate is trusted already |
Caddy runs only when the API's FRONTEND_URL is https:// on a private
network address — 192.168.x.x, 10.x.x.x or 172.16–31.x.x
(httpsHost() in deploy/https/host.cjs). With http://, localhost or a
domain, pm2 runs only the two apps and pnpm https refuses to start.
By default both apps listen on every address, so another device could
also open http://<address>:8081 or :8080 directly, around the front
door, unencrypted. LISTEN_HOST="127.0.0.1" (in
envs/<name>/web-server.env) makes both apps answer only to this
computer, so the front door is the only way in from the network:
THE SERVER
┌───────────────────────────────────────────┐
📱 https://<address> ─🔒─▶│ Caddy / nginx :443 ─┬─▶ web app 127.0.0.1:8081 │
│ └─▶ API 127.0.0.1:8080 │
📱 http://<address>:8081 ✗ refused: nothing listens there for the network │
└───────────────────────────────────────────┘
| Setup | LISTEN_HOST |
|---|---|
| One computer | empty (default). 127.0.0.1 also works, and keeps the apps off the network |
| The shop's network (Caddy) | 127.0.0.1 — pnpm https:setup sets it, --off removes it |
| A remote server, nginx on the same machine | 127.0.0.1 — set it yourself (section 7) |
| A remote server on a platform (Render, Fly.io, …) or in Docker | ⚠ empty: the front door is on another machine and must reach the apps over the network; 127.0.0.1 would cut them off |
The API reads it itself (apps/web-server/src/config/env.ts); the web app
gets it as next -H from scripts/next.mjs (its dev and start scripts)
and from pm2 (ecosystem.config.cjs). The server always keeps
http://localhost:8081.
⚠ For this computer the web app is given localhost, never 127.0.0.1
(webListenHost() in deploy/https/host.cjs): Next writes 127.0.0.1 as
"localhost" inside its middleware, so with -H 127.0.0.1 the phone layout's
rewrite looked external, and every page on a phone was a 500
("Failed to proxy https://localhost:8081/mobile … wrong version number").
It then listens on [::1], which localhost and Caddy reach.
Any device other than the server itself must open the app over HTTPS, on the shop's network as on a remote server:
- The built app's sign-in needs it. Its session cookies are
Secure, and browsers drop those on plain http, so nobody stays signed in. - Live updates run over
wss://once the page ishttps://. - Installing the app on a phone's home screen, system pop-up notifications and the Copy buttons only exist on a secure page.
- ⚠ Today the app crashes on plain
http://<address>:8081, in development too: offlocalhostthe page is not a secure context, so the browser leaves outcrypto.randomUUID(), which the browser code calls in a dozen places (the phone layout fails as it loads; taking payment would too). Openhttps://<address>instead.
The server itself may keep using http://localhost:8081.
With no domain there is no public certificate. Caddy sits in front of both apps on port 443 with a certificate signed by its own local certificate authority, which each device trusts once.
https://<address>/api/v1/* → the API, :8080 (and the live-updates socket, wss://)
https://<address>/* → the web app, :8081
http://<address>/kwtech-local-ca.crt → the authority's certificate, for devices to install
http://<address>/* → redirected to https://
<address>is the server's address on the network, e.g.192.168.1.186.- The authority lives in the gitignored
.caddy/folder at the repository's root. ⚠ It holds the authority's private key: never commit it, never share it, and keep it when the server moves (section 8), or every device must be registered again. - Devices trust the authority, not the address: when the address changes, the server issues itself a new certificate and the devices need nothing new.
Once, on the computer that serves the shop, after the app itself runs (SETUP.md). About ten minutes.
On the router's admin page, reserve the server's current address for it (a DHCP reservation, also called static lease or address reservation). Otherwise the router may give the server a new address after a restart, and every device shows a warning until step 4 is run again.
To see the address: ipconfig getifaddr en0 (Mac), hostname -I (Linux),
ipconfig (Windows). pnpm https:setup finds it too.
| System | Install |
|---|---|
| Mac | brew install caddy |
| Ubuntu, or Windows with WSL | Caddy's own apt repository (caddyserver.com/docs/install), then sudo apt install caddy. Then sudo systemctl disable --now caddy: the app runs Caddy itself, not the system service |
Check it: caddy version.
Other devices reach Windows, not WSL, unless WSL shares Windows' network:
- In
%USERPROFILE%\.wslconfig, under[wsl2], addnetworkingMode=mirrored(Windows 11). - Run
wsl --shutdown, then open WSL again. - In Windows Defender Firewall, allow inbound TCP ports 80 and 443.
The address in step 1 is then the Windows PC's.
⚠ Not yet tried on WSL: with LISTEN_HOST="127.0.0.1" (step 4) the apps
answer only on WSL's own localhost. Mirrored networking shares localhost
with Windows, so a browser on the Windows PC (http://localhost:8081) and a
print agent on that PC (http://localhost:8080) should still reach them.
If either does not connect, open https://<address> there instead, as the
phones do. Phones are unaffected: they come in through Caddy.
From the repository, with the right profile active (pnpm env:show):
pnpm https:setupIt finds the address and asks you to confirm it; checks Caddy (and prints
the install command if it is missing); on Linux lets Caddy use ports 80 and
443 without root (setcap, asks for the sudo password once); writes three
settings into the active profile and nothing else — FRONTEND_URL,
CORS_ORIGINS and LISTEN_HOST="127.0.0.1" in envs/<name>/web-server.env,
NEXT_PUBLIC_WS_URL in envs/<name>/web-app.env; checks the Caddyfile for
that address; and prints what is left.
⚠ From the next start, the apps answer only to Caddy: other devices use
https://<address>, and http://<address>:8081 is refused (section 1, "One
way in"). Restart both apps after the setup for it to take effect.
Options: --address=192.168.1.186 (no detection), --yes (no questions),
--off (back to http://localhost, section 9).
The built apps (the shop's server):
pnpm build # ⚠ needed: NEXT_PUBLIC_WS_URL is baked into the web app's build
pnpm pm2:start # three services: kwtech-web-server, kwtech-web-app, kwtech-https
pnpm pm2:save # all three start again after a rebootDevelopment: the dev servers as usual (pnpm dev:focus … or pnpm dev),
and in a second terminal:
pnpm https # Caddy beside the dev servers; Ctrl+C stops itThe first start creates the authority in .caddy/.
curl -sk -o /dev/null -w "%{http_code}\n" https://192.168.1.186/api/health # 200
curl -sk -o /dev/null -w "%{http_code}\n" https://192.168.1.186/api/v1/health # 200 (the API through Caddy)
curl -s -o /dev/null -w "%{http_code}\n" http://192.168.1.186/kwtech-local-ca.crt # 200
curl -s -m 3 http://192.168.1.186:8081/ || echo "refused — good: the only way in is https"Then register each device (section 5), the server's own browser too if it
will use the https:// address.
Once on each phone, tablet or computer, connected to the shop's
Wi-Fi. It makes the device trust the shop's certificate authority, so
https://<address> opens without a warning. iPhones and Android phones
deliberately ask a person to do this; no script can.
Every device starts by downloading the certificate from
http://192.168.1.186/kwtech-local-ca.crt — plain http://, because
the device does not trust the https:// address yet.
Chrome downloads the certificate but cannot install it; Android installs it from Settings.
-
Download. In Chrome, open
http://192.168.1.186/kwtech-local-ca.crt. It saveskwtech-local-ca.crtto Downloads. If Chrome says a certificate cannot be installed from the browser, that is expected. -
Install, in Settings:
- Pixel and most phones: Settings → Security & privacy → More
security settings → Encryption & credentials → Install a
certificate → CA certificate → Install anyway → choose
kwtech-local-ca.crt. - Samsung: Settings → Security and privacy → More security settings → Install from device storage → CA certificate → Install anyway → choose the file.
- Other phones: search Settings for "CA certificate".
Choose CA certificate, never VPN & app user certificate or Wi-Fi certificate: those do not make the browser trust it. Android asks for a screen lock (PIN, pattern or password) first; set one if the phone has none.
- Pixel and most phones: Settings → Security & privacy → More
security settings → Encryption & credentials → Install a
certificate → CA certificate → Install anyway → choose
-
Open the app. Close any tab still on the old address, then open
https://192.168.1.186in Chrome.
To remove it later: Encryption & credentials → Trusted credentials → User → Caddy Local Authority → Remove.
Two steps, and both are needed.
- Download. In Safari (not Chrome), open
http://192.168.1.186/kwtech-local-ca.crt→ Allow. iOS says Profile Downloaded. - Install the profile. Settings → Profile Downloaded (near the top; or General → VPN & Device Management) → Install → passcode → Install.
- Turn on full trust. Settings → General → About → Certificate Trust Settings → turn on Caddy Local Authority → Continue.
- Open the app:
https://192.168.1.186in Safari.
Skipping step 3 is the usual reason an iPhone still says Not Private.
⚠ Double-clicking the file can fail with "Unable to import … Error: -25294": the dialog picks a keychain it cannot write to. Instead:
-
On the server itself, from the repository:
security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db .caddy/pki/authorities/local/root.crtmacOS asks for your password.
-
On another Mac: download the file, then Keychain Access → File → Import Items… → the file, Destination: login. Open the imported Caddy Local Authority → Trust → When using this certificate: Always Trust, and enter your password.
Then quit and reopen the browser (Safari and Chrome use the keychain).
- Download the file, open it → Install Certificate…
- Store location: Local Machine → Next (allow the admin prompt).
- Place all certificates in the following store → Browse → Trusted Root Certification Authorities → Next → Finish.
- Restart the browser (Edge and Chrome use the Windows store).
Firefox keeps its own list: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import… → the file → tick Trust this CA to identify websites.
Open https://192.168.1.186 and sign in:
- no Not private / Not secure warning, and a padlock in the address bar;
- the point of sale shows no "Not live — other tills' changes show when you reload" note: live updates work;
- an order held on the phone appears on the Cashier screen by itself.
On any https:// address — the shop's, once the device is registered, or a
remote server's — the app can live on the home screen and open full screen.
- Android (Chrome): open the address → ⋮ → Install app (or Add to Home screen) → Install.
- iPhone / iPad (Safari): open the address → Share → Add to Home Screen → Add.
On a server reachable from the internet, HTTPS is required — passwords and sessions would otherwise cross the internet in the clear — and it comes from the host, with a public certificate every device already trusts. No Caddy, and no device registration.
-
A domain (e.g.
pos.yourshop.com) whose DNS points at the server. -
HTTPS from the host, one of:
- a platform that provides it (Render, Railway, Fly.io, DigitalOcean App Platform, …);
- Cloudflare in front of the server;
- on a plain VPS, nginx with certbot (Let's Encrypt). (Caddy with
the domain as its site address would also do it, with its own automatic
Let's Encrypt — but not through
pnpm https, which is for the shop's network only.)
-
Routing, the same as Caddy does on the shop's network:
Path Goes to /api/v1/*, including the WebSocket upgrade (live updates)the API, port 8080 everything else the web app, port 8081 Or put the API on a subdomain of its own (
api.yourshop.com→ 8080) and adjust the settings below to it. -
Ports: only 443 (and 80, for the certificate and the redirect) open to the internet. Never expose 8080, 8081 or Postgres' 5432. With nginx on the same machine,
LISTEN_HOST="127.0.0.1"(below) closes 8080 and 8081 even if the firewall is wrong; on a platform the platform keeps them private.
In envs/<name>/ on the server (pnpm env:new <name>, then pnpm env:use <name>; set APP_ENV to production):
# web-server.env
FRONTEND_URL="https://pos.yourshop.com" # a domain: pm2 starts NO Caddy
CORS_ORIGINS="https://pos.yourshop.com"
LISTEN_HOST="127.0.0.1" # ONLY with nginx on this same machine; leave EMPTY on a platform or in Docker
# web-app.env
NEXT_PUBLIC_WS_URL="wss://pos.yourshop.com/api/v1/graphql" # baked in at build timeDo not run pnpm https:setup there: it is for the shop's network, and
writes a private address.
pnpm build && pnpm pm2:start && pnpm pm2:save # two services: kwtech-web-server, kwtech-web-app
pnpm pm2:status # no kwtech-https here, by designAfter sudo certbot --nginx -d pos.yourshop.com has issued the
certificate:
server {
listen 443 ssl;
server_name pos.yourshop.com;
# ssl_certificate / ssl_certificate_key: written here by certbot
# Uploads (photos, PDFs) larger than nginx's 1 MB default.
client_max_body_size 50m;
location /api/v1/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
# Live updates: the WebSocket upgrade must be passed on.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 1h; # a live-updates socket stays open
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name pos.yourshop.com;
return 301 https://$host$request_uri;
}On a platform or behind Cloudflare, set the same routing in its settings, and make sure WebSockets are allowed (Cloudflare: Network → WebSockets on).
curl -s -o /dev/null -w "%{http_code}\n" https://pos.yourshop.com/api/health # 200
curl -s -o /dev/null -w "%{http_code}\n" https://pos.yourshop.com/api/v1/health # 200Then sign in from a phone: no warning, and no "Not live" note in the point of sale. Google sign-in works here (it needs a real domain).
A new computer takes over serving the shop's network (or a copy is set up on another computer):
- Install and run the app on it: SETUP.md.
- Copy what the repository does not carry, from the old server:
.caddy/— the certificate authority. ⚠ Without it a new authority is made, and every device must be registered again (section 5). With it, devices notice nothing.envs/<name>/— the profile with its secrets, thenpnpm env:use <name>.- The database, if it moves too: SETUP.md §7, "Moving a database to another computer".
- On the new server, section 4: steps 1–3 (address, Caddy, WSL), then
pnpm https:setup(step 4), which writes the NEW address, and step 5. - Devices: nothing, if
.caddy/was copied — they trust the authority, which signs a certificate for the new address. They just open the new address (or the same one, if the new server took it over on the router).
A remote server instead: section 7, with no .caddy/ to copy.
A print agent (apps/print-agent/README.md)
on the server itself keeps http://localhost:8080/api/v1. On another
computer on the shop's network it goes through the front door, like a phone:
API_URL="https://<address>/api/v1" and
WS_URL="wss://<address>/api/v1/graphql". The agent is a Node program and
does not read the computer's certificate list: point it at the shop's
certificate, downloaded from http://<address>/kwtech-local-ca.crt, with
NODE_EXTRA_CA_CERTS=/path/to/kwtech-local-ca.crt in its environment.
(Not yet tried on a second computer.)
| Task | How |
|---|---|
| Start (built apps) | pnpm pm2:start — Caddy too (kwtech-https) on the shop's network |
| Start (development) | the dev servers as usual, plus pnpm https in a second terminal |
| What is running | pnpm pm2:status: kwtech-web-server, kwtech-web-app, and on the shop's network kwtech-https |
| Caddy's log | pnpm pm2:logs kwtech-https (built), or the pnpm https terminal |
| Stop | pnpm pm2:stop — all of them (e.g. before pnpm dev, which needs the same ports) |
| The address changed (new router, another network) | pnpm https:setup again, then pnpm build && pnpm pm2:start. Devices need nothing new. Then reserve the address (section 4, step 1) |
| A new phone, tablet or computer | section 5 only |
| Turn local HTTPS off | pnpm https:setup --off (the profile goes back to http://localhost), then pnpm build && pnpm pm2:start |
| What you see | Why, and the fix |
|---|---|
| Your connection is not private / Not secure on a device | It has not trusted the authority yet (section 5). iPhone: both Install and Certificate Trust Settings. Android: installed as CA certificate, from Settings |
| The warning comes back on every device at once | The server's address changed: pnpm https:setup, rebuild (section 9), reserve the address on the router |
| A phone shows a crash, or "crypto.randomUUID is not a function" | It is on plain http://<address>:8081: open https://<address> (section 2) |
http://<address>:8081 (or :8080) is refused from another device |
By design with LISTEN_HOST="127.0.0.1": the only way in is https://<address> (section 1, "One way in") |
| Every page on a phone is a 500, the server's log says Failed to proxy https://localhost:8081/mobile … wrong version number | The web app was started with -H 127.0.0.1. Start it through its scripts or pm2, which give Next localhost (section 1, "One way in") |
A remote server's site is down after setting LISTEN_HOST |
It runs on a platform or in Docker, whose front door is on another machine: remove LISTEN_HOST (section 1) and restart |
| A print agent on another computer cannot connect | It needs https:// / wss:// and NODE_EXTRA_CA_CERTS pointing at the shop's certificate (section 8) |
| A phone cannot stay signed in | It is on http://: the built app's cookies need https:// |
| "Not live — other tills' changes show when you reload" | Live updates cannot connect. Is Caddy (or the remote host's proxy) running and passing the WebSocket upgrade on /api/v1/? Is NEXT_PUBLIC_WS_URL wss://<address or domain>/api/v1/graphql? It is baked in: pnpm build after changing it |
kwtech-https errored in pnpm pm2:status |
Caddy is not installed, or on Linux may not use port 443: pnpm https:setup says which. pnpm pm2:logs kwtech-https shows the error |
pnpm https says FRONTEND_URL … is not an https:// address on this network |
Run pnpm https:setup first. On a remote server with a domain this is expected: there is no Caddy there (section 7) |
A remote server shows kwtech-https |
FRONTEND_URL is a private address, not the domain: fix the profile (section 7), then pnpm pm2:start |
| The page does not open from a phone | Not on the shop's Wi-Fi; ports 80/443 blocked (Windows: section 4, step 3); or Caddy is not running |
| Mac: Error: -25294 importing the certificate | security add-trusted-cert, or Keychain Access with Destination: login (section 5, Mac) |
| Phones get 502 Bad Gateway after the ports were changed | The Caddyfile still says localhost:8080 / localhost:8081: SETUP.md §8, "Changing the ports" lists every place |
| Uploads fail on a remote server | The proxy's body limit: nginx's client_max_body_size (section 7) |
- Google sign-in does not work on the shop's network (a bare address): Google accepts only a real domain. Passwords and two-step verification do. On a remote server with a domain it works.
- The shop's network only for Caddy: nothing is reachable from the internet that way, by design.
NEXT_PUBLIC_WS_URLis baked into the web app's build: rebuild after changing it.- Plain
http://from another device does not work today (section 2). Making the browser code independent ofcrypto.randomUUID()would let development run over plain http from phones; the built app would still need HTTPS to stay signed in.