0

# 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 localhost khỏi code
  • [ ] Dùng process.env cho connection string, port, JWT secret
  • [ ] .env và .env.local phả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 build chạy được không lỗi
  • [ ] Đẩy code lên GitHub

💻 3. MongoDB Atlas

Bước 3.1 — Tạo cluster

  1. Đăng ký tại https://www.mongodb.com/cloud/atlas/register
  2. Build a Database → chọn gói M0 FREE
  3. Provider: AWS, Region: chọn gần Việt Nam nhất (Singapore ap-southeast-1)
  4. 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):

  1. Compass → New Connection → dán connection string → Connect
  2. Tạo database lith_perfume với 3 collection
  3. Import categories.json và products.json như 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 image củ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 status và đảm bảo .env, .env.local, node_modules không có trong danh sách.

Nếu lỡ push .env chứ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

  1. Đăng ký tại https://render.com (đăng nhập bằng GitHub)
  2. New + → Web Service
  3. Connect repository lith-perfume
  4. 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
  1. 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.PORT nên sẽ dùng đúng giá trị Render đưa vào.

ALLOWED_ORIGINS bạ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.

  1. 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 /health mỗ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

  1. Đăng ký tại https://vercel.com (đăng nhập bằng GitHub)
  2. Add New → Project → Import repo lith-perfume
  3. 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)
  1. Environment Variables:
NEXT_PUBLIC_API_URL  = https://lith-api.onrender.com
NEXT_PUBLIC_IMA_URL  = https://lith-api.onrender.com/img/
  1. 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):

  1. Về Render → Environment → sửa ALLOWED_ORIGINS thành domain đó
  2. 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.

</details> <details> <summary>CORS bị chặn sau khi deploy</summary>
  1. Kiểm tra ALLOWED_ORIGINS trên Render đúng domain Vercel
  2. Không có dấu / ở cuối: https://a.vercel.app chứ không phải https://a.vercel.app/
  3. Phải là https:// không phải http://
  4. Sau khi sửa biến môi trường, Render cần deploy lại
</details> <details> <summary>MongoServerError: bad auth : Authentication failed</summary>
  1. Sai mật khẩu trong connection string
  2. Mật khẩu chứa ký tự đặc biệt chưa mã hóa URL (@ → %40, # → %23)
  3. User chưa được cấp quyền "Read and write to any database"
</details> <details> <summary>Kết nối Atlas bị timeout</summary>

Network Access chưa cho phép 0.0.0.0/0. Vào Atlas → Security → Network Access kiểm tra lại.

</details> <details> <summary>Request đầu tiên mất 60 giây</summary>

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.

</details> <details> <summary>Ảnh upload xong, vài giờ sau mất</summary>

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>
  1. Biến chưa được thêm trên Vercel → Settings → Environment Variables
  2. Đã 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
</details>

📚 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/image thay <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.xml và 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

  1. Deploy toàn bộ dự án theo hướng dẫn trên, chia sẻ link cho bạn bè test.

  2. Thiết lập UptimeRobot ping /health mỗi 10 phút, quan sát thời gian phản hồi trước và sau khi bật.

  3. Đo tốc độ trang: dùng https://pagespeed.web.dev. Ghi lại điểm số, thử tối ưu ảnh bằng next/image rồi đo lại.

  4. CI/CD: tạo .github/workflows/ci.yml chạy npm run build cho cả frontend và backend mỗi khi push. Push code lỗi để xem workflow báo đỏ.

  5. Script sao lưu tự động: viết script bash/PowerShell chạy mongodump và nén thành file zip theo ngày.

  6. Thử phá hoại có chủ đích: đổi ALLOWED_ORIGINS thà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.


⬅️ Bài trước | Mục lục | Bài tiếp theo: Lỗi thường gặp ➡️


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.