architecture/custom-domain.md

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 scale docker compose --scale web=N, dùng Caddy dynamic upstreams (A-record) để round-robin — tương đương trick resolver 127.0.0.11 củ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-domains chứ không phải /tenants/:id/domainstenantId lấ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:

  1. Tạo TenantDomain status PENDING, sinh verifyToken (random, unguessable).
  2. Admin UI hiển thị hướng dẫn DNS:
    • CNAME mysalon.comconnect.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).
  3. Tenant bấm "Verify ngay"DomainVerificationService:
    • dns/promises.resolveTxt('_glamvoo-verify.' + host) chứa verifyToken (ownership — check trước).
    • dns/promises.resolveCname() trỏ về cnameTarget, hoặc (apex) resolve4() khớp 1 trong CUSTOM_DOMAIN_SERVER_IPS (routing).
    • Cả 2 pass → status ACTIVE, set verifiedAt. Fail → FAILED + lastError.
  4. Domain ACTIVE → lần đầu truy cập HTTPS, Caddy ask trả 200 → tự cấp Let's Encrypt cert.

DEFER — auto-poll cron: project hiện chưa có scheduler nào (@nestjs/schedule chư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ại PENDING định kỳ là tiện ích bổ sung, defer đến khi cần (sẽ thêm @nestjs/schedule lú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êm request_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 → Caddy basic_auth vớ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ỏ cron certbot renew.
  • Thêm service caddy (image caddy:2), ăn 80:80 + 443:443, mount Caddyfile (read-only) + 2 named volume caddy_data (cert store — quan trọng, đừng để mất) + caddy_config.
  • api, web, imgproxy, docs giữ 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 ghi CNAME → 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ần CUSTOM_DOMAIN_SERVER_IPS.
  • Apex (mysalon.com): không CNAME được (DNS spec) → UI hiện A record trỏ từng IP trong CUSTOM_DOMAIN_SERVER_IPS, kèm gợi ý ALIAS/ANAME (Cloudflare flattening). Verify qua nhánh A-record → bắt buộc set CUSTOM_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ừ header x-custom-domain.
  • useSalonHref(sub):
    • custom domain → sub (vd /book)
    • platform → /b/<slug>${sub} (vd /b/studio-nordic/book)
  • 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>.
  • generateMetadata set 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:

  1. returnOriginstart PHẢI là platform hoặc custom domain ACTIVE (TenantDomainService.isAllowedAuthOrigin) → chặn open-redirect / token exfiltration.
  2. state ký HMAC bằng JWT_SECRET, TTL 5m, có purpose claim → CSRF stateless (start ở custom domain, callback ở platform → không dùng được cookie state).
  3. ticket opaque random (hash trong DB), single-use (usedAt claim atomic qua updateMany where usedAt=null) + TTL 2m → URL rò rỉ không replay được.
  4. next chỉ 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.tsGET google/start, GET google/callback, POST google/complete. auth-customer.service.tsbuildGoogleAuthUrl / handleGoogleCallback / completeGoogleLogin + findOrCreateFromGoogle (dùng chung với mobile id_token path). Model CustomerAuthTicket.
  • booking-web lib/customer-google-auth.ts (startGoogleLogin, currentReturnPath), components/auth/GoogleSignInButton.tsx, app/(customer)/account/auth/callback/page.tsx. CustomerLoginModal + /account/login dù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 commit 26becd5).

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) set GOOGLE_CLIENT_SECRET + GOOGLE_OAUTH_REDIRECT_URI + PLATFORM_ORIGIN trong booking-api .env, (3) apply migration 20260603090000_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

  1. 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êm CUSTOM_DOMAIN_SERVER_IPS=<IP VPS>.
  2. DNS platform: glamvoo.com / www / images / docs → A record về IP VPS. Mở port 80 (ACME http-01).
  3. DNS cho tenant trỏ tới: tạo connect.glamvoo.com A → IP VPS (đích CNAME của subdomain khách).
  4. docker compose -f docker-compose.prod.yml up -d → Caddy thay nginx, tự xin cert platform domain. Cert lưu volume caddy_datakhông xoá.
  5. (Tuỳ) tắt certbot.timer củ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

  1. Owner: Settings → Domain → nhập hostname (khuyến nghị book.<domain> subdomain).
  2. Làm theo DNS hiển thị: CNAME (subdomain) / A record (apex) + TXT ownership.
  3. 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:

  1. Entry-point ổn định, không phải IP backendconnect.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ì.
  2. ALIAS/ANAME — provider hỗ trợ (Cloudflare, Route 53 ALIAS, DNSimple ANAME…): khách đặt mysalon.com ALIAS connect.glamvoo.com tạ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.com ngay 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