Kiến trúc Custom Domain
BẮT BUỘC đọc trước khi code bất kỳ phần nào liên quan host routing, tenant-by-domain resolution, TLS edge, hoặc clean-URL generation.
Cho phép tenant gắn domain riêng (vd mysalon.com) để truy cập thẳng trang salon + booking, tương đương https://glamvoo.com/b/<slug>, với URL sạch (mysalon.com/book thay vì lộ slug nội bộ).
Quyết định nền tảng (đã chốt 2026-06-01):
- TLS + edge: Caddy thay hẳn Nginx (Cách B). Caddy làm reverse proxy duy nhất + auto-HTTPS cho platform domain + on-demand TLS cho custom domain. Bỏ hoàn toàn Certbot + Nginx. Chọn B vì prod chưa nhiều khách → rủi ro migration thấp, đổi lấy 1 proxy duy nhất + 0 cron renew.
- URL scheme: Clean URLs — proxy rewrite ngầm
mysalon.com/book→/b/<slug>/book.
1. Phạm vi
Trong phạm vi (in scope)
- Tenant tự thêm/gỡ 1 custom domain trong admin (model sẵn sàng multi-domain).
- Xác minh sở hữu domain qua DNS (CNAME + TXT token).
- Cấp + gia hạn TLS cert tự động cho domain đã verify (Caddy on-demand).
- Host → slug resolution ở edge (Next
proxy.ts) → rewrite về/b/<slug>/.... - Clean URLs: mọi link trong subtree salon emit path không prefix khi ở custom domain.
- Absolute URL host-aware (payment return, email, share-link) trả về đúng custom domain.
- Canonical SEO trỏ về custom domain khi ACTIVE.
Ngoài phạm vi (cho đến có yêu cầu cụ thể)
- Nhiều domain / domain alias per tenant (model hỗ trợ, UI chỉ 1 domain MVP).
- Custom domain cho
/admin(admin chỉ ở platform domain). - Apex domain qua A record nhiều IP / load-balanced origin (MVP: CNAME hoặc 1 A record).
- Email gửi từ domain tenant (chỉ web + booking, không động tới mail).
- Google OAuth customer login trên custom domain → xem §9 (defer được).
2. Kiến trúc tổng thể
Internet :80/:443
│
┌───────▼─────────────────────────────────────────┐
│ Caddy │
│ reverse proxy DUY NHẤT (thay Nginx) │
│ • auto-HTTPS: platform domain (glamvoo.com…) │
│ • on_demand_tls: custom domain │
│ ask → api:3010/api/internal/tls-allow │
│ • routing: /api/* → api, còn lại → web, │
│ images.→imgproxy, docs.→docs (basic_auth) │
└───┬──────────┬──────────┬──────────┬─────────────┘
web│3020 api│3010 imgproxy│8080 docs│80
│
Next proxy.ts: Host=mysalon.com
→ resolveSlug(host) (cache 60s, fetch /api/public/domains/resolve)
→ rewrite /book → /b/studio-nordic/book
→ set x-custom-domain, x-tenant-slug
Caddy thay hẳn Nginx: 1 proxy duy nhất lo TLS + routing. Auto-HTTPS xin/gia hạn cert Let's Encrypt cho platform domain trong-process (không cron, không certbot). On-demand TLS cấp cert cho custom domain ngay trong TLS handshake, gated bằng ask endpoint. Custom domain hit Caddy → route về web (Next proxy.ts rewrite về /b/<slug>).
Scaling note: prod hiện 1 replica nên
reverse_proxy web:3020(single hostname) là đủ. Khi scaledocker compose --scale web=N, dùng Caddy dynamic upstreams (A-record) để round-robin — tương đương trickresolver 127.0.0.11của Nginx cũ. Defer đến khi cần.
3. Data model
enum TenantDomainStatus { PENDING VERIFYING ACTIVE FAILED }
model TenantDomain {
id String @id @default(uuid())
tenantId String @map("tenant_id")
hostname String @unique // mysalon.com — lowercase, no scheme/port
status TenantDomainStatus @default(PENDING)
verifyToken String @map("verify_token") // dùng cho TXT record
verifiedAt DateTime? @map("verified_at")
lastCheckedAt DateTime? @map("last_checked_at")
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([status])
@@map("tenant_domains")
}
Tenant thêm quan hệ domains TenantDomain[].
Lý do dùng bảng riêng thay vì field trên Tenant: cần verification state (token, status, lastError, lastChecked) + sẵn sàng multi-domain mà không phải refactor sau.
hostname normalize: lowercase, bỏ scheme/port/trailing dot, reject platform domain + reject nếu đã tồn tại.
4. API
Tenant scope từ auth, KHÔNG từ URL. Theo convention codebase (
@TenantId()decorator), endpoint owner mount ở/tenant-domainschứ không phải/tenants/:id/domains—tenantIdlấy từ JWT (hỗ trợ cả impersonation), cross-tenant access bất khả thi. Tất cả prefix/api.
| Method | Endpoint | Mục đích | Auth |
|---|---|---|---|
| GET | /tenant-domains |
List domain của tenant hiện tại + status + DNS instructions | OWNER/ADMIN |
| POST | /tenant-domains |
Thêm domain → PENDING + sinh verifyToken | OWNER/ADMIN |
| POST | /tenant-domains/:id/verify |
Trigger DNS check ngay | OWNER/ADMIN |
| DELETE | /tenant-domains/:id |
Gỡ domain (204) | OWNER/ADMIN |
| GET | /public/domains/resolve?host= |
host → { slug } (chỉ ACTIVE), else 404 |
public, @SkipThrottle |
| GET | /internal/tls-allow?domain= |
200 (text/plain "ok") nếu có domain ACTIVE, else 404 — Caddy ask | internal-only, @SkipThrottle |
Tuân thủ envelope chuẩn ({ success, data }) cho mọi response trừ /internal/tls-allow (Caddy chỉ đọc status code; nhận query domain do Caddy tự append).
/internal/tls-allow không được expose ra ngoài. Caddy ask gọi thẳng http://api:3010/api/internal/tls-allow qua Docker network (không qua site-routing của Caddy). Để chặn truy cập public, Caddyfile thêm matcher trả 404 cho /api/internal/* ở mọi site block (xem §6).
/public/domains/resolve được proxy.ts gọi mỗi request lạ → phải nhanh + cache phía proxy (§6).
5. Verification flow
Khi tenant thêm domain:
- Tạo
TenantDomainstatusPENDING, sinhverifyToken(random, unguessable). - Admin UI hiển thị hướng dẫn DNS:
- CNAME
mysalon.com→connect.glamvoo.com(apex domain không CNAME được → dùng A record trỏ IP VPS, hoặc CNAME flattening của nhà cung cấp DNS). - TXT
_glamvoo-verify.mysalon.com=<verifyToken>(chứng minh sở hữu).
- CNAME
- Tenant bấm "Verify ngay" →
DomainVerificationService:dns/promises.resolveTxt('_glamvoo-verify.' + host)chứaverifyToken(ownership — check trước).dns/promises.resolveCname()trỏ vềcnameTarget, hoặc (apex)resolve4()khớp 1 trongCUSTOM_DOMAIN_SERVER_IPS(routing).- Cả 2 pass → status
ACTIVE, setverifiedAt. Fail →FAILED+lastError.
- Domain
ACTIVE→ lần đầu truy cập HTTPS, Caddyasktrả 200 → tự cấp Let's Encrypt cert.
DEFER — auto-poll cron: project hiện chưa có scheduler nào (
@nestjs/schedulechưa cài). P2 ship verify thủ công (nút bấm) với DNS check thật — đủ cho luồng "tenant set DNS xong quay lại bấm Verify". Cron tự quét lạiPENDINGđịnh kỳ là tiện ích bổ sung, defer đến khi cần (sẽ thêm@nestjs/schedulelúc đó).
Không fallback: nếu DNS chưa đúng → giữ PENDING + báo lỗi rõ ràng cho tenant, KHÔNG auto-mark ACTIVE.
6. Edge — Caddy (thay hẳn Nginx)
Caddyfile
{
email ops@glamvoo.com # Let's Encrypt account
on_demand_tls {
ask http://api:3010/api/internal/tls-allow
}
}
# Snippet routing dùng chung cho web + api split + chặn endpoint nội bộ
(app) {
@internal path /api/internal/*
respond @internal 404 # chặn /api/internal/* khỏi public
handle /api/* { reverse_proxy api:3010 }
handle { reverse_proxy web:3020 }
}
# ── Platform web (customer + admin) ──
glamvoo.com { import app }
app-dev.novagoo.com { import app } # dev tunnel
www.glamvoo.com {
redir https://glamvoo.com{uri} permanent
}
# ── imgproxy ──
images.glamvoo.com {
reverse_proxy imgproxy:8080
header Cache-Control "public, max-age=2592000, immutable"
}
# ── docs (basic auth) ──
docs.glamvoo.com {
basic_auth {
# tạo hash: `docker exec caddy caddy hash-password --plaintext '<pw>'`
<user> <bcrypt-hash>
}
reverse_proxy docs:80
}
# ── Custom domains — cert on-demand ──
https:// {
tls { on_demand }
import app
}
Lưu ý migration từ Nginx:
client_max_body_size 25m(Nginx) → Caddy mặc định không giới hạn request body; nếu cần chặn thì thêmrequest_body { max_size 25MB }. Upload limit thực vẫn enforce ở API.- Gzip → Caddy bật
encode gzip zstd(thêm vào snippet(app)nếu muốn; Next thường tự nén). - Basic auth docs:
htpasswd→ Caddybasic_authvới bcrypt hash (caddy hash-password). - Health check nội bộ: thêm site
:8080 { respond /healthz 200 }nếu compose cần.
docker-compose.prod
- Bỏ service
nginx+ bỏ mount certbot/letsencrypt + bỏ croncertbot renew. - Thêm service
caddy(imagecaddy:2), ăn80:80+443:443, mountCaddyfile(read-only) + 2 named volumecaddy_data(cert store — quan trọng, đừng để mất) +caddy_config. api,web,imgproxy,docsgiữ nguyên, chỉ là upstream nội bộ (không expose port ra host nữa).- Doc deploy cập nhật + hướng dẫn DNS cho tenant.
Cutover: vì bỏ Nginx + Certbot cùng lúc, lần deploy đầu Caddy sẽ tự xin lại cert cho toàn bộ platform domain (glamvoo.com, www, images, docs, app-dev). Đảm bảo port 80 mở (ACME http-01) và DNS đã trỏ đúng trước khi cutover. Cert cũ của Certbot không cần migrate — Caddy xin mới.
Subdomain vs Apex (hướng dẫn DNS)
Admin UI hiển thị hướng dẫn DNS khác nhau theo loại hostname (dns.isApex, heuristic đếm label — đúng cho .no/.com):
- Subdomain (
book.mysalon.com) — khuyến nghị: 1 bản ghiCNAME → connect.glamvoo.com. Đổi IP edge chỉ sửa 1 chỗ, không đụng website chính của khách. Verify qua nhánh CNAME → không cầnCUSTOM_DOMAIN_SERVER_IPS. - Apex (
mysalon.com): không CNAME được (DNS spec) → UI hiện A record trỏ từng IP trongCUSTOM_DOMAIN_SERVER_IPS, kèm gợi ý ALIAS/ANAME (Cloudflare flattening). Verify qua nhánh A-record → bắt buộc setCUSTOM_DOMAIN_SERVER_IPS. Nếu chưa set, UI hiện cảnh báo amber + gợi ý dùng subdomain.
Tóm lại: subdomain là đường dễ nhất; apex cần khai IP server + ràng buộc khách vào IP đó (fragile khi đổi IP) và biến trang chủ của khách thành trang booking.
7. Web — proxy host→slug
Trong booking-web/src/proxy.ts, trước admin gate:
host = request.headers.get('host')
if (!isPlatformHost(host)) {
slug = await resolveSlug(host) // in-memory Map cache, TTL ~60s
if (!slug) → rewrite /error-404
if (pathname.startsWith('/admin')) → 404 // admin chỉ ở platform
// skip /_next, /api, asset (có '.'), path đã có /b/
url.pathname = `/b/${slug}` + (pathname === '/' ? '' : pathname)
return NextResponse.rewrite(url, headers: { x-custom-domain: host, x-tenant-slug: slug, x-pathname })
}
isPlatformHost: so với danh sách env (glamvoo.com,www.,app-dev.novagoo.com, …).resolveSlug: cache Map{ host → { slug, expiresAt } }TTL 60s (proxy chạy Node runtime). Domain đổi/gỡ → tự hết hạn sau ≤60s.- Header
x-custom-domain+x-tenant-slugđể server/client biết đang ở custom domain (dùng cho clean-URL + canonical).
8. Web — Clean URLs
SalonLinkContext { isCustomDomain, slug }— provider đặt ở layout của/b/[slug], đọc từ headerx-custom-domain.useSalonHref(sub):- custom domain →
sub(vd/book) - platform →
/b/<slug>${sub}(vd/b/studio-nordic/book)
- custom domain →
- Migrate link site trong subtree
/b/[slug]+ booking flow (router.push, redirect server-side, confirmation page) sang helper. Link cross-tenant ở home/account (platform-only) giữ nguyên/b/<slug>. generateMetadataset canonical/og url → custom domain khi ACTIVE.
~39 chỗ build
/b/${slug}toàn repo, nhưng chỉ phần render dưới custom domain context cần đổi (subtree salon + booking). Trang platform-wide (home, account list) luôn dùng prefix.
9. Cross-cutting & known complexity
Absolute URLs host-aware (BẮT BUỘC audit)
Mọi nơi build URL tuyệt đối phải dùng request host hiện tại:
- Bambora
return_url— khách phải quay vềmysalon.com, không nhảy về glamvoo.com. - Email confirmation link, share-link.
Google OAuth trên custom domain — server-side redirect flow ✅ 2026-06-03
Vấn đề gốc: Customer Google login từng dùng GIS client-side ID-token flow (@react-oauth/google <GoogleLogin>). GIS validate JavaScript origin của trang render nút theo allow-list Google Console — không thể đăng ký mọi custom domain. Bản broker-popup (P8, 2026-06-02) lách được nhưng 2 click + dễ bị popup blocker (nhất là mobile) và phải wire thủ công từng nút (trang /account/login từng quên → lỗi).
Giải pháp hiện tại — OAuth 2.0 Authorization Code flow (server-side, redirect). Code flow validate theo redirect_uri (không phải JS origin) → chỉ đăng ký một redirect URI trên platform, không cấu hình per-tenant. 1 click, không popup, chạy giống hệt nhau ở platform lẫn mọi custom domain.
Custom domain (salon.novagoo.com) Backend (qua /api/* same-origin) Google
────────────────────────────────── ───────────────────────────────── ──────
Nút "Continue with Google"
→ window.location → /api/auth/customer/google/start?return=<origin>&next=<path>
validate return = platform | ACTIVE domain
state = JWT ký HMAC {returnOrigin,next,nonce}
302 ───────────────────────────────────▶ consent
(login)
glamvoo.com/api/auth/customer/google/callback?code&state ◀──┘
verify state → getToken(code) → verify id_token
find-or-create customer
mint ticket 1-lần (customer_auth_tickets)
302 ◀─── <returnOrigin>/account/auth/callback?ticket=…&next=…
POST /api/auth/customer/google/complete {ticket} (same-origin)
validate ticket (chưa dùng, chưa hết hạn) → mark used
◀─── Set-Cookie host-only trên salon.novagoo.com (sameSite=strict, same-site request)
router.replace(next) → logged in
Bốn lớp bảo mật:
- returnOrigin ở
startPHẢI là platform hoặc custom domain ACTIVE (TenantDomainService.isAllowedAuthOrigin) → chặn open-redirect / token exfiltration. - state ký HMAC bằng
JWT_SECRET, TTL 5m, cópurposeclaim → CSRF stateless (start ở custom domain, callback ở platform → không dùng được cookie state). - ticket opaque random (hash trong DB), single-use (
usedAtclaim atomic quaupdateMany where usedAt=null) + TTL 2m → URL rò rỉ không replay được. nextchỉ nhận in-app absolute path (/…, không//) ở cả backend lẫn callback page → chặn protocol-relative redirect.
Files:
- booking-api
auth-customer.controller.ts—GET google/start,GET google/callback,POST google/complete.auth-customer.service.ts—buildGoogleAuthUrl/handleGoogleCallback/completeGoogleLogin+findOrCreateFromGoogle(dùng chung với mobile id_token path). ModelCustomerAuthTicket. - booking-web
lib/customer-google-auth.ts(startGoogleLogin,currentReturnPath),components/auth/GoogleSignInButton.tsx,app/(customer)/account/auth/callback/page.tsx.CustomerLoginModal+/account/logindùng chung nút.CustomerAuthContext.completeGoogleLogin(ticket). - Env (booking-api):
GOOGLE_CLIENT_SECRET,GOOGLE_OAUTH_REDIRECT_URI(=https://glamvoo.com/api/auth/customer/google/callback),PLATFORM_ORIGIN. Web KHÔNG còn dùng Google client-side (xoáNEXT_PUBLIC_GOOGLE_CLIENT_ID/NEXT_PUBLIC_PLATFORM_ORIGIN, gỡ dep@react-oauth/google).
Mobile vẫn dùng POST /auth/customer/google với native id_token (React Native Google sign-in trả idToken trực tiếp) — endpoint này giữ nguyên, KHÔNG phải dead code.
Setup thủ công (operator): Google Console → Authorized redirect URIs: thêm https://glamvoo.com/api/auth/customer/google/callback (+ dev https://app-dev.novagoo.com/api/auth/customer/google/callback). Lấy client secret → đặt GOOGLE_CLIENT_SECRET.
Điểm cần lưu ý:
- Cookie customer host-only → mỗi domain login riêng (không SSO cross-domain — chấp nhận được).
- Booking dở dang không mất:
next= URL booking hiện tại (kèm?sessionId=) → BookingDraft restore cart sau redirect. - Broker popup cũ (
lib/google-oauth-broker.ts,app/oauth/broker/) đã xoá hẳn.
SEO
Custom domain ACTIVE → canonical trỏ custom domain. robots.txt hiện chặn toàn bộ (pre-launch) nên chưa có vấn đề duplicate content; khi mở crawl phải set canonical đúng.
10. Phases
| Phase | Nội dung | Ship độc lập |
|---|---|---|
| P1 | DB: TenantDomain model + migration + Prisma ✅ DONE 2026-06-01 (20260601025527_add_tenant_custom_domain) |
✅ |
| P2 | API: domains CRUD + verify + resolve + tls-allow + DomainVerificationService + tests (35) ✅ DONE 2026-06-01. Auto-poll cron deferred (no scheduler yet). | ✅ |
| P3 | Infra: Caddy thay Nginx ✅ DONE 2026-06-01 (caddy/Caddyfile validated, compose service caddy + caddy_data volume, nginx deprecated, cutover trong docker-deploy.md §2.4). Cần điền IP/DNS lúc deploy. |
✅ |
| P4 | Web proxy: host→slug rewrite + cache + headers + admin/asset guard ✅ DONE 2026-06-01 (lib/custom-domain.ts + proxy.ts, 19 tests) |
✅ |
| P5 | Web clean-URL: SalonLinkContext + useSalonLink + migrate 8 link sites + canonical ✅ DONE 2026-06-01 (lib/salon-link.ts, context/SalonLinkContext.tsx, /b/[slug]/layout.tsx) |
✅ |
| P6 | Cross-cutting absolute URL host-aware ✅ DONE 2026-06-01: pure bookingPaymentUrls/salonOrigin helper, resolve theo ACTIVE domain của tenant. Fixed: booking.service buildBookingUrls, payment lookup adapter, email booking-payload.builder. /account + /admin giữ platform. Share-link đã xong ở P5. |
✅ |
| P7 | Admin UI: tab Domain (thêm/verify/gỡ + hướng dẫn DNS + status badge) ✅ DONE 2026-06-01 (DomainsSection.tsx + useTenantDomains.ts + i18n nb/en, regen api types) |
✅ |
| P8 | OAuth-on-custom-domain ✅ DONE 2026-06-03 — broker-popup (2026-06-02) đã thay bằng server-side Authorization Code redirect flow: auth-customer start/callback/complete + CustomerAuthTicket, web GoogleSignInButton + /account/auth/callback, broker xoá hẳn. 1 click, không popup, chạy mọi domain. 10 e2e — xem §9. |
✅ |
| P9 | E2E + docs maintenance | ✅ |
🚀 DEPLOYED PROD 2026-06-02: Caddy cutover live trên VPS 103.226.251.9,
glamvoo.com+ custom domain (salon.novagoo.com) chạy. Header tenant-mode fix trên custom domain (web commit26becd5).P8 reworked 2026-06-03 (server-side OAuth): trước khi deploy cần (1) thêm redirect URI
https://glamvoo.com/api/auth/customer/google/callback(+ dev) vào Google Console, (2) setGOOGLE_CLIENT_SECRET+GOOGLE_OAUTH_REDIRECT_URI+PLATFORM_ORIGINtrong booking-api.env, (3) apply migration20260603090000_add_customer_auth_ticket, (4) redeploy api + web.
Sau P4 có thể demo nội bộ bằng /etc/hosts + cert thủ công trước khi P3 Caddy lên production.
11. Setup & verify (operator)
Một lần — bật Caddy edge
- Env (
.envở root, compose đọc):ACME_EMAIL,DOCS_BASIC_AUTH_USER,DOCS_BASIC_AUTH_HASH(docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'). Cho apex custom domain: thêmCUSTOM_DOMAIN_SERVER_IPS=<IP VPS>. - DNS platform: glamvoo.com / www / images / docs → A record về IP VPS. Mở port 80 (ACME http-01).
- DNS cho tenant trỏ tới: tạo
connect.glamvoo.comA → IP VPS (đích CNAME của subdomain khách). docker compose -f docker-compose.prod.yml up -d→ Caddy thay nginx, tự xin cert platform domain. Cert lưu volumecaddy_data— không xoá.- (Tuỳ) tắt
certbot.timercủa host — không còn cần.
Chi tiết cutover: ../operations/docker-deploy.md §2.4.
Mỗi tenant — kết nối domain
- Owner: Settings → Domain → nhập hostname (khuyến nghị
book.<domain>subdomain). - Làm theo DNS hiển thị: CNAME (subdomain) / A record (apex) + TXT ownership.
- Bấm Verify → status
ACTIVE.
Smoke test (sau khi ACTIVE)
-
GET /api/public/domains/resolve?host=<domain>→{ data: { slug } }. -
GET /api/internal/tls-allow?domain=<domain>(nội bộ) → 200. - Mở
https://<domain>/→ trang salon, cert hợp lệ, URL sạch (không lộ/b/<slug>). -
https://<domain>/book→ vào stepper booking. - Tạo booking có thanh toán → sau khi trả tiền quay về
<domain>(không nhảy glamvoo.com). -
https://<domain>/admin→ 404 (đúng — admin chỉ ở platform).
Gặp lỗi → ../operations/troubleshooting.md §Custom domain.
12. Scaling & load balancing
Điểm mấu chốt: khách phải trỏ vào một entry-point mình kiểm soát, KHÔNG phải IP của backend server. Nhờ vậy mình scale/đổi hạ tầng phía sau tự do mà khách không phải sửa DNS.
Subdomain — đã scale sẵn (không cần làm gì)
book.mysalon.com CNAME connect.glamvoo.com. Khách trỏ vào hostname mình sở hữu → đổi gì sau connect.glamvoo.com (IP, LB, anycast) khách auto-follow. Đây là lý do khuyến nghị subdomain mặc định.
Apex — điểm yếu cố hữu (A record = IP cứng)
DNS spec bắt apex (mysalon.com) phải là A record → khách hardcode IP. Hai cách tránh khoá cứng vào 1 backend:
- Entry-point ổn định, không phải IP backend —
connect.glamvoo.com+ apex A record phải trỏ vào Floating/Elastic IP hoặc LB IP hoặc anycast IP, KHÔNG phải IP raw của VPS. Khi scale, di chuyển floating IP / đổi target LB → khách không đổi gì. - ALIAS/ANAME — provider hỗ trợ (Cloudflare, Route 53 ALIAS, DNSimple ANAME…): khách đặt
mysalon.com ALIAS connect.glamvoo.comtại apex, hành xử như CNAME → auto-follow, không hardcode IP. UI apex đã gợi ý (apexAliasNote). Chỉ provider không có ALIAS mới buộc A record (→ cách 1).
Multi-node Caddy — BẮT BUỘC shared cert storage
Khi chạy nhiều node Caddy sau LB, mặc định mỗi node lưu cert ở /data riêng → cấp trùng + dễ dính Let's Encrypt rate limit + on-demand ask không share state. Phải cấu hình shared storage (Caddy storage module: Redis / Postgres / S3) để mọi node dùng chung cert store + ACME lock. Single VPS hiện tại không cần.
Lộ trình 3 giai đoạn
| Giai đoạn | Hạ tầng |
|---|---|
| Hiện tại (ít khách) | 1 VPS, Caddy, connect.glamvoo.com → IP VPS. Đủ. |
| HA / scale vừa | Đặt Floating IP trước (rẻ, nên làm sớm): connect.glamvoo.com + apex → floating IP. Scale = di chuyển floating IP, khách không sửa DNS. |
| Scale lớn | LB + N node Caddy + shared cert storage; hoặc Cloudflare for SaaS (offload custom hostname + TLS + anycast LB — apex + scale giải quyết trọn, khách trỏ vào Cloudflare). |
Nên làm sớm (chi phí ~0): dùng Floating IP cho
connect.glamvoo.comngay từ đầu thay vì IP raw của VPS — sau này scale không cần đụng tới DNS của bất kỳ khách nào (kể cả apex).
13. Liên quan
- Embed widget:
embed-widget.md— ưu tiên sau Feature này. - Booking flow:
../flows/booking-flow.md - Deploy:
../operations/docker-deploy.md