ทำ Uptime Monitoring ฟรี $0/เดือน — สร้าง /api/health endpoint แบบมืออาชีพใน 50 บรรทัด
ลูกค้าแจ้งว่าเว็บล่ม คุณเปิด /dashboard ปกติ ทุกอย่างเขียวสวย — แต่จริงๆ background job ค้างมา 2 ชั่วโมงแล้ว ใครก็ไม่รู้>
ที่ยังไม่มี health endpoint ที่ดี ใช่มั้ย? บทความนี้แก้ปัญหานั้น
ปัญหา: monitor ดูแต่ 200 OK จากหน้าแรก
UptimeRobot / Pingdom / BetterStack ส่วนใหญ่ถูกตั้งให้ ping https://yourdomain.com/ แล้วเช็คว่าได้ HTTP 200 — แค่นั้น
ปัญหาคือ:
- Auth ไม่อยู่ในการเช็ค — token ของคุณหมดอายุ user login ไม่ได้ แต่ home page ก็ยัง 200
- DB ไม่อยู่ในการเช็ค — Postgres ค้าง POST request fail แต่ static home page ก็ยัง 200
- Email service ไม่อยู่ในการเช็ค — Resend API key หาย email บอกลูกค้าไม่ได้ แต่ก็ยัง 200
- CDN cache อาจ lie — static page ยังเสิร์ฟ cached 200 อยู่ 60 วินาทีหลังเว็บล่ม
→ Monitor บอก "ทุกอย่างเขียว" แต่ ลูกค้าจริงเดือดร้อนอยู่
แนวคิด: /api/health คือ probe จริงๆ ที่เช็คทุกส่วนของระบบ
Pattern ที่บริษัทใหญ่ใช้ (Stripe, GitHub, AWS, ที่ NirvaDeploy ใช้):
GET /api/health → HTTP 200 + JSON (ทุกอย่าง OK)
→ HTTP 503 + JSON (มีอะไรพัง)
→ HTTP 500 (probe เองพัง — bug)HTTP code คือ signal หลัก — monitor ฟรี/ราคาถูกอ่านแค่ status code ก็พอ JSON body เป็น bonus สำหรับ dashboard ที่ render รายละเอียดได้
Step 1: List ของ "ส่วนสำคัญ"
ก่อนเขียน code ตอบคำถามนี้ก่อน — อะไรที่ถ้าตาย แล้วลูกค้าเดือดร้อน?
ตัวอย่างของ NirvaDeploy:
| Subsystem | Critical? | If down → ลูกค้าเจออะไร? |
|---|---|---|
| 🌐 Web Dashboard | ✅ Yes | UI ไม่ load |
| 🔑 Authentication (NextAuth) | ✅ Yes | Login ไม่ได้ |
| 💳 Stripe billing | ✅ Yes | สมัครใหม่ไม่ได้, webhook fail |
| 🗄 Database (Postgres) | ✅ Yes | Save ไม่ได้, query timeout |
| 📦 Engine (Railway/Dokploy) | ✅ Yes | Deploy ไม่ได้ |
| 📧 Email (Resend) | ⚠️ Degraded | password reset ไม่ส่ง — แต่ระบบยังใช้ได้ |
| 🤝 Affiliate tracking | ✅ Yes | คิด commission ผิด |
→ 3 statuses ที่ต้องแยก: operational, degraded, outage (บวก maintenance สำหรับช่วง downtime ที่ planned ไว้)
Step 2: Severity ordering
อย่าทำผิดข้อนี้ — เวลาประกาศ "ภาพรวม":
outage > degraded > maintenance > operationalตัวอย่าง:
- ทุกอย่างเขียว + email "degraded" → ภาพรวม = degraded (ไม่ใช่ operational!)
- ทุกอย่างเขียว + DB "outage" → ภาพรวม = outage
- ทุกอย่างเขียว + 1 service "maintenance" planned → ภาพรวม = maintenance
(banner สีฟ้า ไม่ใช่เขียว — เพราะอย่างน้อยลูกค้าเดือดร้อนสำหรับ feature นั้น)
Step 3: Code — TypeScript / Next.js เวอร์ชัน
ที่ NirvaDeploy เราใช้ Next.js 14 App Router — web/app/api/health/route.ts:
// lib/health-checks.ts (shared library)
export type CheckStatus =
| "operational" | "degraded" | "outage" | "maintenance";
export interface HealthCheck {
name: string;
component: string;
status: CheckStatus;
message?: string;
}
const SEVERITY: Record<CheckStatus, number> = {
operational: 0, maintenance: 1, degraded: 2, outage: 3,
};
export function aggregateStatus(checks: HealthCheck[]): CheckStatus {
return checks.reduce<CheckStatus>(
(worst, c) => SEVERITY[c.status] > SEVERITY[worst] ? c.status : worst,
"operational",
);
}
function envOk(name: string): boolean {
const v = process.env[name];
return typeof v === "string" && v.trim().length > 0;
}
export async function runHealthChecks() {
const checks: HealthCheck[] = [];
// 1. Web — ถ้าเราถึงตรงนี้แล้ว = OK
checks.push({
name: "🌐 Web", component: "nirvadeploy.com",
status: "operational",
});
// 2. Auth — แยก NEXTAUTH_SECRET (critical) vs OAuth providers (degraded)
const hasSecret = envOk("NEXTAUTH_SECRET");
const hasProvider =
(envOk("LINE_CLIENT_ID") && envOk("LINE_CLIENT_SECRET")) ||
(envOk("GOOGLE_CLIENT_ID") && envOk("GOOGLE_CLIENT_SECRET"));
checks.push({
name: "🔑 Auth", component: "NextAuth",
status:
hasSecret && hasProvider ? "operational" :
!hasSecret ? "outage" : "degraded",
message: !hasSecret
? "NEXTAUTH_SECRET missing — sessions cannot be signed"
: !hasProvider
? "No OAuth providers configured"
: undefined,
});
// 3. Database — required
checks.push({
name: "🗄 Database", component: "Postgres",
status: envOk("DATABASE_URL") ? "operational" : "outage",
});
// 4. Email — degraded (not outage) when missing
checks.push({
name: "📧 Email", component: "Resend",
status: envOk("RESEND_API_KEY") ? "operational" : "degraded",
message: envOk("RESEND_API_KEY")
? undefined
: "Falls back to console.log — emails won't be sent",
});
return {
status: aggregateStatus(checks),
timestamp: new Date().toISOString(),
checks,
};
}
export function statusToHttpCode(status: CheckStatus): number {
return status === "outage" ? 503 : 200;
}// app/api/health/route.ts (Next.js handler)
import { NextResponse } from "next/server";
import { runHealthChecks, statusToHttpCode } from "@/lib/health-checks";
export const dynamic = "force-dynamic"; // never cache
export const revalidate = 0;
export async function GET() {
const report = await runHealthChecks();
return NextResponse.json(report, {
status: statusToHttpCode(report.status),
headers: {
"Cache-Control": "no-store, max-age=0",
"X-Health-Status": report.status, // monitors can key off this
},
});
}
export async function HEAD() {
const report = await runHealthChecks();
return new Response(null, {
status: statusToHttpCode(report.status),
headers: { "X-Health-Status": report.status },
});
}ทั้งหมด 50 บรรทัด — แค่นี้พอ. ที่เหลือเป็น detail ของ subsystem ที่เพิ่มทีหลังได้
Step 4: เลือก Mode — Shallow vs Deep
โค้ดด้านบนเป็น shallow mode — เช็คแค่ env-var presence:
- ✅ เร็วมาก (sub-millisecond)
- ✅ ไม่เปลือง quota ของ Stripe / Railway / Resend
- ❌ ไม่จับ "token ถูก revoke" หรือ "DB ค้าง"
ถ้าต้องการเช็คเข้มกว่านี้ — deep mode (รัน round-trip จริง):
export async function runHealthChecks({ deep = false } = {}) {
// ... checks เดิม ...
if (deep) {
// 1. DB
const t = performance.now();
try {
await prisma.$queryRaw`SELECT 1`;
checks.push({
name: "🗄 DB (deep)", component: "Postgres",
status: "operational",
});
} catch (e) {
checks.push({
name: "🗄 DB (deep)", component: "Postgres",
status: "outage", message: e.message,
});
}
// 2. Stripe
try {
const r = await fetch("https://api.stripe.com/v1", {
signal: AbortSignal.timeout(3000),
});
// 401 ก็ OK — แปลว่า Stripe ตอบได้ (แค่ไม่ส่ง auth)
checks.push({
name: "💳 Stripe (deep)", component: "api.stripe.com",
status: r.status < 500 ? "operational" : "outage",
});
} catch {
checks.push({
name: "💳 Stripe (deep)", component: "api.stripe.com",
status: "outage", message: "Network unreachable",
});
}
}
return { status: aggregateStatus(checks), timestamp, checks };
}แล้ว expose:
/api/health→ shallow (UptimeRobot poll ทุก 5 นาทีก็ไม่เปลือง)/api/health?deep=1→ deep (เรียกเฉพาะตอน manual debug หรือ rate-limit ไว้)
Warning: อย่าให้ public hit ?deep=1 ฟรี — โดน DoS ทำให้ Stripe quota หมด. ถ้า expose ต้องมี:
- Auth header เฉพาะ ops, หรือ
- Rate limit (1 ครั้ง/นาที max), หรือ
- IP allowlist เฉพาะ UptimeRobot IPs
Step 5: Wire UptimeRobot / BetterStack (ฟรีทั้งคู่)
UptimeRobot (Free tier: 50 monitors, 5-minute interval)
- Sign up: uptimerobot.com
- + New Monitor:
- Type: HTTP(s)
- URL:
https://yourdomain.com/api/health - Interval: 5 min (free max)
- Advanced → Keyword: ใส่
"operational"(alert ถ้าไม่เจอใน response)
- Alert contacts:
- Line OA broadcast (via webhook → Line Messaging API)
BetterStack (Free tier: 10 monitors, 3-minute interval)
- betterstack.com/uptime
- + Create monitor:
- URL:
https://yourdomain.com/api/health - Required HTTP status:
200 - Required response body: contains
"operational"
- ดี: มี Status page ฟรีพ่วงให้ด้วย — เหมือนของ NirvaDeploy ที่
Cronitor (ฟรี 5 monitors — แต่ดีตรง Slack/Discord integration)
URL pattern เดียวกัน
Step 6: Status Page บนเว็บของตัวเอง
/api/health เป็น JSON สำหรับ monitor — ลูกค้าทั่วไปอยากเห็น UI สวยๆ ทำหน้า /status ที่ ใช้ library เดียวกัน (ไม่ duplicate logic):
// app/status/page.tsx
import { runHealthChecks } from "@/lib/health-checks";
export const revalidate = 60; // re-render every minute
export default async function StatusPage() {
const report = await runHealthChecks();
return (
<main>
<Banner status={report.status} /> {/* เขียว/เหลือง/แดง/ฟ้า */}
<ul>
{report.checks.map(check => (
<ChecklistItem key={check.name} check={check} />
))}
</ul>
</main>
);
}ห้าม fetch `/api/health` จาก server component — import logic ตรงๆ ดีกว่า (ไม่ต้องผ่าน network, cache header ไม่ขวาง, type-safe)
Gotchas — สิ่งที่ผิดบ่อย
1. ลืม Cache-Control: no-store
CDN / Vercel จะ cache /api/health ถ้าไม่บอกไม่ให้ cache — monitor จะเห็น "green" ค้าง 60 วินาทีหลัง outage เกิดจริง
"Cache-Control": "no-store, max-age=0"
export const dynamic = "force-dynamic"; // Next.js ห้าม SSG2. ใช้ HTTP 200 ทุก case (รวม outage)
แล้ว monitor จะไม่ alert. 503 บน outage = standard practice — Kubernetes liveness probe, ELB health check, ทุกคนใช้แบบนี้
3. Leak env values ใน response
// ❌ NEVER
{ "stripe_key": process.env.STRIPE_SECRET_KEY }
// ✅ Yes — boolean presence only
{ "stripe": envOk("STRIPE_SECRET_KEY") ? "operational" : "outage" }/api/health ต้อง public + safe — ใครเข้าก็ได้ ไม่ leak secret
4. Probe ตัวเอง (infinite loop)
// ❌ Bad
checks.push({
name: "Web", status:
(await fetch("https://yourdomain.com/")).ok ? "operational" : "outage"
});ถ้า probe ตัวเอง — ตอนเว็บล่ม /api/health ก็ล่มด้วย — monitor เห็นแค่ 500 ไม่ค่อย useful
Web check ใน /api/health = implicit operational (ถ้าโค้ดรันถึงจุดนี้ = web works)
5. Deep mode bug — quota burn
ถ้าทำ ?deep=1 แล้วลืม rate limit — DoS attack 10 req/sec จะเรียก Stripe API 10/sec → Stripe quota หมด → ลูกค้าจริงจ่ายเงินไม่ได้
→ Always rate-limit deep mode, หรือ auth-gate ไว้
ที่ NirvaDeploy เราใช้แบบนี้
ของจริงดูได้ที่:
- 🔌 `/api/health` — JSON response
- 📊 `/status` — pretty UI ใช้ library เดียวกัน
- 🆔 `/api/version` — build SHA + region
แล้วยังมี nirvadeploy doctor CLI command + nirvadeploy_doctor MCP tool ที่เรียก /api/health แล้ว print สวยๆ — agent / AI / ops ใช้ได้หมด
$ nirvadeploy doctor
🩺 NirvaDeploy doctor
Local config
✓ Node.js v20.11.0
✓ Token present (~/.config/nirvadeploy): nd_pat_…f0a3
Remote subsystems
✓ 🌐 Web Dashboard · nirvadeploy.com
✓ 🔑 Authentication · NextAuth + Line/Google OAuth
✓ 💳 Billing · Stripe Thailand
✓ 🗄 Database · Supabase
⚠ 📧 Email · Resend
↳ RESEND_API_KEY missing — emails will log to stdout only
Overall: degradedTL;DR
- Don't ping `/` — ping
/api/healthที่เช็คทุกระบบ - HTTP code = signal — 503 on outage, 200 otherwise. monitor ฟรี
อ่านแค่นั้นพอ
- Severity matters —
outage > degraded > maintenance > operational - Shallow by default — env-presence checks, sub-millisecond.
Deep mode behind auth/rate-limit
- Cache-Control: no-store — non-negotiable. force-dynamic ใน Next.js
- Don't leak secrets — boolean presence only, ไม่ใช่ value
- Wire UptimeRobot/BetterStack — ฟรีทั้งคู่, 3-5 นาที setup
- Status page ใช้ library เดียวกัน — single source of truth
ที่จะอ่านต่อ
- Real-time logs SSE — สร้าง log streamer ที่ rebuffer ไม่ได้ใน Next.js
- Security best practices สำหรับ Thai SaaS
- Autoscaling Thai PaaS — เมื่อไหร่ scale, scale เท่าไหร่
หรือ ลอง NirvaDeploy เลย — เราจัดการ /api/health + status page ให้ทุก service ที่ deploy ที่ nirvadeploy.com (Hobby ฿111/mo · cheaper than Railway 37%)
ชอบบทความนี้?
ลอง NirvaDeploy ฟรี
Free tier ตลอดชีพ · ไม่ต้องใส่บัตร · Sign in ด้วย Line/Google
เริ่มเลย →