EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / DirectAdmin / โฮสต์ static บน DirectAdmin แล้ว GET ได้ 404 แต่ HEAD ได้ 200
DirectAdmin

โฮสต์ static บน DirectAdmin แล้ว GET ได้ 404 แต่ HEAD ได้ 200

โดย Ton-Or ·14 ก.ย. 2026·อ่าน 9 นาที
โฮสต์ static บน DirectAdmin แล้ว GET ได้ 404 แต่ HEAD ได้ 200

อาการที่ HEAD กับ GET ให้คำตอบไม่ตรงกันไม่ใช่เรื่องลึกลับ มันคือสัญญาณว่ามีของคั่นอยู่ระหว่างเว็บเซิร์ฟเวอร์กับไฟล์ และของนั้นตัดสินใจไม่เหมือนกันในสองเส้นทาง

พอดีทำเว็บคำนวณตัวหนึ่งเสร็จ เป็น static ล้วน HTML กับ JavaScript คำนวณในเบราว์เซอร์ ไม่มีฐานข้อมูล ไม่มี backend อะไรเลย รวมทั้งหมดไม่ถึงหกร้อยกิโลไบต์ ผมนึกว่างานนี้จบใน 5 นาที ลาก public_html ขึ้นไปแล้วก็ได้ใช้ ปรากฏว่าเปิดแล้วหน้าขาว ไล่ดูใน devtools เห็นไฟล์ js ทุกตัวได้ 404

ที่ทำให้เรื่องยาวคือตอนไล่หาสาเหตุ ผมยิง curl -I ไปเช็กว่าไฟล์อยู่ไหม แล้วมันตอบ 200 มาสวย ๆ พร้อม content-length ตรงกับขนาดไฟล์เป๊ะ กลับไปเปิดในเบราว์เซอร์ก็ยัง 404 อยู่ดี สองคำตอบขัดกันแบบนี้แหละที่ทำให้ผมเสียเวลาไปนานกว่าที่ควร บทความนี้เลยเขียนไว้ให้คนที่เจออาการเดียวกันไม่ต้องเสียเวลาซ้ำ

ชื่อโดเมน บัญชี และคีย์ต่าง ๆ ในบทความเป็นค่าตัวอย่าง ส่วนอาการและวิธีแก้เป็นของจริง

อาการที่อ่านแล้วงง

เริ่มจากบันทึกอาการให้ครบก่อน เพราะอาการชุดนี้ชี้ทางได้เกือบหมดถ้าอ่านออก

200HEAD ทุกไฟล์
404GET ไฟล์ใหญ่
200GET ไฟล์เล็ก
200ทุกไฟล์ใต้ subdomain

จุดที่ชวนหลงทางที่สุดคือ "ไฟล์เล็กได้ ไฟล์ใหญ่ไม่ได้" เพราะมันทำให้คิดไปทางเรื่องขนาด เรื่อง limit เรื่อง buffer ซึ่งผิดทางทั้งหมด ผมลองไล่ตั้งแต่ LimitRequestBody ยัน mod_deflate อยู่พักใหญ่โดยไม่ได้อะไรเลย

# ไฟล์เดียวกัน สองวิธี สองคำตอบ
curl -sI https://calc.example.com/engine.js | head -n3
# HTTP/2 200
# content-type: text/javascript
# content-length: 30214

curl -s -o /dev/null -w '%{http_code}\n' https://calc.example.com/engine.js
# 404

ตอนนั้นผมยังไม่รู้ว่าเส้นแบ่งจริงไม่ใช่ขนาดไฟล์ ผมเลยเริ่มจากคำถามพื้นฐานที่สุดแทน คือไฟล์อยู่บนเครื่องจริงไหม และเว็บเซิร์ฟเวอร์มองเห็นมันไหม

ssh -p 2233 user@host 'ls -la ~/domains/example.com/public_html/ | head -n20'
# ไฟล์ครบทุกตัว สิทธิ์ 644 เจ้าของถูก

