md-server: เปิดอ่านไฟล์ .md บนมือถือได้ทันที ไม่ต้อง publish ไม่ต้อง build
เซิร์ฟเวอร์เล็ก ๆ ที่ render ทุกไฟล์ .md ใต้โฟลเดอร์งานเป็นหน้าเว็บ mobile-first พร้อม auto-reload, Mermaid และ syntax highlighting เปิดอ่านจากมือถือผ่าน Tailscale ได้ทันที ไม่มีขั้นตอน publish/build ต่างจาก Obsidian Publish / Notion / mkdocs ตรงที่ชี้ไปที่โฟลเดอร์งานจริง serve ทุกไฟล์ .md โดยไม่ต้องย้ายไฟล์หรือ config — บทความรวมการเปรียบเทียบ วิธีติดตั้ง Tailscale และ Docker Compose ครบ
ของ lazyyy เอง · อัปเดต 6 พ.ย. 2569
ดูโค้ดต้นทางส่วนประกอบ
markdown · self-hosted · tailscale · developer-tools · docker
md-server: เปิดอ่านไฟล์ .md บนมือถือได้ทันที ไม่ต้อง publish ไม่ต้อง build
ทุกวันนี้ agent (Claude Code, Codex ฯลฯ) เขียน PLAN.md, DESIGN.md, REVIEW.md ทิ้งไว้เต็มเครื่อง แต่เวลาจะอ่านจริงๆ เรามักไม่ได้นั่งอยู่หน้าเทอร์มินัล — เราอยู่บนโซฟา ถือมือถือ แล้ว path แบบ ~/projects/myapp/PLAN.md มันอ่านไม่ได้เลย
md-server คือเซิร์ฟเวอร์เล็กๆ ที่ render ทุกไฟล์ .md ใต้ ~/projects เป็นหน้าเว็บ mobile-first ไม่มีขั้นตอน publish ไม่มีขั้นตอน build — เขียนไฟล์เสร็จ ส่ง URL ให้ตัวเอง จบ
- ไฟล์
~/projects/myapp/PLAN.md→http://dev-box:8080/myapp/PLAN.md - แก้ไฟล์แล้วหน้าเว็บ reload เอง ลิงก์เดิมที่เปิดค้างไว้ยังสดอยู่เสมอ
เปรียบเทียบกับทางเลือกอื่น
| เครื่องมือ | publish/build | อ่านบนมือถือ | auto-reload | ต้องติดตั้งบน host |
|---|---|---|---|---|
| md-server | ไม่ต้อง | ผ่าน Tailscale | ✓ | แค่ Docker + Tailscale |
| Obsidian Publish | ต้อง sync | ใช่ (internet) | ไม่ได้ | Obsidian app |
| Notion | ต้อง | ใช่ (internet) | ไม่ได้ | browser/app |
mkdocs serve | ต้อง build | ต้อง tunnel/expose | ตอน dev อย่างเดียว | Python + mkdocs |
จุดต่าง
- Obsidian Publish — ออกแบบมาให้ sync vault ขึ้น cloud แล้วเข้าถึงผ่านอินเทอร์เน็ต เหมาะกับ knowledge base ส่วนตัวที่อยากแชร์สาธารณะ แต่ต้องจ่ายรายเดือน ต้อง publish ทีละหน้าด้วยมือ และไฟล์ที่ agent เขียนนอก Obsidian ต้อง import เข้า vault ก่อน
- Notion — เอกสารต้องสร้างใน Notion เท่านั้น ไฟล์
.mdบนดิสก์ต้อง copy-paste หรือ import ด้วยมือ ไม่มี auto-reload และไม่สามารถชี้ไปที่โฟลเดอร์งานจริงได้โดยตรง mkdocs serve— ต้องมีไฟล์mkdocs.ymlconfig ต้อง build theme ไฟล์ที่ไม่ได้อยู่ในdocs/ต้องย้ายหรือ symlink auto-reload ทำงานเฉพาะตอน dev (ไม่ได้ออกแบบให้รันเป็น daemon) และถ้าจะเข้าจากมือถือต้อง expose port ด้วย ngrok หรือ Tailscale Funnel
md-server ต่างตรงที่ มันชี้ไปที่โฟลเดอร์งานจริงๆ ของคุณ (~/projects) serve ทุกไฟล์ .md ที่มีโดยไม่ต้องย้ายไฟล์ ไม่ต้อง config ไม่ต้อง build และ auto-reload ทำงานเสมอ — เหมาะกับเอกสารที่ agent เขียนให้เยอะ ๆ ที่อยากเปิดอ่านทันทีโดยไม่ต้องผ่านขั้นตอนอะไรเลย
มันทำอะไรได้บ้าง
- Auto-reload — หน้าเว็บที่เปิดอยู่รีโหลดเมื่อไฟล์บนดิสก์เปลี่ยน เหมาะมากเวลา agent กำลังแก้เอกสารอยู่ ส่งลิงก์ครั้งเดียวพอ
- Mermaid diagram และ highlight.js syntax highlighting ทั้งคู่ตามธีม light/dark ของเครื่องที่เปิด
- Mobile-first — รองรับ safe-area inset ตารางกว้างๆ และบล็อกโค้ด scroll อยู่ในกล่องของตัวเอง ไม่ทำให้ทั้งหน้าเลื่อนซ้ายขวา
- Render ไฟล์ source (
.ts,.rs,.json…) พร้อม highlight และ serve รูป/วิดีโอ inline ให้ markdown ใช้งานได้จริง
Route ที่มี
| Route | ทำอะไร |
|---|---|
/<path> | render ไฟล์ markdown หรือ list ไดเรกทอรี (ถ้าโฟลเดอร์มี README.md / index.md จะ render อันนั้นแทน) |
/_recent | ไฟล์ markdown ทั้งหมดใต้ root เรียงจากที่แก้ล่าสุด |
/_search?q= | ค้นหาทั้งชื่อไฟล์และ full-text |
/_healthz | liveness probe |
Stack
Node 22 (alpine) + markdown-it (พร้อม markdown-it-anchor, markdown-it-task-lists) + highlight.js + mermaid — เท่านั้น ไม่มี framework ไม่มี database ทั้งหมดอยู่ในไฟล์ server.js ไฟล์เดียว
ข้างใน server.js
ไม่มี Express ไม่มี router library — ใช้ http.createServer ของ Node ตรงๆ แล้วเช็ค pathname เรียงกันไป route ที่ขึ้นต้นด้วย _ จองไว้ให้ระบบ ที่เหลือถือเป็น path ของไฟล์จริงทั้งหมด
if (pathname === '/_healthz') // liveness
if (pathname === '/_events') // SSE ไว้ auto-reload
if (pathname === '/_recent') // ไฟล์ล่าสุด
if (pathname === '/_search') // ค้นหา
if (pathname.startsWith('/_assets/')) // css, mermaid
// ไม่เข้าเงื่อนไขไหนเลย → มองเป็นไฟล์บนดิสก์
กันหลุดออกนอก doc root
หัวใจความปลอดภัยอยู่ที่ฟังก์ชันเดียว ทุก request ต้องผ่านมัน:
function safeResolve (urlPath) {
let rel
try { rel = decodeURIComponent(urlPath) } catch { return null }
const abs = path.resolve(ROOT, '.' + path.posix.normalize('/' + rel))
if (abs !== ROOT && !abs.startsWith(ROOT + path.sep)) return null
return abs
}
จุดสำคัญคือ decode ก่อนแล้วค่อย normalize — ถ้าเช็คก่อน decode %2e%2e%2f จะรอดด่านไปเป็น ../ ทีหลัง และการเติม '.' หน้า path ที่ normalize แล้วบังคับให้ทุกอย่างถูก resolve จาก ROOT เสมอ ส่วนบรรทัดสุดท้ายเทียบด้วย ROOT + path.sep ไม่ใช่ ROOT เฉยๆ ไม่งั้นโฟลเดอร์ชื่อ /docs-secret จะผ่านเพราะขึ้นต้นด้วยคำว่า /docs เหมือนกัน
ไฟล์ประเภทไหน แสดงยังไง
ดูจากนามสกุลล้วนๆ:
| กลุ่ม | ทำอะไร |
|---|---|
.md, .markdown, .mdx | render เป็น HTML |
ไฟล์ในตาราง MIME (รูป วิดีโอ pdf font) | createReadStream ส่ง byte ตรงๆ ไม่โหลดเข้าหน่วยความจำ |
.ts, .rs, .py, .json … | render พร้อม syntax highlight (เกิน 2MB ตอบ 404 กันหน้าค้าง) |
| อย่างอื่น | application/octet-stream |
ถ้า path ที่ขอเป็นโฟลเดอร์ จะมองหา README.md แล้ว index.md ก่อน เจอเมื่อไหร่ render อันนั้นแทนการโชว์รายชื่อไฟล์
markdown-it กับ mermaid
ตัว mermaid ไม่ได้ถูก render ที่ฝั่ง server — hook highlight แค่ดักภาษาไว้แล้วโยนให้ browser ทำต่อ:
highlight (code, lang) {
if (lang === 'mermaid') {
return `<pre class="mermaid">${escapeHtml(code)}</pre>`
}
// ที่เหลือส่งให้ highlight.js
}
ผลคือ mermaid.js ถูกโหลดเฉพาะหน้าที่มีไดอะแกรมจริงเท่านั้น หน้าอื่นไม่ต้องแบก และตัว mermaid.esm.min.mjs เสิร์ฟจาก node_modules ผ่าน /_assets/ เอง ไม่ต้องพึ่ง CDN — เปิดได้แม้เน็ตหลุด
auto-reload ทำงานยังไง
ทุกหน้าที่ render จะฝัง EventSource ชี้ไปที่ /_events?path=<ไฟล์นั้น> ฝั่ง server ใช้ fs.watchFile แบบ poll ทุก 700ms ไม่ใช่ fs.watch:
// watchFile polls — เชื่อถือได้กว่าบน bind mount ที่ inotify มักไม่ทำงาน
const sigOf = (st) => `${st.mtimeMs}:${st.size}`
let last
try { last = sigOf(fs.statSync(abs)) } catch { last = '' }
fs.watchFile(abs, { interval: 700 }, (curr) => {
const sig = sigOf(curr)
if (sig !== last) { last = sig; res.write('data: change\n\n') }
})
สองรายละเอียดที่ต้องมี ไม่งั้นพังเงียบๆ:
- poll ไม่ใช่ inotify — ไฟล์ที่แก้จากนอก container ผ่าน bind mount บ่อยครั้งไม่ส่ง inotify event เข้ามาถึงข้างใน การ poll ช้ากว่าแต่ไม่พลาด
- ต้อง
statSyncตั้งต้นก่อน —watchFileไม่ยิง event แรกตอนเริ่ม watch ถ้าไปถือเอา callback แรกเป็นค่าตั้งต้น การแก้ไฟล์ครั้งแรกจะหายไปทั้งครั้ง
ปิดท้ายด้วย : ping ทุก 25 วินาทีกัน proxy ตัดการเชื่อมต่อ และ req.on('close') เรียก fs.unwatchFile เสมอ ไม่งั้นแท็บที่เปิดๆ ปิดๆ จะทิ้ง watcher ค้างสะสม
กันตัวเองไม่ให้ค้าง
ไฟล์เดียวกันนี้ตั้งเพดานไว้สามจุด เพราะมันชี้ไปที่โฟลเดอร์งานจริงที่โตได้เรื่อยๆ:
SKIP_DIRS— ข้ามnode_modules,.git,target,dist,.next… ทั้งตอน list, walk และ searchMAX_WALK_ENTRIES = 40000— เดินไฟล์เกินนี้แล้วหยุด ไม่ให้ tree ใหญ่ๆ ทำ server ค้างMAX_SEARCH_BYTES = 512KB— ไฟล์ใหญ่กว่านี้ค้นเฉพาะชื่อ ไม่อ่านเนื้อใน และผลการค้นตัดที่ 100 รายการ
Tailscale คืออะไร
Tailscale คือ VPN แบบ mesh ที่สร้างบน WireGuard ทำงานโดยสร้างเครือข่ายส่วนตัว (tailnet) ที่เชื่อมเครื่องทั้งหมดของคุณเข้าด้วยกันโดยตรง ไม่ว่าจะอยู่ที่ไหนก็ตาม
หลักการทำงาน
- ติดตั้ง Tailscale บนเครื่องแต่ละเครื่อง (คอมพิวเตอร์ มือถือ เซิร์ฟเวอร์)
- ล็อกอินด้วยบัญชีเดียวกัน (Google, GitHub, Microsoft …)
- ทุกเครื่องได้ IP ในช่วง
100.64.0.0/10และคุยกันได้เหมือนอยู่ LAN เดียวกัน - Tailscale จะพยายามเชื่อมต่อแบบ peer-to-peer โดยตรง (ผ่าน NAT traversal) ถ้าไม่ได้ค่อยใช้ relay server
ทำไมถึงเหมาะกับ md-server
- ไม่ต้องเปิดพอร์ตออกอินเทอร์เน็ต — คนอื่นมองไม่เห็น port 8080 ของคุณเลย
- ไม่ต้อง forward port ที่เราเตอร์ — Tailscale จัดการ NAT ให้เอง
- ไม่ต้องมีโดเมนหรือใบรับรอง HTTPS — ใช้ MagicDNS (ชื่อเครื่อง +
.ts.net) ได้ทันทีหรือใช้ IP ตรงๆ ก็ได้ - มือถือเข้าถึงเครื่องที่บ้านได้ตรงๆ — แม้จะอยู่นอกบ้านหรือใช้ 4G/5G
ตัวอย่างการใช้งาน
สมมติคุณมี:
- เครื่องเดสก์ท็อปที่บ้านชื่อ
dev-box(ติดตั้ง Tailscale แล้ว) - มือถือที่ติดตั้ง Tailscale app และล็อกอินบัญชีเดียวกัน
เมื่อเปิด Tailscale บนมือถือ คุณสามารถเปิดบราวเซอร์แล้วพิมพ์ http://dev-box:8080/ ได้ทันที ไม่ว่าจะอยู่ที่ไหน — ที่ทำงาน ในรถไฟ หรือต่างประเทศ
ความปลอดภัย
การเข้าถึงผ่าน Tailscale หมายความว่า:
- เฉพาะคุณ (และเครื่องที่คุณเชื่อถือที่ล็อกอินบัญชีเดียวกัน) เท่านั้นที่เข้าถึงได้
- ทราฟฟิกถูกเข้ารหัสด้วย WireGuard
- ไม่มีใครบนอินเทอร์เน็ตสแกนเจอ port 8080 ของคุณได้
หากต้องการแชร์ให้คนอื่นเข้าถึง Tailscale มี ACL (Access Control Lists) ให้ตั้งค่าได้ละเอียด หรือใช้ Tailscale Funnel เพื่อเปิดเผยเซิร์ฟเวอร์ออกสู่อินเทอร์เน็ตสาธารณะชั่วคราว
การติดตั้ง Tailscale
# Ubuntu / Debian
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
# macOS
brew install tailscale
sudo tailscale up
# Windows / iOS / Android
# ดาวน์โหลด app จาก https://tailscale.com/download
หลังรันคำสั่ง tailscale up เปิดลิงก์ที่ขึ้นมาเพื่อล็อกอิน เมื่อเสร็จเช็ค IP ที่ได้รับด้วย:
tailscale ip -4
# ได้ เช่น 100.x.y.z
IP นี้ใช้ bind ใน docker-compose.yml และเป็น IP ที่มือถือจะใช้เข้าถึง
วิธีติดตั้ง
โครงสร้างโปรเจกต์: Dockerfile, docker-compose.yml, package.json, server.js
ตัวอย่างทั้งหมดในบทความนี้ใช้ชื่อสมมติ —
~/projects(โฟลเดอร์งาน),dev-box(ชื่อเครื่อง),tailnet-name(ชื่อ tailnet),100.x.y.z(Tailscale IP) — เปลี่ยนเป็นของเครื่องคุณทุกจุด
1. Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY server.js ./
ENV DOCS_ROOT=/docs \
PORT=8080 \
HOST=0.0.0.0 \
NODE_ENV=production
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1:8080/_healthz || exit 1
CMD ["node", "server.js"]
2. package.json
{
"name": "md-server",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"main": "server.js",
"scripts": { "start": "node server.js" },
"dependencies": {
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-task-lists": "^2.1.1",
"mermaid": "^11.4.1"
}
}
3. docker-compose.yml
จุดสำคัญอยู่ที่ การ bind port — ไม่ publish ออก 0.0.0.0 แต่ผูกไว้กับ interface ของ Tailscale + loopback เท่านั้น
services:
md-server:
build: .
container_name: md-server
restart: unless-stopped
ports:
# Tailscale interface เท่านั้น — LAN และอินเทอร์เน็ตเข้าไม่ถึง
- "100.x.y.z:8080:8080"
# loopback เพื่อให้ตัวเครื่องเอง (และ agent บนเครื่อง) เรียกได้
- "127.0.0.1:8080:8080"
# /etc/hosts ของ Ubuntu map ชื่อเครื่องตัวเองไป 127.0.1.1
# ซึ่งชนะ search domain ของ Tailscale — ต้อง bind ด้วย
# ไม่งั้น `myhost:8080` ใช้ได้จากมือถือ แต่ถูก refuse บนเครื่องตัวเอง
- "127.0.1.1:8080:8080"
volumes:
- ${HOME}/projects:/docs:ro
environment:
DOCS_ROOT: /docs
TZ: Asia/Bangkok # ตั้งตาม timezone ของคุณ
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
หา Tailscale IP ของเครื่องตัวเองด้วย
tailscale ip -4
4. server.js
ตัวเซิร์ฟเวอร์ยาวหลายร้อยบรรทัด ยาวเกินจะแปะทั้งก้อนในบทความ — โค้ดทั้งหมดอยู่บน GitHub แล้ว รวมสามไฟล์ข้างบนด้วย ก๊อปมาทั้ง repo ได้เลย:
https://github.com/wadjakorn/md-server
git clone https://github.com/wadjakorn/md-server.git
cd md-server
ถ้าอยากรู้ว่าข้างในทำงานยังไงก่อนรัน อ่านได้ที่หัวข้อ
ข้างใน server.js ด้านบน
5. รัน
docker compose up -d --build # start / rebuild หลังแก้ server.js
docker compose logs -f # ดู log
docker compose restart
restart: unless-stopped ทำให้มันกลับมาเองหลัง reboot
6. เปิดจากมือถือ
ต่อ Tailscale บนมือถือ แล้วเปิด:
- MagicDNS:
http://dev-box:8080/ - FQDN:
http://dev-box.tailnet-name.ts.net:8080/ - Raw IP:
http://100.x.y.z:8080/
path หลัง port คือ path ที่ relative กับ doc root
เรื่องความปลอดภัย (อ่านก่อนเอาไปใช้)
- ไม่มี authentication — อะไรก็ตามที่อยู่บน tailnet และเข้าถึง port 8080 ได้จะอ่านไฟล์ทั้งหมดใน doc root ได้ นั่นคือ trust boundary ที่ตั้งใจไว้ อย่าขยาย port binding ออกไปกว้างกว่านี้
- mount doc root แบบ read-only ตัว container เองก็
read_only: trueบวกno-new-privileges - ปฏิเสธ path traversal ออกนอก doc root
- ข้าม dotfiles และโฟลเดอร์ build หนักๆ (
node_modules,target,.git…) ทั้งตอน list, walk และ search
ทำไมถึงคุ้ม
เพราะมันลบ "ขั้นตอน publish" ออกจากสมการทั้งหมด เอกสารยาวๆ ที่ agent เขียนให้ไม่ต้องถูก copy ไปที่ไหน ไม่ต้อง deploy ไม่ต้องเปิด editor บนมือถือ — แค่ไฟล์ .md ที่วางถูกที่ กับลิงก์หนึ่งลิงก์
ถ้าใช้ coding agent อยู่ ลองตั้งกฎประจำให้มันไว้ว่า: เอกสารยาวๆ ให้เขียนเป็น .md ไว้ใต้โฟลเดอร์ที่ serve อยู่ แล้วปิดท้ายคำตอบด้วยลิงก์ของหน้านั้นเสมอ เท่านี้ทุกอย่างที่มันเขียนก็อ่านต่อบนมือถือได้ทันทีโดยไม่ต้องทำอะไรเพิ่ม

