Tài liệu / Triển khai

Triển khai

Đưa sản phẩm bạn xây trên PhoStack lên production: Vercel + Neon Postgres, DNS ở Cloudflare, và bước chốt IPN thật — mảnh mà mọi hướng dẫn khác hay bỏ qua. Thay your-domain.com bằng tên miền của bạn là dùng nguyên xi.

Mục Những chỗ dễ sai ở cuối ghi lại những lỗi có thật đã gặp khi dựng hệ thống này, kèm triệu chứng để nhận ra và cách sửa. Đọc trước sẽ tiết kiệm được vài giờ.

Cập nhật lần cuối: 29/07/2026 · Một phần của bộ tài liệu PhoStack


Bức tranh chung

Tối thiểu bạn cần ba thứ:

Repo GitHub private  →  Vercel project  →  Database Postgres (Neon)
                             ↓
                        your-domain.com

Tuỳ chọn: một instance thứ hai chạy cùng source với APP_MODE=hq làm back office riêng tư — dữ liệu kinh doanh thật tách hẳn khỏi ứng dụng khách hàng dùng. Xem Phần E.

Cloudflare ở đây chỉ làm DNS. Không cần Cloudflare Pages, không upload tay.

Phần A — Đẩy code lên GitHub (~5 phút)

Vercel deploy từ repo, nên bước này bắt buộc.

# Tạo repo PRIVATE trên github.com (không init README), rồi:
git remote add origin git@github.com:<user>/<repo>.git
git push -u origin main

Để repo private. Đây là source thương mại — xem khung pháp lý.

⚠️ Gói Vercel Hobby không deploy được repo private thuộc GitHub organization. Nếu repo của bạn nằm trong org, tạo thêm một repo mirror private trong tài khoản cá nhân, rồi cấu hình git remote set-url --add --push origin <cả hai URL> để mỗi lần git push đi cả hai nơi; Vercel deploy từ bản mirror. Thiếu bước này thì code lên GitHub nhưng site đứng yên — và không có gì báo lỗi.

Phần B — Database trên Neon (~10 phút)

Free tier đủ cho giai đoạn đầu.

  1. Đăng ký neon.tech → New project → region ap-southeast-1 (Singapore, gần VN nhất).
  2. Copy connection string dạng pooled (host có -pooler, kèm ?sslmode=require).
  3. Đẩy schema và tạo tài khoản admin từ máy bạn — lần đầu không cần migrate riêng:
DATABASE_URL="<neon-pooled-url>" yarn db:push
DATABASE_URL="<neon-pooled-url>" ADMIN_EMAIL="ban@email.com" \
  ADMIN_PASSWORD="$(openssl rand -base64 18)" yarn db:seed

# Tuỳ chọn — dữ liệu mẫu để mọi màn hình có nội dung:
DATABASE_URL="<neon-pooled-url>" yarn db:seed:demo

Đặt mật khẩu admin ngay từ đầu thay vì seed mặc định rồi đổi sau. Mật khẩu mặc định tồn tại trên một môi trường công khai dù chỉ vài phút cũng là vài phút quá nhiều.

Phần C — Deploy lên Vercel và gắn tên miền (~15 phút)

  1. vercel.com → Add New → Project → import repo. Framework tự nhận Next.js, build mặc định là đúng — postinstall đã lo prisma generate.
  2. Environment Variables — tối thiểu:
BiếnGiá trị
DATABASE_URLNeon pooled URL
AUTH_SECRETSinh mới bằng openssl rand -base64 32 — không dùng lại secret dev
APP_URLhttps://your-domain.com — QUAN TRỌNG: returnUrl/ipnUrl của mọi cổng đều dựng từ biến này
VNPAY_TMN_CODE / VNPAY_HASH_SECRET / VNPAY_PAY_URLLấy từ portal merchant

