TIL: Xây dựng Finance Tracker hoàn toàn trên Cloudflare Workers
Hôm nay bắt đầu khởi tạo project Finance Tracker với mục tiêu chạy hoàn toàn trên hệ sinh thái Cloudflare Workers — không cần VPS, không cần quản lý database server riêng.
Tại sao chọn Cloudflare Workers?#
Cloudflare Workers cung cấp một môi trường serverless chạy ở edge network toàn cầu, với latency thấp và free tier khá rộng rãi cho side project cá nhân. Quan trọng hơn, ecosystem của Cloudflare đã đủ trưởng thành để xây dựng một ứng dụng full-stack hoàn chỉnh mà không cần rời khỏi nền tảng:
- Workers — compute layer, xử lý API và business logic
- D1 — SQLite-based database chạy trên edge
- KV — key-value store cho caching và session
- R2 — object storage tương thích S3 cho file/export
- Pages — hosting frontend tĩnh với CI/CD tích hợp
Kiến trúc dự kiến#
[Cloudflare Pages - Frontend]
│
▼
[Workers - API Layer]
├── /api/transactions (CRUD giao dịch)
├── /api/budgets (quản lý ngân sách)
├── /api/reports (tổng hợp / thống kê)
└── /api/auth (xác thực, session via KV)
│
▼
[D1 - SQLite Database]
├── transactions
├── categories
├── budgets
└── users
Khởi tạo project#
# Khởi tạo Workers project với Hono framework
npm create cloudflare@latest finance-tracker -- --template hono
# Tạo D1 database
wrangler d1 create finance-db
# Bind vào wrangler.toml
# [[d1_databases]]
# binding = "DB"
# database_name = "finance-db"
# database_id = "<id>"
Hono là framework nhẹ, được tối ưu cho Workers runtime — cú pháp quen thuộc kiểu Express nhưng không có Node.js dependencies.
Schema cơ bản#
CREATE TABLE transactions (
id TEXT PRIMARY KEY,
amount REAL NOT NULL,
type TEXT CHECK(type IN ('income', 'expense')),
category_id TEXT,
note TEXT,
date TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
type TEXT CHECK(type IN ('income', 'expense'))
);
D1 dùng SQLite syntax, hỗ trợ đầy đủ các câu lệnh DDL/DML cơ bản. Không có auto-increment integer ID nên dùng nanoid hoặc crypto.randomUUID() (built-in trong Workers runtime).
Điểm cần chú ý#
D1 vẫn còn giới hạn so với PostgreSQL hay MySQL — không có stored procedures, full-text search hạn chế, và không hỗ trợ concurrent writes tốt. Với Finance Tracker cá nhân thì không thành vấn đề, nhưng cần tính đến nếu muốn mở rộng sau này.
Cold start của Workers rất thấp (dưới 5ms) so với Lambda — không cần warm-up workaround.
Pricing free tier: 100,000 requests/ngày cho Workers, 5GB D1 reads, 100,000 D1 writes — đủ dùng cho cá nhân.
Bug: Set-Cookie bị chặn do cross-origin#
Khi frontend (finance.dhphong.com) gọi thẳng đến API worker (finance-tracker-api.dhphong.workers.dev), browser chặn Set-Cookie header vì hai domain khác nhau — cookie JWT không được lưu, dẫn đến auth bị broken hoàn toàn.
Nguyên nhân: Browser áp dụng Same-Origin Policy — cookie chỉ được set khi response đến từ cùng origin với trang hiện tại.
Giải pháp: Tạo Pages Function proxy tại functions/api/[[path]].js ngay trong repo frontend. Proxy này forward toàn bộ request đến worker thông qua Service Binding (kết nối nội bộ, không qua public internet):
// functions/api/[[path]].js
export async function onRequest({ request, env, params }) {
const url = new URL(request.url);
const path = "/" + (params.path?.join("/") ?? "");
const workerUrl = new URL(path + url.search, "https://placeholder");
const newRequest = new Request(workerUrl.toString(), {
method: request.method,
headers: request.headers,
body: ["GET", "HEAD"].includes(request.method) ? undefined : request.body,
});
return env.API_WORKER.fetch(newRequest);
}
Frontend gọi /api/... → Pages Function (cùng domain finance.dhphong.com) → Worker qua Service Binding → cookie được set từ cùng origin → browser chấp nhận ✅
Lợi ích thêm: Service Binding không tính vào request quota của Worker public, và latency thấp hơn vì không đi qua internet.
Tiếp theo#
- Implement CRUD API cơ bản với D1
- Thiết kế frontend đơn giản trên Pages (React hoặc plain HTML)
- Thêm xác thực nhẹ bằng Workers KV (session token)
- Export báo cáo ra CSV lưu vào R2