# ยิงตรงจากในเครื่องเอง ตัด CDN กับ proxy ข้างนอกออกให้หมดก่อน
ssh -p 2233 user@host \
  'curl -sSk --resolve calc.example.com:443:127.0.0.1 -o /dev/null -w "%{http_code}\n" https://calc.example.com/engine.js'
# 404   <- ยังพังจากในเครื่อง แปลว่าไม่ใช่เรื่องของ CDN
ตัดตัวแปรจากนอกออกก่อนเสมอ

ถ้าโดเมนวิ่งผ่าน CDN หรือ proxy อยู่ อย่าเพิ่งสรุปอะไรจากผลที่ยิงจากเครื่องเรา ให้ ssh เข้าไปยิงจากในเครื่องด้วย --resolve ชี้กลับมา 127.0.0.1 ก่อน ถ้าผลเหมือนกันแปลว่าปัญหาอยู่ที่เครื่อง ถ้าต่างกันแปลว่าปัญหาอยู่ที่ชั้นนอก คำถามนี้ตอบได้ในสิบวินาทีและตัดงานเดาไปได้ครึ่งหนึ่ง

ต้นเหตุ — .htaccess ที่นั่งอยู่ผิดที่

พอรู้ว่าปัญหาอยู่ในเครื่อง ผมก็ไปดูสิ่งที่ควรดูตั้งแต่แรก คือ .htaccess ที่ docroot ปรากฏว่าโดเมนหลักของบัญชีนี้เคยเอาไปรัน Laravel ไว้ก่อน แล้วไฟล์ .htaccess ของ Laravel ยังอยู่ที่เดิม

# ~/domains/example.com/public_html/.htaccess (ของเดิมที่เหลือค้างจาก Laravel)
<IfModule mod_rewrite.c>
    <IfModule mod_negotiation.c>
        Options -MultiViews -Indexes
    </IfModule>

    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^ index.php [L]
</IfModule>

อ่านผ่าน ๆ กฎชุดนี้ดูปลอดภัยดีนะครับ เพราะมี !-f คุมอยู่ แปลว่าถ้าไฟล์มีอยู่จริงมันจะไม่ rewrite แต่ผลที่ได้จริงบนเครื่องนั้นคือคำขอถูกส่งเข้า index.php แล้วตัว router ของแอปหา route ที่ตรงไม่เจอ เลยตอบ 404 กลับมา ทั้งที่ไฟล์นั่งอยู่ตรงนั้น

เส้นทางของคำขอที่ถูกดูดเข้า front controllerGET /engine.jsไฟล์มีอยู่จริง->.htaccess ที่ docrootrewrite ไป index.php->router ของแอปไม่มี route นี้->404ไฟล์ไม่เคยถูกอ่าน

ส่วนเรื่อง Options -MultiViews นั้นเป็นอีกครึ่งของเรื่อง เจ้า MultiViews คือกลไกเก่าของ Apache ที่ยอมให้ขอ /report แล้วมันไปหยิบ report.html มาให้เอง Laravel ปิดมันเพราะไม่อยากให้ไปชนกับ route ของตัวเอง ผลข้างเคียงคือพฤติกรรมการหาไฟล์ที่ docroot นี้ไม่เหมือนโฟลเดอร์อื่นในเครื่องเดียวกัน ซึ่งอธิบายว่าทำไม path ที่ไม่มีนามสกุลถึงยังใช้ได้อยู่

เส้นแบ่งไม่ใช่ขนาดไฟล์

ที่ผมเห็นว่า "ไฟล์เล็กผ่าน ไฟล์ใหญ่ไม่ผ่าน" นั้นเป็นเรื่องบังเอิญของชุดไฟล์ที่ผมทดสอบ ไฟล์เล็กที่ผ่านคือพวกที่ path ตรงกับ route ของแอปพอดี ส่วนไฟล์ใหญ่ที่ไม่ผ่านคือพวกที่ไม่ตรง อย่าด่วนสรุปกฎจากตัวอย่างที่ยังไม่ครบ ผมเผาเวลาไปกับสมมติฐานเรื่องขนาดอยู่นาน ทั้งที่ควรเปิดไฟล์ .htaccess ดูตั้งแต่นาทีแรก

