nirva|deploy
2026-05-14·9 min read·NirvaDeploy team
#monitoring#observability#ops#devex#Next.js#Thai-SaaS

ทำ 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 — แค่นั้น

ปัญหาคือ:

  1. Auth ไม่อยู่ในการเช็ค — token ของคุณหมดอายุ user login ไม่ได้ แต่ home page ก็ยัง 200
  2. DB ไม่อยู่ในการเช็ค — Postgres ค้าง POST request fail แต่ static home page ก็ยัง 200
  3. Email service ไม่อยู่ในการเช็ค — Resend API key หาย email บอกลูกค้าไม่ได้ แต่ก็ยัง 200
  4. 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:

SubsystemCritical?If down → ลูกค้าเจออะไร?
🌐 Web Dashboard✅ YesUI ไม่ load
🔑 Authentication (NextAuth)✅ YesLogin ไม่ได้
💳 Stripe billing✅ Yesสมัครใหม่ไม่ได้, webhook fail
🗄 Database (Postgres)✅ YesSave ไม่ได้, query timeout
📦 Engine (Railway/Dokploy)✅ YesDeploy ไม่ได้
📧 Email (Resend)⚠️ Degradedpassword 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 ต้องมี:

  1. Auth header เฉพาะ ops, หรือ
  2. Rate limit (1 ครั้ง/นาที max), หรือ
  3. IP allowlist เฉพาะ UptimeRobot IPs

Step 5: Wire UptimeRobot / BetterStack (ฟรีทั้งคู่)

UptimeRobot (Free tier: 50 monitors, 5-minute interval)

  1. Sign up: uptimerobot.com
  2. + New Monitor:
  • Type: HTTP(s)
  • URL: https://yourdomain.com/api/health
  • Interval: 5 min (free max)
  • Advanced → Keyword: ใส่ "operational" (alert ถ้าไม่เจอใน response)
  1. Alert contacts:
  • Email
  • Line OA broadcast (via webhook → Line Messaging API)

BetterStack (Free tier: 10 monitors, 3-minute interval)

  1. betterstack.com/uptime
  2. + Create monitor:
  • URL: https://yourdomain.com/api/health
  • Required HTTP status: 200
  • Required response body: contains "operational"
  1. ดี: มี Status page ฟรีพ่วงให้ด้วย — เหมือนของ NirvaDeploy ที่

nirvadeploy.com/status

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 ห้าม SSG

2. ใช้ 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 เราใช้แบบนี้

ของจริงดูได้ที่:

แล้วยังมี 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: degraded

TL;DR

  1. Don't ping `/` — ping /api/health ที่เช็คทุกระบบ
  2. HTTP code = signal — 503 on outage, 200 otherwise. monitor ฟรี

อ่านแค่นั้นพอ

  1. Severity mattersoutage > degraded > maintenance > operational
  2. Shallow by default — env-presence checks, sub-millisecond.

Deep mode behind auth/rate-limit

  1. Cache-Control: no-store — non-negotiable. force-dynamic ใน Next.js
  2. Don't leak secrets — boolean presence only, ไม่ใช่ value
  3. Wire UptimeRobot/BetterStack — ฟรีทั้งคู่, 3-5 นาที setup
  4. Status page ใช้ library เดียวกัน — single source of truth

ที่จะอ่านต่อ

หรือ ลอง NirvaDeploy เลย — เราจัดการ /api/health + status page ให้ทุก service ที่ deploy ที่ nirvadeploy.com (Hobby ฿111/mo · cheaper than Railway 37%)


ชอบบทความนี้?

ลอง NirvaDeploy ฟรี

Free tier ตลอดชีพ · ไม่ต้องใส่บัตร · Sign in ด้วย Line/Google

เริ่มเลย →