Cổng nào không điền env thì tự chạy mock provider — app vẫn lên sóng được trước khi bạn có tài khoản sandbox.

  1. Deploy → bạn nhận một URL tạm *.vercel.app. Kiểm tra app chạy.
  2. Settings → Domains → thêm your-domain.com → Vercel hiện bản ghi DNS cần tạo.
  3. Cloudflare → DNS → tạo đúng bản ghi đó, Proxy status: DNS only (mây XÁM).
  4. Nếu lúc đầu bạn điền APP_URL là URL tạm, sửa lại thành tên miền thật → Redeploy. Env chỉ nạp lúc build/boot.

Phần D — Chốt IPN thật (~10 phút)

Đây là bước phân biệt "demo chạy được" với "thu tiền được". Giờ server đã có URL công khai:

  1. Portal VNPay sandbox (sandbox.vnpayment.vn/merchantv2) → cấu hình IPN URL: https://your-domain.com/api/payments/ipn/vnpay
  2. Tạo một đơn thật trên https://your-domain.com/checkout bằng thẻ test NCB.
  3. Đơn phải tự chuyển sang Đã thanh toán mà bạn không bấm gì thêm. Đó mới là IPN server-to-server thật. Xem payload thô tại /dev.

MoMo và ZaloPay cùng pattern: /api/payments/ipn/momo/api/payments/ipn/zalopay. Khác một điểm quan trọng — hai cổng này nhận URL theo từng request (dựng từ APP_URL), nên đổi APP_URL là đủ, không phải khai báo ở portal như VNPay.

Phần E — Instance back office riêng tư (tuỳ chọn, ~20 phút)

Cùng một source, chỉ khác biến môi trường. APP_MODE=hq khoá toàn bộ ứng dụng: mọi đường dẫn chưa đăng nhập bị đẩy về /login, trang chủ vào thẳng /admin, tự đăng ký bị tắt. Chỉ ba nhóm còn mở vì máy gọi máy: /api/auth/*, /api/payments/ipn/*/api/leads.

Dùng khi bạn muốn tách dữ liệu vận hành nội bộ khỏi ứng dụng mà khách hàng đăng nhập vào.

  1. Database riêng — Neon → New database. Dữ liệu nội bộ không nằm chung DB với ứng dụng công khai.
  2. Vercel → Add New → Project → chọn lại chính repo đó → đặt tên khác.
  3. Environment Variables:
BiếnGiá trị
APP_MODEhq
DATABASE_URLPooled URL của database mới
AUTH_SECRETSinh mới, khác instance công khai
APP_URLhttps://hq.your-domain.com
LEAD_ALLOWED_ORIGINSBỏ trống nếu site gửi lead qua server action — khi đó không cần CORS
  1. Deploy → Settings → Domainshq.your-domain.com; Cloudflare: CNAME · Name hq · Target cname.vercel-dns.com · DNS only.
  2. Chạy db:pushdb:seed với DATABASE_URL của instance này, đặt mật khẩu ngay từ đầu.

Giữ URL database nội bộ trong một file .env.hq riêng, không để trong .env — nếu không mọi thứ bạn chạy ở localhost:3000 sẽ ghi thẳng vào database thật.

  1. Kiểm tra: mở https://hq.your-domain.com khi chưa đăng nhập → phải nhảy về /login.

Những chỗ dễ sai

Mỗi mục dưới đây là một lỗi có thật, ghi theo triệu chứng để lần sau nhận ra ngay.

Trang báo lỗi 525 sau khi gắn domain. Bản ghi DNS đang để Proxy (mây cam). Cloudflare không bắt tay được TLS với Vercel nên trả 525. Sửa: bấm vào chữ "Proxied" trong bảng DNS Records để lật sang DNS only (mây xám). Quy tắc: mọi bản ghi trỏ về Vercel đều phải DNS only.

Cloudflare cảnh báo "Proxying is required" hoặc "Visitors cannot reach your site". Cảnh báo sai — nó xuất hiện chính vì bản ghi để DNS only nên Cloudflare không thấy traffic. Kiểm chứng bằng dig +short @1.1.1.1 <domain> (phải ra IP Vercel) và curl -I https://<domain> (phải 200) rồi bỏ qua.

Apex hoặc subdomain phục vụ nhầm app. Triệu chứng: tên miền mở ra giao diện của project khác, các đường dẫn quen thuộc trả 404. Nguyên nhân: domain được add vào nhầm project Vercel — một domain chỉ thuộc một project. Sửa: vào project đang giữ nó → Settings → Domains → Remove, rồi add lại ở đúng project. DNS không cần đụng tới.

Nhiều domain cùng trỏ vào một target xxxxx.vercel-dns-0xx.com — không sao. Vercel định tuyến theo tên miền trong request (Host), không theo địa chỉ đích, nên dùng chung target vẫn đúng project.

Sửa biến môi trường mà không có tác dụng. Env chỉ nạp lúc build/boot. Sau khi sửa phải Deployments → ⋯ → Redeploydòng trên cùng (xem mục dưới về việc bấm nhầm dòng).

Push thành công nhưng site không đổi gì. Không có lỗi ở đâu cả — git push báo ok, GitHub hiện commit mới, Vercel im lặng. Nguyên nhân: Vercel deploy từ repo mirror, còn git push chỉ đi tới repo gốc. Kiểm bằng git remote get-url --push --all origin — phải thấy hai URL. Chỉ thấy một là thiếu, thêm bằng git remote set-url --add --push origin <url-mirror>. Lưu ý: lần --add đầu tiên thay thế URL mặc định, nên phải thêm cả hai URL một cách tường minh, và kiểm lại xem có bị nhân đôi không.

Redeploy xong thì production lùi về bản cũ. Nút Redeploy nằm trên từng dòng của danh sách Deployments và luôn build lại đúng commit của dòng đó — không phải commit mới nhất. Bấm nhầm một dòng cũ là bạn vừa đưa bản cũ lên production, đè lên bản mới, mà Vercel không hỏi gì. Sửa: tìm dòng có đúng commit mong muốn → Promote to Production.

Khách thanh toán xong bị đá về trang 404. APP_URL vẫn còn là URL tạm *.vercel.app từ lúc deploy lần đầu, mà URL đó đã ngừng phục vụ khi bạn gắn tên miền thật. Mọi returnUrl đều dựng từ biến này. Kiểm nhanh mà không cần mở dashboard: tạo một đơn, lúc trình duyệt nhảy sang cổng thanh toán thì nhìn tham số vnp_ReturnUrl trên thanh địa chỉ — nó chính là ${APP_URL}/checkout/return.

Thêm bản ghi DNS vào nhầm zone. Cloudflare quản nhiều tên miền trong một tài khoản, và bộ chọn domain nằm khuất ở góc trên trái. Thêm nhầm zone thì bản ghi vẫn "tạo thành công", chỉ là ở sai tên miền — và nếu đó là SPF thì bạn vừa tạo bản ghi SPF thứ hai cho tên miền không liên quan, phá hỏng SPF của nó. Trước khi bấm Save, đọc lại breadcrumb trên đầu trang xem có đúng tên miền không.

Hai bản ghi DMARC cũng hỏng như hai bản ghi SPF. Chuẩn quy định mỗi tên miền chỉ một bản ghi _dmarc; có từ hai trở lên thì máy chủ nhận coi như không có DMARC. Kiểm: dig +short _dmarc.<domain> TXT | grep -c v=DMARC1 phải ra 1. Với SPF cũng vậy: dig +short <domain> TXT | grep -c v=spf1 phải ra 1.

Tên miền .dev bắt buộc HTTPS. Toàn bộ TLD .dev nằm trong danh sách HSTS preload của trình duyệt — không bao giờ có http://. Hệ quả: trong lúc Vercel chưa cấp xong chứng chỉ thì tên miền không truy cập được, và bạn không thể trỏ một *.dev vào dev server local chạy http để thử. Lỗi trông y hệt hỏng DNS.

Vercel chặn deploy với trạng thái "Blocked", không phải "Failed". Banner ghi "the commit email … could not be matched to a GitHub account". Build chưa từng chạy — Vercel từ chối deploy commit mà email tác giả không thuộc tài khoản GitHub nào. Hay gặp nhất với commit do CI hoặc automation tạo, vì người ta đặt một địa chỉ trông hợp lý nhưng không ai sở hữu. Bấm Redeploy không gỡ được, vì tác giả commit không đổi. Sửa: đặt email của bước commit thành 41898282+github-actions[bot]@users.noreply.github.com, hoặc thêm địa chỉ đang dùng vào tài khoản GitHub của bạn.

Vercel không cho lưu biến rỗng. Với biến có giá trị mặc định trong src/lib/env.ts (ví dụ LEAD_ALLOWED_ORIGINS), đừng tạo dòng đó thay vì cố nhập chuỗi rỗng. Tuyệt đối không điền "none" — nó sẽ được hiểu là một origin tên "none".

Một trang admin báo lỗi 500 trên môi trường đã chạy lâu. Thêm model mới vào schema.prisma không tự tạo bảng trên database đang chạy — deploy không migrate hộ. Sau mỗi lần đổi schema phải chạy DATABASE_URL="<url>" yarn db:push cho từng database.

Đăng nhập lỗi JWTSessionError ở local. Cookie cũ của một app khác chạy cùng localhost. Starter đặt tên cookie riêng (phostack.session-token) để tránh, nhưng nếu vẫn gặp thì xoá cookie của localhost rồi đăng nhập lại.

VNPay báo "Không tìm thấy website". VNPAY_TMN_CODE đang điền nhầm tên trường thay vì giá trị (mã 8 ký tự lấy trong portal merchant).

VNPay báo "Sai chữ ký". VNPAY_HASH_SECRET gõ sai hoặc thiếu ký tự khi chép sang Vercel. Kiểm bằng độ dài chuỗi, đừng nhìn bằng mắt.

Email gửi tới địa chỉ in trên website bị trả về. Tên miền chưa có bản ghi MX. Kiểm tra bằng dig +short <domain> MX — trống là chưa có. Sửa nhanh: Cloudflare → Email → Email Routing → Enable → tạo địa chỉ chuyển tiếp về hộp thư thật; Cloudflare tự thêm MX và SPF.

Thư chuyển tiếp rơi vào spam. Đây là bản chất của email forwarding, không phải cấu hình sai: máy chủ kết nối tới hộp thư đích là Cloudflare chứ không phải người gửi gốc, nên SPF của họ không cho phép IP đó. Xử lý ở phía nhận bằng filter "Never send it to Spam", không phải bằng DNS.

Thêm SPF thứ hai làm hỏng SPF. Một tên miền chỉ được có đúng một bản ghi SPF. Email Routing thêm một cái, nhà cung cấp gửi mail (Resend…) yêu cầu thêm cái nữa — làm theo cả hai là permerror, SPF hỏng sạch và thư đi thẳng vào spam mà không có lỗi nào để lần. Phải gộp thành một dòng.

Next.js 16 cảnh báo middleware đã deprecated. Convention đổi tên thành proxy: đặt file src/proxy.ts và export proxy thay vì middleware. Hành vi giữ nguyên.

Checklist production

  • AUTH_SECRET sinh riêng cho production, không trùng dev
  • Mật khẩu admin đặt ngay lúc seed, không dùng mặc định
  • APP_URL đúng tên miền chính thức
  • Cổng thanh toán chuyển từ sandbox sang production (VNPay: đổi cả VNPAY_PAY_URL)
  • Neon: bật backup/branch cho database production
  • Không seed dữ liệu demo vào DB production
  • Mọi bản ghi DNS trỏ Vercel đều ở chế độ DNS only
  • Tên miền có bản ghi MX nếu website có in địa chỉ email
  • Đã chạy db:push cho tất cả database sau lần đổi schema gần nhất
  • Đơn thử tự chuyển PAID qua IPN, không cần bấm gì

Ngoài phạm vi tài liệu này

Self-host trên VPS/Docker: repo không có Dockerfile — Vercel là đường đi được hỗ trợ. Cần self-host thì hỏi qua kênh support.