jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Localhost HTTPS for Development — mkcert, OpenSSL, Caddy, Vite/Next & nginx

A hands-on guide to running https://localhost in development: mkcert, OpenSSL self-signed, Caddy, Node/Vite/Next.js dev servers and Docker + nginx — per-OS steps, verified commands, a comparison table, checklist and troubleshooting.

Note kỹ thuật nội bộ, viết lại thành bài blog. Mục tiêu: có https://localhost được tin cậy (khóa xanh, không cảnh báo) cho dev, không giả định bạn đã biết PKI. Mọi lệnh đều đối chiếu tài liệu chính thức — nhưng tool đổi theo version, nên chạy --version trước khi nghi ngờ.


Vì sao cần HTTPS trên localhost

http://localhost:3000 ổn cho tới khi không còn ổn. Nhiều tính năng trình duyệt chỉ chạy trong secure context:

  • API yêu cầu secure context — Service Worker, camera/mic, Web Crypto, Clipboard, WebAuthn, HTTP/2. http://localhost được coi là an toàn cho một số API, nhưng host tùy biến như dev.example.local thì không.
  • Cookie SecureSameSite=None chỉ gửi qua HTTPS — cần để mô phỏng auth/OAuth giống production.
  • Giống production — test redirect, HSTS, mixed-content, CSP.
  • Nhiều provider bắt buộc redirect URI là https://.

Vì certificate không được tin cậy vẫn làm hỏng Service Worker, fetch nội bộ và HTTP/2 — và phải click lại mỗi lần. Mục tiêu là cert được tin cậy, không phải tắt cảnh báo.


PKI trong 60 giây

Bỏ qua nếu đã biết:

  • Certificate — file công khai chứng minh “tôi là localhost”, đi kèm private key bí mật không bao giờ chia sẻ.
  • CA — ký vào cert để trình duyệt tin. Production dùng CA công khai; dev thì ta tạo CA cục bộ của mình.
  • Trust store — danh sách CA mà OS/trình duyệt tin. Muốn hết cảnh báo phải cài root CA vào trust store.
  • SAN — trình duyệt hiện đại bỏ qua field CN, chỉ đọc SAN để khớp hostname. Cert không SAN luôn lỗi. Luôn thêm tối thiểu DNS:localhost,IP:127.0.0.1IP:::1.

Trust store nằm ở đâu, theo OS:

OSSystem trust storeBrowser note
macOSKeychain Access → “System”Firefox needs NSS (brew install nss)
Windowscertlm.msc → “Trusted Root Certification Authorities”Chrome/Edge use system store
Linux/usr/local/share/ca-certificates/ + update-ca-certificatesFirefox/Chrome use a separate NSS store (libnss3-tools)

So sánh các phương án

ApproachEaseAuto-trustCustom domainReverse proxyUse when
mkcert⭐⭐⭐⭐⭐✅ easyDefault for most devs
OpenSSL self-signed⭐⭐❌ (trust manually)✅ (config)Minimal envs (CI), full control
Caddy⭐⭐⭐⭐✅ (caddy trust)✅ built-inA proxy in front of your app
Vite/Next builtin⭐⭐⭐⭐Next ✅ (mkcert) / Vite plugin ❌limitedPure FE project, fewest steps
Docker + nginx⭐⭐❌ (trust root manually)Prod-like topology, many services

Khuyến nghị cho team: dùng mkcert tạo cert được tin cậy, rồi nạp cert đó vào dev server hoặc proxy. Đa nền tảng, được tin cậy thật, và một bộ cert dùng được cho mọi nơi.

Không bao giờ commit private key hay root CA cá nhân vào git. Thêm vào .gitignore và generate bằng script.


Phương án 1 — mkcert (khuyến nghị)

mkcert là tool zero-config: tạo local CA, cài vào system root store, rồi cấp cert được tin cậy cục bộ. Nó không cấu hình server — việc nạp cert là của bạn.

Cài đặt:

# macOS
brew install mkcert
brew install nss          # only if you use Firefox

# Windows (PowerShell, run as Administrator if needed)
choco install mkcert
# or: scoop bucket add extras; scoop install mkcert

# Linux (Ubuntu/Debian)
sudo apt install libnss3-tools     # needed for Firefox/Chrome trust
brew install mkcert                # via Homebrew on Linux, or download a release binary

Trên Linux gói apt không phải lúc nào cũng có — Homebrew on Linux hoặc binary từ GitHub là chắc chắn nhất. Kiểm tra version release mới nhất.

Cài CA một lần, rồi cấp cert:

mkcert -install                       # installs local CA into the trust store (may ask password)
mkcert localhost 127.0.0.1 ::1        # creates localhost+2.pem and localhost+2-key.pem

Ví dụ thêm:

mkcert localhost myapp.localhost dev.example.local 127.0.0.1 ::1   # several names in one cert
mkcert "*.example.localhost"                                       # wildcard
mkcert -cert-file dev.pem -key-file dev-key.pem localhost 127.0.0.1  # explicit filenames

Lệnh hữu ích:

mkcert -CAROOT          # path to rootCA.pem / rootCA-key.pem
mkcert -uninstall       # remove the local CA from the trust store
mkcert -pkcs12 localhost   # export a .p12 for legacy apps (e.g. Java)

Để tin cert trên máy/container khác, copy rootCA.pem từ mkcert -CAROOT và import; nhưng không bao giờ mang rootCA-key.pem ra ngoài.

Kiểm tra:

openssl x509 -in localhost+2.pem -noout -text | grep -A1 "Subject Alternative Name"
curl -v https://localhost:3000      # no -k needed once it's trusted

Lỗi thường gặp:

ErrorCauseFix
Firefox still warnsMissing NSS/certutilInstall nss/libnss3-tools, re-run mkcert -install
permission denied (Windows)Not elevatedRun terminal as Administrator
Chrome still invalid after installCert made before CA install, or cacheRe-issue the cert; clear cache (see Troubleshooting)

Phương án 2 — OpenSSL self-signed

Dùng OpenSSL trực tiếp khi không cài được tool ngoài (CI, server tối giản) hoặc cần kiểm soát đầy đủ. Cái giá: không tự trust — bạn phải import vào trust store từng OS.

Cần OpenSSL 1.1.1+ để có -addext. Trên OpenSSL 3.x, -nodes vẫn chạy nhưng alias mới là -noenc. Kiểm tra bằng openssl version.

Self-signed một lệnh, có SAN:

openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 365 \
  -keyout localhost.key \
  -out localhost.crt \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1,IP:::1"

Tốt hơn: local CA tái dùng — trust CA một lần, rồi ký bao nhiêu cert tùy ý:

# 1) Root CA (trust this once)
openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \
  -keyout devRootCA.key -out devRootCA.crt -subj "/CN=My Dev Local CA"

# 2) Key + CSR for the domain
openssl req -newkey rsa:2048 -nodes -sha256 \
  -keyout localhost.key -out localhost.csr -subj "/CN=localhost"

# 3) Sign the CSR with the CA, attaching SAN via an ext file
cat > localhost.ext <<'EOF'
subjectAltName = DNS:localhost,DNS:*.localhost,DNS:dev.example.local,IP:127.0.0.1,IP:::1
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
basicConstraints = critical, CA:FALSE
EOF

openssl x509 -req -in localhost.csr \
  -CA devRootCA.crt -CAkey devRootCA.key -CAcreateserial \
  -out localhost.crt -days 365 -sha256 -extfile localhost.ext

Biến thể config file cho OpenSSL cũ không có -addextlocalhost.cnf:

[req]
default_bits       = 2048
prompt             = no
default_md         = sha256
distinguished_name = dn
x509_extensions    = v3_req

[dn]
CN = localhost

[v3_req]
subjectAltName   = @alt_names
keyUsage         = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth

[alt_names]
DNS.1 = localhost
DNS.2 = *.localhost
DNS.3 = dev.example.local
IP.1  = 127.0.0.1
IP.2  = ::1
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 365 \
  -keyout localhost.key -out localhost.crt -config localhost.cnf

Trust cert/CA theo OS:

# macOS (System Keychain — needs sudo)
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain localhost.crt
# Windows (PowerShell as Administrator)
Import-Certificate -FilePath "C:\path\localhost.crt" -CertStoreLocation Cert:\LocalMachine\Root
# Linux (Ubuntu/Debian) — system store
sudo cp localhost.crt /usr/local/share/ca-certificates/localhost.crt
sudo update-ca-certificates

# Firefox/Chrome on Linux use NSS, not the system store:
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "dev-local-ca" -i localhost.crt

Kiểm tra:

openssl x509 -in localhost.crt -noout -text | grep -A1 "Subject Alternative Name"
curl -v https://localhost:8080      # drop -k to test real trust
ErrorCauseFix
unknown option -addextOpenSSL < 1.1.1 / LibreSSLUse the config-file variant
hostname mismatchMissing/incorrect SANAdd the right name to subjectAltName, re-issue
NET::ERR_CERT_AUTHORITY_INVALIDCA/cert not trustedTrust it per OS above

Phương án 3 — Caddy local HTTPS