ส่วนเรื่อง HEAD ได้ 200 นั้น ผมไม่ได้ไล่ต่อจนถึงที่สุดว่าเป็นเพราะชั้นไหนกันแน่ระหว่าง cache ที่ขอบกับตัวเว็บเซิร์ฟเวอร์เอง สิ่งที่ผมเอามาใช้เป็นบทเรียนคือเลิกใช้ curl -I เป็นเครื่องมือเช็กว่าไฟล์มีอยู่ไหม ให้ยิง GET จริงแล้วดู status กับขนาดที่ได้กลับมาแทน คำสั่งที่ผมใช้ตอนนี้เป็นแบบนี้

# เช็กแบบที่เชื่อได้ — GET จริง แต่ทิ้ง body
curl -s -o /dev/null -w 'code=%{http_code} size=%{size_download} type=%{content_type}\n' \
     https://calc.example.com/engine.js

ทางแก้ที่หนึ่ง — ย้ายไปอยู่ใต้ subdomain

พอรู้ต้นเหตุแล้วทางแก้มีให้เลือกสามทาง ผมลองมาแล้วทั้งหมด เลยเล่าให้ครบว่าอันไหนเหมาะกับใคร ทั้งสามทางนี้ยังอยู่บน DirectAdmin เหมือนเดิม ไม่ต้องย้ายโฮสต์

ทางแรกและง่ายที่สุดคือย้ายไปอยู่ใต้ subdomain ครับ เพราะ docroot ของ subdomain เป็นคนละโฟลเดอร์กับของโดเมนหลัก ไม่มี .htaccess ของ Laravel นั่งทับอยู่ ผมทดสอบจริงบน subdomain ตัวหนึ่งด้วยไฟล์สี่ขนาด ได้ 200 ครบทุกตัวพร้อม content-type ถูกต้อง

# ทดสอบบน subdomain — ไฟล์ทุกขนาดผ่านหมด
for f in page.html app.js style.css shot.png; do
  curl -s -o /dev/null -w "$f -> %{http_code} %{size_download} %{content_type}\n" \
       https://demo.example.com/$f
done
# page.html -> 200 40118 text/html
# app.js    -> 200 30214 text/javascript
# style.css -> 200 20447 text/css
# shot.png  -> 200 1638400 image/png

ทางแก้ที่สอง — ยกเว้นไฟล์ static ก่อนถึงกฎ rewrite

ทางที่สองคือแก้ .htaccess ที่ docroot ให้ยกเว้นนามสกุลของไฟล์ static ก่อนจะถึงกฎ rewrite ของแอป วิธีนี้ใช้ได้ถ้าโดเมนหลักยังต้องรัน Laravel อยู่จริง ๆ และเราแค่อยากแทรก static เข้าไปบางส่วน

# แทรกไว้ "ก่อน" กฎ rewrite เดิม
RewriteEngine On
RewriteRule ^(assets|static)/ - [L]
RewriteCond %{REQUEST_URI} \.(?:js|css|png|jpe?g|svg|webp|woff2?|ico|json|map)$
RewriteRule ^ - [L]

ทางแก้ที่สาม — เสิร์ฟผ่าน php ตัวเล็ก

ทางที่สามคือเสิร์ฟผ่าน php ตัวเล็ก ๆ ซึ่งเป็นทางที่ผมใช้ชั่วคราวตอนโดเมนหลักยังแก้ไม่ได้ วิธีนี้อ่านไฟล์จากโฟลเดอร์ที่ front controller ไม่ยุ่งด้วย แล้วส่งออกเอง

<?php
// public_html/a.php — เสิร์ฟไฟล์ static ผ่านทาง .php ที่ front controller ไม่แตะ
$base = __DIR__.'/asset/';
$f    = $_GET['f'] ?? '';

