Building a Private TIL Site: VitePress + Cloudflare Pages + AI Inbox Processor
Stack: VitePress · Cloudflare Pages · Cloudflare Access · GitHub Actions · Claude API
Architecture Overview
Local .md files
│
▼
Git Push (GitHub)
│
▼
GitHub Actions
├── deploy.yml → build VitePress → Cloudflare Pages
└── process-inbox.yml → AI categorize _inbox/ → tạo PR
│
▼
Cloudflare Pages (static hosting)
│
▼
Cloudflare Access (auth gate)
│
▼
User sees docs ✅Phase 1 — VitePress Setup
mkdir til && cd til
pnpm init
pnpm add -D vitepress
pnpm vitepress initKhi init:
- Site title:
deekode / TIL - Site description:
Today I Learned — personal notes & knowledge base - Theme: Default Theme
- Use TypeScript: Yes
Config
// .vitepress/config.mts
import { defineConfig } from 'vitepress'
import sidebar from '../docs/sidebar.json'
export default defineConfig({
title: 'deekode / TIL',
description: 'Today I Learned — personal notes & knowledge base',
srcDir: './docs', // ← quan trọng nếu để docs/ riêng
base: '/',
themeConfig: {
nav: [
{ text: 'Home', link: '/' },
{
text: 'Private',
items: [
{ text: 'AWS', link: '/aws/' },
{ text: 'Node.js', link: '/nodejs/' },
{ text: 'Docker', link: '/docker/' },
],
},
{ text: 'Share', link: '/share/' },
],
sidebar, // đọc từ sidebar.json
search: { provider: 'local' },
socialLinks: [
{ icon: 'github', link: 'https://github.com/itsdeekha/til' },
],
footer: {
message: 'Today I Learned',
copyright: 'deekode',
},
},
})sidebar.json
Thay vì hardcode sidebar trong config.mts, dùng JSON riêng để AI có thể update dễ dàng:
[
{
"text": "AWS",
"collapsed": false,
"items": [
{ "text": "Overview", "link": "/aws/" }
]
},
{
"text": "Docker",
"collapsed": false,
"items": [
{ "text": "Overview", "link": "/docker/" }
]
}
]Cấu trúc thư mục
til/
├── .vitepress/
│ └── config.mts
├── .github/
│ └── workflows/
│ ├── deploy.yml
│ └── process-inbox.yml
├── docs/
│ ├── index.md ← homepage (layout: home)
│ ├── sidebar.json ← sidebar config
│ ├── aws/
│ ├── nodejs/
│ ├── docker/
│ └── share/
├── _inbox/ ← dump notes vào đây
│ └── .gitkeep
├── scripts/
│ └── process-inbox.mjs
├── .gitignore
├── biome.json
└── package.json.gitignore
# VitePress
.vitepress/cache
.vitepress/dist
# Dependencies
node_modules
# OS
.DS_Store
Thumbs.db
# Logs
*.logPhase 2 — Deploy lên Cloudflare Pages
GitHub Action deploy
# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
paths:
- 'docs/**'
- '.vitepress/**'
- 'package.json'
- 'pnpm-lock.yaml'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm docs:build
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy .vitepress/dist --project-name=tilSecrets cần thiết
| Secret | Lấy ở đâu |
|---|---|
CLOUDFLARE_API_TOKEN | CF Dashboard → My Profile → API Tokens → Custom Token → Account: Cloudflare Pages: Edit |
CLOUDFLARE_ACCOUNT_ID | CF Dashboard → Workers & Pages → Account ID |
Bug đã gặp: Token tạo từ Account settings thay vì My Profile sẽ bị lỗi Authentication error [code: 10000]. Phải tạo từ My Profile.
Phase 3 — Cloudflare Access (Private Auth)
Setup
- CF Dashboard → Zero Trust → đặt tên team
- Access → Applications → Add → Self-hosted
- Domain:
til.deekode.dev, Path: (để trống) - Policy: Allow → Emails →
your@gmail.com - Identity Provider: One-time PIN (đơn giản nhất, không cần OAuth)
Enable GitHub Actions tạo PR
Mặc định GitHub Actions không được phép tạo PR. Fix:
GitHub repo → Settings → Actions → General → Workflow permissions
✅ Read and write permissions
✅ Allow GitHub Actions to create and approve pull requestsPhase 4 — AI Inbox Processor
Workflow
Viết note → bỏ vào _inbox/
↓
git add + commit + push
↓
GitHub Action trigger
↓
Claude API phân tích nội dung
↓
Move file → đúng docs/topic/
Update sidebar.json
↓
Tạo 1 PR duy nhất với tất cả files
↓
Review → Merge ✅process-inbox.yml
name: Process Inbox
on:
push:
branches: [main]
jobs:
process:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get inbox files
id: changed
run: |
FILES=$(find _inbox -name "*.md" -type f | tr '\n' ',' | sed 's/,$//')
echo "files=$FILES" >> $GITHUB_OUTPUT
echo "Found inbox files: $FILES"
- name: Setup pnpm
if: steps.changed.outputs.files != ''
uses: pnpm/action-setup@v4
with:
version: 10.33.2
- name: Setup Node.js
if: steps.changed.outputs.files != ''
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
if: steps.changed.outputs.files != ''
run: pnpm install --frozen-lockfile
- name: Process inbox files
if: steps.changed.outputs.files != ''
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CHANGED_FILES: ${{ steps.changed.outputs.files }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/process-inbox.mjsTại sao dùng find thay vì git diff?
git diff HEAD~1 HEAD chỉ nhìn 1 commit trước — nếu push 2 commits liên tiếp (1 xóa file cũ, 1 add file mới) thì commit cuối diff ra empty. find _inbox -name "*.md" lấy tất cả files đang tồn tại thực sự → không bao giờ miss.
Tại sao if per-step thay vì 2 jobs?
Dùng 2 jobs (check + process) cần pass outputs qua needs.check.outputs.files — dễ bị lỗi context invalid. Dùng if per-step đơn giản hơn, vẫn skip setup/install khi không có files.
Bugs Đã Gặp & Fix
1. Upgrade Required (localhost)
Triệu chứng: Mở localhost:5173 chỉ thấy "Upgrade Required", không load được.
Nguyên nhân: Port conflict hoặc Node.js version không tương thích.
Fix:
pnpm docs:dev --port 5174
# hoặc upgrade Node.js lên v20+
nvm install 20 && nvm use 202. srcDir build lỗi
Triệu chứng: Move docs vào thư mục docs/ → build lỗi.
Fix: Thêm srcDir vào config:
export default defineConfig({
srcDir: './docs',
})3. CF Pages mất CSS ở /share/
Triệu chứng: Trang /share/ bypass CF Access nhưng bị mất toàn bộ CSS/JS.
Nguyên nhân: CF Access chặn cả /assets/ (nơi chứa JS/CSS bundles của VitePress).
Fix: Tạo thêm bypass application cho /assets/ trong CF Access.
4. Cloudflare API Token lỗi auth
Triệu chứng:
Authentication error [code: 10000]
Authentication failed (status: 400) [code: 9106]Nguyên nhân: Tạo token từ Account Settings thay vì My Profile → token type sai.
Fix: Vào My Profile → API Tokens → Create Token → Custom Token:
- Account → Cloudflare Pages → Edit
- Account → Account Settings → Read
5. GitHub Actions không tạo được PR
Triệu chứng:
{ "message": "GitHub Actions is not permitted to create or approve pull requests." }Fix: Repo Settings → Actions → General → Workflow permissions:
- ✅ Read and write permissions
- ✅ Allow GitHub Actions to create and approve pull requests
6. Sidebar insert sai vị trí
Triệu chứng: File Docker được add vào AWS sidebar block thay vì Docker block.
Nguyên nhân: Dùng regex để parse TypeScript config → fragile, dễ match nhầm.
Fix: Chuyển sang sidebar.json + dùng Array.find():
const topicEntry = sidebar.find(
(s) => s.text.toLowerCase() === topic.toLowerCase()
)
topicEntry.items.push({ text: sidebarLabel, link })Không bao giờ sai vị trí vì dùng exact match thay vì regex.
7. File path uppercase
Triệu chứng: File được move vào docs/AWS/ hoặc docs/Docker/ thay vì docs/aws/, docs/docker/.
Nguyên nhân: Claude API trả về topic với chữ hoa.
Fix: Force lowercase ở mọi nơi trong script:
const topic = analysis.topic.toLowerCase()
const filename = analysis.filename.toLowerCase()
const newFilePath = `docs/${topic}/${filename}`Thêm rules vào prompt:
- "filename" must be kebab-case lowercase
- "newTopicConfig.folderName" must be kebab-case lowercase8. YAML frontmatter lỗi với dấu :
Triệu chứng:
[vitepress] incomplete explicit mapping pair; a key node is missed
title: EC2 vs ECS: Production Decision Framework
^Nguyên nhân: YAML interpret : trong title là key-value separator.
Fix: Wrap title trong quotes:
function ensureFrontmatter(content, title) {
if (content.startsWith('---')) return content;
const safeTitle = `"${title.replace(/"/g, '\\"')}"`;
return `---\ntitle: ${safeTitle}\n---\n\n${content}`;
}9. Process Inbox trigger khi merge PR
Triệu chứng: Merge PR vào main → process-inbox trigger lại → lỗi ENOENT: no such file _inbox/...
Nguyên nhân: git diff HEAD~1 HEAD detect file bị xóa trong PR commit như là "changed".
Fix: Dùng find + check file tồn tại:
FILES=$(find _inbox -name "*.md" -type f | tr '\n' ',' | sed 's/,$//')Khi merge PR, _inbox/ đã trống → find trả về empty → skip.
10. CHANGED_FILES empty khi dùng 2-job workflow
Triệu chứng: needs.check.outputs.files không pass được sang job process.
Nguyên nhân: VS Code lint warning Context access might be invalid: check — outputs giữa jobs đôi khi không resolve đúng.
Fix: Gộp lại 1 job duy nhất, dùng if per-step:
- name: Setup pnpm
if: steps.changed.outputs.files != ''
uses: pnpm/action-setup@v4Commit Convention
| Prefix | Dùng khi |
|---|---|
inbox: <mô tả> | Thêm file vào _inbox/ |
inbox: add multiple notes (aws, docker) | Thêm nhiều files |
chore: | Config, setup, không ảnh hưởng code |
fix: | Bug fix |
feat: | Tính năng mới |
perf: | Cải thiện performance |
refactor: | Refactor code |
revert: | Revert commit |
Anthropic API Key
Tạo tại console.anthropic.com:
- Settings → API Keys → Create API Key
- Tên:
til-inbox-processor - Copy ngay — không xem lại được sau khi tạo
- Add vào GitHub: Settings → Secrets → Actions →
ANTHROPIC_API_KEY
Lưu ý: Claude Pro ($20/tháng) và Anthropic API là 2 sản phẩm tách biệt, không dùng chung được. API tính tiền theo token — với inbox processor dùng claude-haiku tốn ~$0.0005/file, cực rẻ.
Cloudflare Pages Auto Build vs GitHub Actions Deploy
Khi connect GitHub repo với Cloudflare Pages, mỗi lần push vào main sẽ có 2 luồng deploy chạy song song:
Push main
├── GitHub Actions deploy.yml → build → wrangler deploy ← tự setup
└── Cloudflare Pages (auto) → build → deploy ← CF tự triggerDẫn đến deploy 2 lần — lãng phí và đôi khi conflict.
Chọn 1 trong 2
Option A — Dùng CF Pages auto build (đơn giản hơn, recommend):
- Xóa
.github/workflows/deploy.yml - CF Pages tự build mỗi khi push, không cần maintain thêm gì
Option B — Dùng GitHub Actions hoàn toàn:
- CF Dashboard → Workers & Pages → til → Settings → Builds & Deployments → Disconnect GitHub
- Mọi deploy đều đi qua
wranglertrong GitHub Actions
Khác nhau:
| CF Pages Auto | GitHub Actions | |
|---|---|---|
| Setup | Zero config | Cần maintain workflow |
| Build config | CF Dashboard | deploy.yml |
| Trigger control | Mọi push | Có thể filter theo paths |
| Debug | CF Dashboard logs | GitHub Actions logs |
Nếu không cần filter paths (chỉ deploy khi thay đổi docs/), dùng CF Pages auto build là đủ và đơn giản hơn nhiều.
Future: Migrate sang Astro + Starlight
Khi cần per-page auth (một số trang public, một số private), migrate sang:
Astro + Starlight + @astrojs/cloudflare adapter
→ SSR trên Cloudflare Pages Functions
→ Auth middleware check CF Access JWT
→ Private pages không bao giờ render nếu không pass authMigration plan:
docs/**/*.md→src/content/docs/**/*.md(copy nguyên)sidebarconfig → Starlight sidebar formatoutput: 'server'+adapter: cloudflare()- Middleware check
Cf-Access-Jwt-Assertionheader