Caddy là web server/reverse proxy phục vụ HTTPS mặc định. Với host local/internal, nó sinh cert từ internal CA và cố cài root đó vào trust store.

Cài đặt:

brew install caddy          # macOS
choco install caddy         # Windows (or: scoop install caddy)
# Linux: use the official Caddy apt repo — see caddyserver.com/docs/install

tĩnh, hoặc proxy tới app:

# Reverse proxy localhost HTTPS -> a Vite app on port 5173
localhost {
	tls internal
	reverse_proxy localhost:5173
}

# Custom host -> app on port 3000
myapp.localhost {
	tls internal
	reverse_proxy 127.0.0.1:3000
}

tls internal ép Caddy dùng internal CA được tin cậy cục bộ thay vì cert public qua ACME.

Chạy + trust:

caddy trust        # install Caddy's root CA into the trust store (sudo/admin) — run once
caddy run          # foreground, reads ./Caddyfile
# or: caddy start / caddy stop

*.localhost tự resolve về loopback — myapp.localhost chạy ngay. Host khác như dev.example.local cần entry trong file hosts (xem Ví dụ).

Nếu auto-install thất bại (vd Docker), root cert nằm ở <data_dir>/pki/authorities/local/root.crt — copy và import thủ công.

ErrorCauseFix
Browser not trustedCaddy root CA not in storecaddy trust or import root.crt
permission denied on :443Privileged port, non-rootUse a high port :8443 (see Troubleshooting)
address already in useSomething holds 80/443Stop it or caddy stop

Phương án 4 — dev server Node/Vite/Next.js

Pattern tốt nhất: tạo cert bằng mkcert rồi nạp vào.

Node.js thuần:

import https from "node:https";
import { readFileSync } from "node:fs";
import express from "express";

const app = express();
app.get("/", (_req, res) => res.send("OK over HTTPS"));

https
  .createServer(
    { key: readFileSync("./localhost-key.pem"), cert: readFileSync("./localhost.pem") },
    app,
  )
  .listen(3000, () => console.log("https://localhost:3000"));

server.https nhận đúng object như https.createServer() của Node:

// vite.config.ts
import { defineConfig } from "vite";
import { readFileSync } from "node:fs";

export default defineConfig({
  server: {
    https: {
      key: readFileSync("./localhost-key.pem"),
      cert: readFileSync("./localhost.pem"),
    },
    host: "localhost",
    port: 5173,
  },
});

Biến thể nhanh nhưng untrusted: @vitejs/plugin-basic-ssl tự sinh cert self-signed, nhưng không được tin cậy nên trình duyệt vẫn cảnh báo. Ưu tiên cert mkcert ở trên để có khóa sạch.

Next.js có cờ dùng mkcert sẵn:

// package.json
{ "scripts": { "dev": "next dev --experimental-https" } }
npm run dev    # creates ./certificates and serves https://localhost:3000
# custom cert:
next dev --experimental-https \
  --experimental-https-key ./localhost-key.pem \
  --experimental-https-cert ./localhost.pem

--experimental-https có từ Next.js 13.5 và vẫn mang nhãn experimental — tên cờ có thể đổi, chỉ dùng cho dev.

ErrorCauseFix
ERR_OSSL_PEM_NO_START_LINEWrong path / not PEMCheck paths; use the right *-key.pem & *.pem
Vite plugin still warnsCert is untrusted by designSwitch to a mkcert cert via server.https
Next flag not recognizedNext < 13.5npm i next@latest
internal fetch self-signed errorserver-side HTTPS call to itselfUse a trusted cert; temporarily NODE_TLS_REJECT_UNAUTHORIZED=0 (dev only, insecure)

Phương án 5 — Docker + nginx reverse proxy

Đặt nginx trong container làm SSL termination proxy: nginx nghe HTTPS, forward HTTP xuống app. Tạo cert bằng mkcert ở host và mount vào.

# 1) On the host
mkcert -install
mkdir -p certs
mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1

file nginx.conf:

events {}

http {
    server {
        listen 443 ssl;
        server_name localhost;

        ssl_certificate     /etc/nginx/certs/localhost.pem;
        ssl_certificate_key /etc/nginx/certs/localhost-key.pem;
        ssl_protocols       TLSv1.2 TLSv1.3;
        ssl_ciphers         HIGH:!aNULL:!MD5;

        location / {
            proxy_pass http://app:3000;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header Upgrade $http_upgrade;       # WebSocket / HMR
            proxy_set_header Connection "upgrade";
        }
    }

    server {
        listen 80;
        server_name localhost;
        return 301 https://$host$request_uri;
    }
}