// กัน path traversal: ยอมเฉพาะรูปแบบที่กำหนด และต้องอยู่ใต้ base จริงหลัง realpath
if (!preg_match('#^[A-Za-z0-9._/-]+$#', $f) || str_contains($f, '..')) {
    http_response_code(400); exit;
}
$path = realpath($base.$f);
if ($path === false || !str_starts_with($path, realpath($base).DIRECTORY_SEPARATOR)) {
    http_response_code(404); exit;
}

$types = ['js'=>'text/javascript','css'=>'text/css','png'=>'image/png',
          'svg'=>'image/svg+xml','json'=>'application/json','woff2'=>'font/woff2'];
$ext   = strtolower(pathinfo($path, PATHINFO_EXTENSION));

header('Content-Type: '.($types[$ext] ?? 'application/octet-stream'));
header('Content-Length: '.filesize($path));
header('Cache-Control: public, max-age=31536000, immutable');
readfile($path);

เลือกทางไหนดี

สามทางข้างบนแก้อาการได้หมดครับ คำถามที่เหลือคือของชิ้นนี้ควรจะอยู่บนโฮสต์เดิมต่อไปไหม ผมชั่งด้วยเกณฑ์เดียวคือ "ของชิ้นนี้ต้องพึ่งอะไรบนเครื่องนั้นบ้าง" ถ้าคำตอบคือไม่ต้องพึ่งอะไรเลย การดันให้มันอยู่ต่อคือการรับภาระที่ไม่ได้อะไรกลับมา

อยู่บน DirectAdmin ต่อ

เหมาะกับของที่ผูกกับโฮสต์เดิม

ไม่ต้องย้ายอะไร ใช้โดเมนกับใบรับรองเดิม แต่ต้องยอมรับว่าทุกไฟล์ต้องวิ่งผ่าน php หรือกฎ rewrite ที่ต้องดูแลต่อ และเวลาแก้ .htaccess ผิดทีคือทั้งโดเมนล่ม

ย้ายไปโฮสต์ static ที่ขอบ

เหมาะกับ static ล้วนที่ไม่มี backend

ไม่ต้องยุ่งกับเว็บเซิร์ฟเวอร์เลย ได้ CDN มาฟรีในตัว deploy ด้วยคำสั่งเดียว แต่ต้องย้าย DNS และต้องเรียนรู้กติกาของแพลตฟอร์มใหม่อีกชุดหนึ่ง

ทำไมสุดท้ายผมย้ายไปโฮสต์ที่ขอบ

ของชิ้นนี้เป็น static ล้วนจริง ๆ ไม่มีอะไรที่ต้องใช้ php เลยสักบรรทัด ผมเลยตัดสินใจว่าการดันมันให้อยู่บนเครื่องที่ต้องคอยระวัง .htaccess มันไม่คุ้ม ย้ายไปอยู่บนบริการโฮสต์ static ที่ขอบเครือข่ายจบกว่า

แต่มีของอย่างเดียวที่ต้องใช้ backend คือปุ่มแจ้งปัญหาที่ให้ผู้ใช้ส่งข้อความกับรูปเข้ามา ตัวแพลตฟอร์มที่เลือกมีทางให้ทำสองแบบ ผมลองแบบที่เป็นโฟลเดอร์ฟังก์ชันก่อนตามที่คู่มือแนะนำ แล้วไม่ผ่าน

อัปแล้วขึ้น แต่ endpoint ตอบ 405

ผมวางไฟล์ไว้ในโฟลเดอร์ functions/ ตามคู่มือ ตัว deploy รายงานว่าสำเร็จทุกอย่าง แต่เครื่องมือมันไม่ได้คอมไพล์ฟังก์ชันนั้นเลย มันอัปทั้งโฟลเดอร์ขึ้นไปเป็นไฟล์ static เฉย ๆ ผลคือเรียก endpoint แล้วได้ 405 ทั้งที่ log ของ deploy ไม่มีคำว่า error สักคำ นี่คือความล้มเหลวแบบเงียบอีกแบบหนึ่ง ที่ตัวชี้วัดความสำเร็จวัดคนละอย่างกับที่เราต้องการ

