# Bài 22 — Đưa dự án lên internet
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
Đưa dự án từ localhost lên internet để ai cũng truy cập được:
- MongoDB Atlas — database trên cloud (miễn phí 512 MB)
- Render — chạy backend Express (miễn phí)
- Vercel — chạy frontend Next.js (miễn phí)
- Cloudinary — lưu ảnh sản phẩm (miễn phí 25 GB)
Tổng chi phí: 0 đồng.
📚 1. Kiến trúc sau khi deploy
Người dùng
│
▼
┌────────────────────────────────────────┐
│ Vercel │
│ https://lith-perfume.vercel.app │ ← Frontend Next.js
└────────────────────────────────────────┘
│ HTTPS
▼
┌────────────────────────────────────────┐
│ Render │
│ https://lith-api.onrender.com │ ← Backend Express
└────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ MongoDB Atlas │ │ Cloudinary │
│ cluster0.xxx │ │ ảnh sản phẩm │
└──────────────────┘ └──────────────────┘
⚠️ 2. Danh sách việc phải làm TRƯỚC khi deploy
Đây là các thay đổi bắt buộc — bỏ qua là sẽ gặp lỗi:
- [ ] Xóa mọi giá trị hardcode
localhostkhỏi code - [ ] Dùng
process.envcho connection string, port, JWT secret - [ ]
.envvà.env.localphải nằm trong.gitignore - [ ] Giới hạn CORS về đúng domain frontend
- [ ] Chuyển lưu ảnh từ ổ đĩa sang Cloudinary
- [ ] Đảm bảo
npm run buildchạy được không lỗi - [ ] Đẩy code lên GitHub
💻 3. MongoDB Atlas
Bước 3.1 — Tạo cluster
- Đăng ký tại https://www.mongodb.com/cloud/atlas/register
- Build a Database → chọn gói M0 FREE
- Provider: AWS, Region: chọn gần Việt Nam nhất (Singapore
ap-southeast-1) - Cluster Name:
lith-cluster→ Create
Bước 3.2 — Tạo user database
Security → Database Access → Add New Database User
- Authentication Method: Password
- Username:
lith_admin - Password: bấm Autogenerate Secure Password → copy lưu lại ngay
- Database User Privileges: Read and write to any database
- Add User
⚠️ Nếu mật khẩu chứa ký tự đặc biệt (
@,:,/,#), phải mã hóa URL khi đưa vào connection string. Ví dụ@→%40. Cách an toàn nhất: dùng mật khẩu chỉ gồm chữ và số.
Bước 3.3 — Cho phép truy cập từ mọi IP
Security → Network Access → Add IP Address → ALLOW ACCESS FROM ANYWHERE
(0.0.0.0/0) → Confirm
⚠️ Đây là cấu hình kém an toàn, chỉ chấp nhận được vì Render dùng IP động. Với gói trả phí, hãy dùng VPC Peering hoặc Private Endpoint. Bảo mật thật nằm ở username/password mạnh và không để lộ connection string.
Bước 3.4 — Lấy connection string
Database → Connect → Drivers → chọn Node.js:
mongodb+srv://lith_admin:<password>@lith-cluster.abc12.mongodb.net/?retryWrites=true&w=majority
Thay <password> bằng mật khẩu thật.
Bước 3.5 — Chuyển dữ liệu lên Atlas
Cách 1 — MongoDB Compass (dễ nhất):
- Compass → New Connection → dán connection string → Connect
- Tạo database
lith_perfumevới 3 collection - Import
categories.jsonvàproducts.jsonnhư bài 03
Cách 2 — Dòng lệnh:
# Xuất từ máy local
mongodump --db lith_perfume --out ./backup
# Nạp lên Atlas
mongorestore --uri "mongodb+srv://lith_admin:PASSWORD@lith-cluster.abc12.mongodb.net" \
--db lith_perfume ./backup/lith_perfume
Bước 3.6 — Tạo lại các index
// Trong mongosh của Atlas
use lith_perfume
db.products.createIndex({ categoryId: 1 })
db.products.createIndex({ price: 1 })
db.users.createIndex({ email: 1 }, { unique: true })
💻 4. Chuyển ảnh sang Cloudinary
Vì sao bắt buộc?
Render (và Heroku, Vercel) dùng ephemeral filesystem: mọi file bạn ghi ra đĩa sẽ biến mất khi:
- Deploy phiên bản mới
- Ứng dụng tự khởi động lại
- Gói miễn phí "ngủ" sau 15 phút không hoạt động rồi thức dậy
→ Ảnh sản phẩm upload lên sẽ mất sau vài giờ.
Bước 4.1 — Đăng ký và cài đặt
Đăng ký tại https://cloudinary.com/users/register_free.
Dashboard sẽ hiện: Cloud name, API Key, API Secret.
cd backend
npm install cloudinary multer-storage-cloudinary
Bước 4.2 — Cấu hình
Tạo backend/config/cloudinary.js:
const cloudinary = require("cloudinary").v2;
const { CloudinaryStorage } = require("multer-storage-cloudinary");
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
});
const storage = new CloudinaryStorage({
cloudinary,
params: {
folder: "lith-perfume",
allowed_formats: ["jpg", "jpeg", "png", "gif", "webp"],
transformation: [{ width: 800, height: 800, crop: "limit" }],
},
});
module.exports = { cloudinary, storage };
Bước 4.3 — Dùng trong route
Sửa backend/routes/products.js:
const { storage, cloudinary } = require("../config/cloudinary");
const upload = multer({
storage, // ← thay cho diskStorage
limits: { fileSize: 5 * 1024 * 1024 },
});
// Khi tạo sản phẩm:
const newProduct = {
// ...
image: req.file.path, // ← URL đầy đủ của Cloudinary
imagePublicId: req.file.filename, // ← id để xóa sau này
};
Bước 4.4 — Xóa ảnh trên Cloudinary
// Trong route DELETE
if (product.imagePublicId) {
await cloudinary.uploader.destroy(product.imagePublicId);
}
Bước 4.5 — Sửa frontend
req.file.path giờ là URL đầy đủ (https://res.cloudinary.com/...),
không phải tên file. Nên không cần ghép IMA_URL nữa:
// Hỗ trợ CẢ HAI: ảnh cũ (tên file) và ảnh mới (URL đầy đủ)
function imageUrl(image) {
if (!image) return "/img/no-image.png";
return image.startsWith("http") ? image : `${IMG}${image}`;
}
// Dùng:
<img src={imageUrl(product.image)} alt={product.name} />
Thêm hàm này vào frontend/lib/fetcher.js và dùng ở mọi nơi hiển thị ảnh.
💡 Nếu muốn giữ 12 ảnh mẫu, upload thủ công lên Cloudinary rồi cập nhật trường
imagecủa từng sản phẩm trong Atlas thành URL đầy đủ.
💻 5. Chuẩn bị code backend
backend/.gitignore
node_modules
.env
public/img/*
!public/img/.gitkeep
npm-debug.log
Đảm bảo dùng process.env ở mọi nơi
backend/model/db.js:
const url = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017";
const dbName = process.env.DB_NAME || "lith_perfume";
backend/bin/www:
require('dotenv').config();
var port = normalizePort(process.env.PORT || '5000');
Giới hạn CORS
backend/app.js:
var cors = require('cors');
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "http://localhost:3000")
.split(",")
.map((s) => s.trim());
app.use(cors({
origin: function (origin, callback) {
// Cho phép request không có origin (Postman, curl, server-to-server)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
callback(new Error("Không được phép bởi CORS"));
},
credentials: true,
}));
Thêm route health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
Dùng để kiểm tra server còn sống và để "đánh thức" server ngủ.
Đẩy lên GitHub
cd D:/lith-perfume
git init
git add .
git commit -m "Hoàn thành dự án Lith Perfume"
git branch -M main
git remote add origin https://github.com/<username>/lith-perfume.git
git push -u origin main
⚠️ Kiểm tra kỹ trước khi push: chạy
git statusvà đảm bảo.env,.env.local,node_moduleskhông có trong danh sách.Nếu lỡ push
.envchứa secret lên GitHub, secret đó coi như đã bị lộ vĩnh viễn (kể cả khi bạn xóa commit sau đó). Phải đổi toàn bộ mật khẩu và API key.
💻 6. Deploy backend lên Render
- Đăng ký tại https://render.com (đăng nhập bằng GitHub)
- New + → Web Service
- Connect repository
lith-perfume - Cấu hình:
| Trường | Giá trị |
|---|---|
| Name | lith-api |
| Region | Singapore |
| Branch | main |
| Root Directory | backend ← quan trọng |
| Runtime | Node |
| Build Command | npm install |
| Start Command | npm start |
| Instance Type | Free |
- Mục Environment Variables → thêm:
MONGODB_URI = mongodb+srv://lith_admin:PASSWORD@lith-cluster.abc12.mongodb.net
DB_NAME = lith_perfume
JWT_SECRET = <chuỗi ngẫu nhiên dài>
JWT_EXPIRES_IN = 7d
ALLOWED_ORIGINS = https://lith-perfume.vercel.app
CLOUDINARY_CLOUD_NAME = <cloud name>
CLOUDINARY_API_KEY = <api key>
CLOUDINARY_API_SECRET = <api secret>
💡 Không đặt biến
PORT— Render tự cấp. Code đã cóprocess.env.PORTnên sẽ dùng đúng giá trị Render đưa vào.
ALLOWED_ORIGINSbạn chưa biết ngay lúc này. Cứ điền tạm, sau khi deploy frontend xong sẽ quay lại sửa.
- Create Web Service → chờ 3–5 phút
Kiểm tra
Mở https://lith-api.onrender.com/health → phải thấy {"status":"ok",...}
Rồi https://lith-api.onrender.com/products → thấy danh sách sản phẩm.
⚠️ Gói Free của Render "ngủ đông"
Sau 15 phút không có request, service tự tắt. Request tiếp theo phải chờ 30–60 giây để khởi động lại.
Giải pháp:
- Dùng dịch vụ ping miễn phí như UptimeRobot
gọi
/healthmỗi 10 phút - Hoặc chấp nhận (với dự án học tập/demo thì không sao)
- Hoặc nâng lên gói trả phí ($7/tháng)
💻 7. Deploy frontend lên Vercel
- Đăng ký tại https://vercel.com (đăng nhập bằng GitHub)
- Add New → Project → Import repo
lith-perfume - Cấu hình:
| Trường | Giá trị |
|---|---|
| Framework Preset | Next.js (tự nhận diện) |
| Root Directory | frontend ← bấm Edit để đổi |
| Build Command | npm run build (mặc định) |
| Output Directory | .next (mặc định) |
- Environment Variables:
NEXT_PUBLIC_API_URL = https://lith-api.onrender.com
NEXT_PUBLIC_IMA_URL = https://lith-api.onrender.com/img/
- Deploy → chờ 2–3 phút
Quay lại cập nhật CORS
Sau khi Vercel cấp domain (VD https://lith-perfume.vercel.app):
- Về Render → Environment → sửa
ALLOWED_ORIGINSthành domain đó - Save Changes → Render tự deploy lại
💡 Vercel tạo domain mới cho mỗi lần deploy preview (VD
lith-perfume-git-feature-abc.vercel.app). Nếu muốn preview cũng gọi được API, thêm nhiều origin ngăn bởi dấu phẩy, hoặc dùng regex trong hàm kiểm tra CORS.
✅ 8. Kiểm thử sau khi deploy
| # | Kiểm tra | Cách làm |
|---|---|---|
| 1 | Backend sống | https://lith-api.onrender.com/health |
| 2 | API trả dữ liệu | https://lith-api.onrender.com/products |
| 3 | Frontend load | https://lith-perfume.vercel.app |
| 4 | Không lỗi CORS | F12 → Console phải sạch |
| 5 | Ảnh hiển thị | Kiểm tra thẻ <img> load được |
| 6 | Thêm vào giỏ | Badge tăng |
| 7 | Giỏ hàng bền | F5 vẫn còn |
| 8 | Đặt hàng | Kiểm tra đơn xuất hiện trong Atlas |
| 9 | Đăng nhập | Vào được /admin |
| 10 | Upload ảnh | Ảnh mới lên Cloudinary, hiển thị được |
| 11 | Trên điện thoại | Mở bằng 4G, không dùng Wi-Fi nhà |
🆘 9. Lỗi thường gặp khi deploy
<details> <summary>Build thất bại trên Vercel: "Module not found"</summary>Nguyên nhân phổ biến nhất: phân biệt hoa thường.
Windows không phân biệt Product.js và product.js, nhưng Linux (Vercel) có.
import Product from "./component/Product"; // file thật là product.js → LỖI trên Linux
Kiểm tra mọi câu import khớp chính xác tên file. Cách chắc chắn: chạy
npm run build ở máy local trước khi push.
- Kiểm tra
ALLOWED_ORIGINStrên Render đúng domain Vercel - Không có dấu
/ở cuối:https://a.vercel.appchứ không phảihttps://a.vercel.app/ - Phải là
https://không phảihttp:// - Sau khi sửa biến môi trường, Render cần deploy lại
- Sai mật khẩu trong connection string
- Mật khẩu chứa ký tự đặc biệt chưa mã hóa URL (
@→%40,#→%23) - User chưa được cấp quyền "Read and write to any database"
Network Access chưa cho phép 0.0.0.0/0. Vào Atlas → Security → Network Access
kiểm tra lại.
Render Free đang "thức dậy". Bình thường. Dùng UptimeRobot ping /health
mỗi 10 phút để giữ nó tỉnh.
Bạn chưa chuyển sang Cloudinary. Xem lại mục 4 — đây là vấn đề bắt buộc phải giải quyết.
</details> <details> <summary>process.env.NEXT_PUBLIC_API_URL là undefined trên production</summary>- Biến chưa được thêm trên Vercel → Settings → Environment Variables
- Đã thêm nhưng chưa deploy lại — biến
NEXT_PUBLIC_*được nhúng vào bundle lúc build, nên phải build lại. Vào Deployments → Redeploy
📚 10. Các lựa chọn hosting khác
| Dịch vụ | Loại | Miễn phí | Ghi chú |
|---|---|---|---|
| Vercel | Frontend | ✅ | Tốt nhất cho Next.js |
| Netlify | Frontend | ✅ | Tương đương Vercel |
| Render | Backend | ✅ (ngủ 15 phút) | Dễ dùng nhất |
| Railway | Backend | $5 credit/tháng | Không ngủ, nhanh |
| Fly.io | Backend | ✅ hạn chế | Cấu hình phức tạp hơn |
| MongoDB Atlas | Database | ✅ 512 MB | Chuẩn cho MongoDB |
| Cloudinary | Ảnh | ✅ 25 GB | Có tối ưu ảnh tự động |
| Supabase Storage | Ảnh | ✅ 1 GB | Thay thế Cloudinary |
📚 11. Sau khi deploy — các bước tiếp theo
Domain riêng
Mua domain (~200k/năm ở các nhà cung cấp Việt Nam) rồi:
- Vercel: Settings → Domains → Add
- Trỏ bản ghi CNAME theo hướng dẫn Vercel
Theo dõi lỗi thực tế
npm install @sentry/nextjs
Sentry ghi lại mọi lỗi JavaScript của người dùng thật — bạn biết ngay khi có sự cố thay vì chờ người dùng báo.
Phân tích truy cập
Vercel Analytics (miễn phí) hoặc Google Analytics.
Tối ưu SEO
- Dùng
next/imagethay<img>để tự động tối ưu - Thêm
metadatađộng cho từng trang sản phẩm:export async function generateMetadata({ params }) { const product = await fetch(`${API}/products/id/${params.id}`).then(r => r.json()); return { title: product.name, description: product.description?.slice(0, 160) }; } - Tạo
sitemap.xmlvàrobots.txt
Sao lưu database
Atlas gói M0 không có backup tự động. Đặt lịch chạy:
mongodump --uri "mongodb+srv://..." --out ./backup-$(date +%F)
📝 Bài tập
-
Deploy toàn bộ dự án theo hướng dẫn trên, chia sẻ link cho bạn bè test.
-
Thiết lập UptimeRobot ping
/healthmỗi 10 phút, quan sát thời gian phản hồi trước và sau khi bật. -
Đo tốc độ trang: dùng https://pagespeed.web.dev. Ghi lại điểm số, thử tối ưu ảnh bằng
next/imagerồi đo lại. -
CI/CD: tạo
.github/workflows/ci.ymlchạynpm run buildcho cả frontend và backend mỗi khi push. Push code lỗi để xem workflow báo đỏ. -
Script sao lưu tự động: viết script bash/PowerShell chạy
mongodumpvà nén thành file zip theo ngày. -
Thử phá hoại có chủ đích: đổi
ALLOWED_ORIGINSthành một domain sai, deploy lại, mở web và đọc lỗi CORS trong console. Sau đó sửa lại. Trải nghiệm này giúp bạn nhận ra lỗi CORS ngay lập tức trong tương lai.
All Rights Reserved