Từ nginx 1.25.1 directive ssl; cũ đã bị gỡ — luôn bật TLS bằng listen ... 443 ssl; như trên.

docker-compose.yml:

services:
  app:
    image: node:20-alpine
    working_dir: /app
    volumes:
      - ./:/app
    command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 3000"
    expose:
      - "3000"

  proxy:
    image: nginx:stable
    depends_on:
      - app
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
docker compose up
curl -v https://localhost     # trusted because the host already trusts the mkcert root

browser và curl chạy ở host, root mkcert của host là đủ. Nếu một container cần trust cert, nướng root vào image:

COPY rootCA.pem /usr/local/share/ca-certificates/devRootCA.crt
RUN update-ca-certificates     # Debian/Ubuntu images
ErrorCauseFix
cannot load certificateWrong mount pathCheck ./certs:/etc/nginx/certs and filenames
502 Bad GatewayWrong proxy_pass / app not readyApp must listen on 0.0.0.0:3000, service name app
bind: address already in usePort 443 takenMap "8443:443" or stop the other service

Ví dụ cụ thể

localhost cổng 3000:

mkcert localhost 127.0.0.1 ::1
next dev --experimental-https        # -> https://localhost:3000

*.localhost tự resolve, không cần sửa hosts:

mkcert -cert-file app.pem -key-file app-key.pem myapp.localhost localhost 127.0.0.1 ::1
# vite.config.ts: server.host = "myapp.localhost", server.port = 5173
# -> https://myapp.localhost:5173

.local cần entry hosts:

# macOS / Linux
echo "127.0.0.1 dev.example.local" | sudo tee -a /etc/hosts
# Windows: edit C:\Windows\System32\drivers\etc\hosts as Administrator, add:
#   127.0.0.1 dev.example.local

mkcert dev.example.local
# Caddyfile:
#   dev.example.local:8443 {
#     tls /path/dev.example.local.pem /path/dev.example.local-key.pem
#     reverse_proxy localhost:8080
#   }
# -> https://dev.example.local:8443

Trên macOS, .local do mDNS dùng; nếu resolve chập chờn, ưu tiên .localhost hoặc .test.

PortTypically forCommand
3000Next.js / Node / Expressnext dev --experimental-https / node server.js
5173Vitenpm run dev (with server.https)
8080Backend behind a proxynginx/Caddy reverse_proxy localhost:8080
443Standard HTTPS proxyCaddy/nginx (needs privileged-port permission)

Checklist

  • Cài tool
  • Cài local CA
  • Tạo certificate
  • Trust certificate
  • Config dev server
  • Entry hosts nếu cần
  • Test bằng browser
  • Test bằng curl
  • Kiểm tra SAN
  • Loại trừ key khỏi git

Xử lý sự cố

root CA chưa được tin cậy. Chạy mkcert -install rồi tạo lại cert, hoặc import root CA, hoặc caddy trust. Firefox cần NSS riêng.

SAN không khớp. Kiểm tra rồi tạo lại với tên đúng.

port đang bận:

lsof -i :3000 && kill -9 <PID>                    # macOS / Linux
netstat -ano | findstr :3000 ; taskkill /PID <PID> /F   # Windows

permission denied khi bind 443 — dễ nhất là dùng port cao như 8443. Trên Linux có thể cấp capability:

sudo setcap CAP_NET_BIND_SERVICE=+eip $(which caddy)

cache/HSTS bị kẹt — sau khi vào trang HTTPS lỗi, trình duyệt có thể từ chối. Trong Chrome mở chrome://net-internals/#hsts → xóa policy cho localhost, rồi hard-reload hoặc Incognito. Khởi động lại browser sau mkcert -install để nạp trust store mới.

trust store WSL/Windows lệch nhau — dev chạy trong WSL nhưng browser ở Windows, hai store tách biệt. Cài mkcert và chạy mkcert -install trên Windows, hoặc export root CA từ WSL rồi import trên Windows:

cp "$(mkcert -CAROOT)/rootCA.pem" /mnt/c/Users/<you>/Desktop/
Import-Certificate -FilePath "C:\Users\<you>\Desktop\rootCA.pem" -CertStoreLocation Cert:\LocalMachine\Root

Đảm bảo dev server trong WSL nghe 0.0.0.0 để Windows truy cập được.

curl lỗi nhưng browser OK hoặc ngược lại — curl dùng system store còn browser có thể dùng NSS; trust root ở cả hai. Chỉ để chẩn đoán: curl -k ....


Tài liệu tham khảo

Đối chiếu khi cần — tool thay đổi, kiểm tra version hiện tại: