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--versiontrướ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.localthì không. - Cookie
SecurevàSameSite=Nonechỉ 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.1vàIP:::1.
Trust store nằm ở đâu, theo OS:
| OS | System trust store | Browser note |
|---|---|---|
| macOS | Keychain Access → “System” | Firefox needs NSS (brew install nss) |
| Windows | certlm.msc → “Trusted Root Certification Authorities” | Chrome/Edge use system store |
| Linux | /usr/local/share/ca-certificates/ + update-ca-certificates | Firefox/Chrome use a separate NSS store (libnss3-tools) |
So sánh các phương án
| Approach | Ease | Auto-trust | Custom domain | Reverse proxy | Use when |
|---|---|---|---|---|---|
| mkcert | ⭐⭐⭐⭐⭐ | ✅ | ✅ easy | — | Default for most devs |
| OpenSSL self-signed | ⭐⭐ | ❌ (trust manually) | ✅ (config) | — | Minimal envs (CI), full control |
| Caddy | ⭐⭐⭐⭐ | ✅ (caddy trust) | ✅ | ✅ built-in | A proxy in front of your app |
| Vite/Next builtin | ⭐⭐⭐⭐ | Next ✅ (mkcert) / Vite plugin ❌ | limited | — | Pure 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
.gitignorevà 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
aptkhô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.pemtừmkcert -CAROOTvà import; nhưng không bao giờ mangrootCA-key.pemra 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:
| Error | Cause | Fix |
|---|---|---|
| Firefox still warns | Missing NSS/certutil | Install nss/libnss3-tools, re-run mkcert -install |
permission denied (Windows) | Not elevated | Run terminal as Administrator |
| Chrome still invalid after install | Cert made before CA install, or cache | Re-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,-nodesvẫn chạy nhưng alias mới là-noenc. Kiểm tra bằngopenssl 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ó -addext — localhost.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
| Error | Cause | Fix |
|---|---|---|
unknown option -addext | OpenSSL < 1.1.1 / LibreSSL | Use the config-file variant |
hostname mismatch | Missing/incorrect SAN | Add the right name to subjectAltName, re-issue |
NET::ERR_CERT_AUTHORITY_INVALID | CA/cert not trusted | Trust 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
*.localhosttự resolve về loopback —myapp.localhostchạy ngay. Host khác nhưdev.example.localcầ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.
| Error | Cause | Fix |
|---|---|---|
| Browser not trusted | Caddy root CA not in store | caddy trust or import root.crt |
permission denied on :443 | Privileged port, non-root | Use a high port :8443 (see Troubleshooting) |
address already in use | Something holds 80/443 | Stop 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-ssltự 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-httpscó từ Next.js 13.5 và vẫn mang nhãn experimental — tên cờ có thể đổi, chỉ dùng cho dev.
| Error | Cause | Fix |
|---|---|---|
ERR_OSSL_PEM_NO_START_LINE | Wrong path / not PEM | Check paths; use the right *-key.pem & *.pem |
| Vite plugin still warns | Cert is untrusted by design | Switch to a mkcert cert via server.https |
| Next flag not recognized | Next < 13.5 | npm i next@latest |
internal fetch self-signed error | server-side HTTPS call to itself | Use 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ằnglisten ... 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
Vì 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
| Error | Cause | Fix |
|---|---|---|
cannot load certificate | Wrong mount path | Check ./certs:/etc/nginx/certs and filenames |
502 Bad Gateway | Wrong proxy_pass / app not ready | App must listen on 0.0.0.0:3000, service name app |
bind: address already in use | Port 443 taken | Map "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,
.localdo mDNS dùng; nếu resolve chập chờn, ưu tiên.localhosthoặc.test.
| Port | Typically for | Command |
|---|---|---|
| 3000 | Next.js / Node / Express | next dev --experimental-https / node server.js |
| 5173 | Vite | npm run dev (with server.https) |
| 8080 | Backend behind a proxy | nginx/Caddy reverse_proxy localhost:8080 |
| 443 | Standard HTTPS proxy | Caddy/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:
- mkcert — github.com/FiloSottile/mkcert
- OpenSSL —
req·x509 - Caddy — Automatic HTTPS ·
tlsdirective · Install - Node.js — HTTPS API
- Vite —
server.https· plugin-basic-ssl - Next.js — CLI
next dev· Vercel KB: localhost HTTPS - nginx — Configuring HTTPS servers · SSL termination
- Let’s Encrypt — Certificates for localhost