Skip to content

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

bash
mkdir til && cd til
pnpm init
pnpm add -D vitepress
pnpm vitepress init

Khi init:

  • Site title: deekode / TIL
  • Site description: Today I Learned — personal notes & knowledge base
  • Theme: Default Theme
  • Use TypeScript: Yes

Config

ts
// .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',
    },
  },
})

Thay vì hardcode sidebar trong config.mts, dùng JSON riêng để AI có thể update dễ dàng:

json
[
  {
    "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
*.log

Phase 2 — Deploy lên Cloudflare Pages

GitHub Action deploy

yaml
# .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=til

Secrets cần thiết

SecretLấy ở đâu
CLOUDFLARE_API_TOKENCF Dashboard → My Profile → API Tokens → Custom Token → Account: Cloudflare Pages: Edit
CLOUDFLARE_ACCOUNT_IDCF 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

  1. CF Dashboard → Zero Trust → đặt tên team
  2. Access → Applications → Add → Self-hosted
  3. Domain: til.deekode.dev, Path: (để trống)
  4. Policy: Allow → Emails → your@gmail.com
  5. 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 requests

Phase 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

yaml
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.mjs

Tạ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:

bash
pnpm docs:dev --port 5174
# hoặc upgrade Node.js lên v20+
nvm install 20 && nvm use 20

2. 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:

ts
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:

json
{ "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():

js
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:

js
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 lowercase

8. 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:

js
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:

bash
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:

yaml
- name: Setup pnpm
  if: steps.changed.outputs.files != ''
  uses: pnpm/action-setup@v4

Commit Convention

PrefixDù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:

  1. Settings → API Keys → Create API Key
  2. Tên: til-inbox-processor
  3. Copy ngay — không xem lại được sau khi tạo
  4. 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ự trigger

Dẫ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 wrangler trong GitHub Actions

Khác nhau:

CF Pages AutoGitHub Actions
SetupZero configCần maintain workflow
Build configCF Dashboarddeploy.yml
Trigger controlMọi pushCó thể filter theo paths
DebugCF Dashboard logsGitHub 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 auth

Migration plan:

  • docs/**/*.mdsrc/content/docs/**/*.md (copy nguyên)
  • sidebar config → Starlight sidebar format
  • output: 'server' + adapter: cloudflare()
  • Middleware check Cf-Access-Jwt-Assertion header

Today I Learned