ทางที่ได้ผลคือใช้โหมด advanced ซึ่งใช้ไฟล์ worker ไฟล์เดียวที่รากของโฟลเดอร์ deploy แล้วเขียน routing เองทั้งหมด อะไรที่ไม่ใช่ path ของ endpoint ก็โยนกลับไปให้ตัวเสิร์ฟไฟล์ static จัดการ ตอน deploy จะเห็นบรรทัดยืนยันว่าคอมไพล์ worker สำเร็จ ซึ่งเป็นสัญญาณที่เชื่อได้จริง

// _worker.js — วางที่รากของโฟลเดอร์ที่ deploy
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === '/report' && request.method === 'POST') {
      const form  = await request.formData();
      const answer = form.get('cf-turnstile-response');

      const verify = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ secret: env['TURNSTILE_SECRET'], response: answer }),
      }).then(r => r.json());

      if (!verify.success) return new Response('bad token', { status: 403 });

      await sendMail(env, form);          // ยิงเมลผ่านผู้ให้บริการเมล
      return new Response(JSON.stringify({ ok: true }), {
        headers: { 'content-type': 'application/json' },
      });
    }

    // ที่เหลือปล่อยให้ตัวเสิร์ฟไฟล์ static ทำงานตามปกติ
    return env.ASSETS.fetch(request);
  },
};

กับดักตอน deploy ที่เสียเวลาแน่ถ้าไม่รู้

สามเรื่องนี้ผมเจอครบในวันเดียว เก็บไว้ให้คนที่จะทำตาม

  1. ตอนเพิ่มโดเมนของตัวเองเข้าโปรเจกต์ ระหว่างที่ระบบกำลังออกใบรับรองให้ จะมีช่วงหนึ่งที่เปิดเว็บแล้วได้ error ของชั้น proxy อย่าเพิ่งตกใจแล้วไปรื้อ DNS รอสักนาทีสองนาทีมันจะหายไปเอง
  2. หลัง deploy ทุกครั้งต้องสั่งล้าง cache ที่ขอบ ไม่งั้นตอนไล่ verify จะได้ของเก่ากลับมาแล้วนึกว่า deploy ไม่ขึ้น ผมโดนหลอกด้วยเรื่องนี้ไปสองรอบก่อนจะจำได้
  3. ตัวกันบอทที่วางไว้หน้าฟอร์ม ทดสอบแบบ end-to-end ด้วยสคริปต์ไม่ได้ เพราะหน้าที่ของมันคือกันสิ่งนี้พอดี ต้องเปิดเบราว์เซอร์จริงกดเองอย่างเดียว
# deploy แล้วล้าง cache ต่อทันทีในสองบรรทัด อย่าแยกจำ
npx wrangler pages deploy ./dist --project-name mysite --branch main --commit-dirty=true

curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
     -H "Authorization: Bearer $CF_TOKEN" \
     -H 'Content-Type: application/json' \
     --data '{"purge_everything":true}' | head -c 200
สรุปสิ่งที่ผมเอากลับมาใช้ทุกงาน

หนึ่ง — ยิง GET จริงตอนตรวจว่าไฟล์มีอยู่ อย่าใช้ HEAD เพราะสองเส้นทางนี้อาจถูกตัดสินคนละที่ สอง — เวลาเจออาการแปลกที่ docroot ให้เปิด .htaccess ดูก่อนเป็นอย่างแรก ก่อนจะไปสงสัยเรื่องขนาด สิทธิ์ หรือ CDN สาม — ถ้าของเป็น static ล้วน อย่าฝืนดันให้อยู่ในที่ที่มี front controller คุมอยู่ ย้ายมันออกมาให้พ้นตั้งแต่แรกจบกว่าเยอะ

ใครที่โดเมนหลักยังต้องรัน Laravel อยู่แล้วอยากแทรก static เข้าไปด้วย ทางแก้ที่สองน่าจะเหมาะที่สุดครับ ส่วนใครที่ของเป็น static ล้วนแบบผม ย้ายออกไปเลยไม่ต้องเสียดาย ที่เหลือก็ตามสะดวก ตรงไหนผมเล่าตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย