Deploy static site ขึ้น VPS แล้วผู้ใช้ไม่เห็นของใหม่ — ETag, rsync และ cache ที่คุณไม่เคยตั้ง

Deploy static site ขึ้น VPS แล้วผู้ใช้ไม่เห็นของใหม่ — ETag, rsync และ cache ที่คุณไม่เคยตั้ง

TL;DR — build hash ให้แล้ว มี ETag แล้ว ยังไม่พอ. เพราะ (1) web server ส่วนใหญ่ ไม่ส่ง Cache-Control เลย → เบราว์เซอร์ใช้ heuristic เดาเอง แล้วไม่ถามด้วยซ้ำ → ETag ที่มีอยู่ไม่ถูกใช้ (2) ETag ของ Caddy คำนวณจาก mtime + size ไม่ใช่เนื้อไฟล์ (3) rsync -a ก็ตัดสิน “เปลี่ยนไหม” ด้วย mtime + size เหมือนกัน → เนื้อต่างแต่ metadata เท่ากัน = ข้ามไฟล์ไปเลย. ทางแก้: Cache-Control: no-cache (ไม่ใช่ no-store)

  • immutable เฉพาะไฟล์ที่มี hash + rsync -c

ปัญหา: ทุกอย่างเขียว แต่ผู้ใช้ยังเห็นเว็บเวอร์ชันเก่า

สถานการณ์ที่เจอบ่อยมากกับ SPA ที่ deploy ขึ้น VPS เอง (Caddy/nginx เสิร์ฟไฟล์ static):

  • CI เขียว ✅
  • rsync ขึ้นเครื่องสำเร็จ ✅
  • เปิดเว็บใน incognito → เห็นของใหม่ ✅
  • แต่ผู้ใช้ที่เคยเข้ามาก่อน ยังเห็นของเก่า ❌ (บางคนเจอจอขาว)

คำถามแรกที่ทุกคนถาม — และผมก็ถาม — คือ “Vite ใส่ hash ในชื่อไฟล์ให้แล้วไม่ใช่เหรอ? แล้วมันมี ETag อยู่แล้วด้วย ทำไมยังพัง”

คำตอบอยู่ที่ 3 ชั้นที่ต่างคนต่างตัดสินใจ และทั้งสามชั้นตัดสินจากข้อมูลที่อ่อนกว่าที่เราคิด

ชั้นที่ 1: hash ครอบไม่ถึง entry point

Vite ใส่ content hash ในชื่อไฟล์ของ asset:

dist/assets/index-LThigBuy.js
dist/assets/index-BM4B51Ye.css

เนื้อเปลี่ยน → ชื่อเปลี่ยน → cache ยาวแค่ไหนก็ปลอดภัย เพราะ URL ใหม่ = ของใหม่เสมอ

แต่ index.html hash ไม่ได้ — มันคือจุดเริ่มที่เบราว์เซอร์ต้องขอจาก URL คงที่ ถ้า hash แล้วจะไม่มีใครรู้ว่าต้องขออันไหน. ไฟล์ config ที่ generate ตอน deploy (/config.js) ก็เหตุผลเดียวกัน

ไฟล์ที่ URL คงที่แต่เนื้อเปลี่ยนได้ คือจุดที่ cache เป็นปัญหา — ไม่ใช่ asset

ชั้นที่ 2: server ไม่ส่ง Cache-Control → เบราว์เซอร์เดาเอง

ตรงนี้ผมวัดจริง ไม่ได้เดา — Caddy file_server เปล่า ๆ:

curl -sSI http://localhost:8899/index.html
HTTP/1.1 200 OK
Etag: "dkhxan6gc2a52n"
Last-Modified: Thu, 06 Aug 2026 14:16:08 GMT

มี ETag มี Last-Modifiedแต่ไม่มี Cache-Control เลยสักตัว

นี่คือหัวใจของเรื่อง เพราะ HTTP บอกว่า: ไม่มี Cache-Control และไม่มี Expires → เบราว์เซอร์ใช้ heuristic caching ได้ (RFC 9111 §4.2.2) ซึ่งโดยทั่วไป ≈ 10% ของอายุไฟล์ (now − Last-Modified)

ระหว่างที่ยังถือว่า “สด” เบราว์เซอร์เสิร์ฟจาก cache โดยไม่ยิง request เลย

ETag ไม่ได้ถูกใช้ — ETag ช่วยได้ต่อเมื่อเบราว์เซอร์ ยอมถาม

แล้วมันแย่กว่าที่คิด เพราะ rsync -a เก็บ mtime ของตอน build

rsync -a (มี -t อยู่ข้างใน) รักษา mtime ของต้นทาง — และต้นทางคือ artifact ที่ build ไว้

# ไฟล์ที่ build ไว้ 30 วันก่อน แล้วเพิ่ง deploy วันนี้
Last-Modified: Tue, 30 Jun 2026 17:00:00 GMT

⇒ heuristic ให้ freshness ≈ 3 วัน ⇒ ผู้ใช้ได้ index.html เก่าได้ถึง 3 วันโดยไม่ถามเซิร์ฟเวอร์

และ index.html เก่าชี้ไปที่ asset hash เดิม ซึ่งถ้า deploy ใช้ rsync --delete ไฟล์นั้นถูกลบไปแล้วจอขาว

ชั้นที่ 3: ETag ไม่ใช่ hash ของเนื้อไฟล์

ข้อนี้เป็นข้อที่ทำให้ผมต้องแก้แผนทั้งหมด. ลองวัดว่า Caddy คำนวณ ETag จากอะไร:

printf 'AAAA' > web/a.js  &&  touch -d '2026-01-01' web/a.js
# → Etag: "dfck9c65szk04"

printf 'BBBB' > web/a.js  &&  touch -d '2026-01-01' web/a.js   # เนื้อต่าง ขนาดเท่า mtime เท่า
# → Etag: "dfck9c65szk04"   ← เหมือนเดิม!

printf 'AAAA' > web/a.js  &&  touch -d '2026-06-06' web/a.js   # เนื้อเดิม mtime ใหม่
# → Etag: "dj19ybnftvk04"   ← เปลี่ยน

printf 'AAAAAA' > web/a.js && touch -d '2026-01-01' web/a.js   # ขนาดต่าง
# → Etag: "dfck9c65szk06"   ← ลงท้าย 04 → 06

อ่านค่าออกมาได้เลยว่า ETag = <mtime ฐาน36><size ฐาน36>

เปลี่ยนอะไรETag เปลี่ยนไหม
เนื้อ (ขนาดเท่าเดิม, mtime เท่าเดิม)ไม่เปลี่ยน
mtime✅ เปลี่ยน
ขนาด✅ เปลี่ยน

⚠️ นี่ไม่ใช่บั๊กของ Caddy — nginx ก็ใช้ mtime + content_length เหมือนกัน. มันคือ การแลกที่จงใจ: อ่าน stat() ราคาถูกกว่าอ่านทั้งไฟล์มา hash ทุก request. แต่แปลว่า ETag เป็น proxy ที่ อ่อน ของคำถาม “ไฟล์เปลี่ยนไหม” ไม่ใช่ตัวยืนยันเนื้อ

ชั้นที่ 3.5: rsync ก็ตัดสินด้วยเกณฑ์เดียวกัน — พังตั้งแต่ก่อนถึง cache

พอรู้ว่า ETag มาจาก mtime + size คำถามถัดมาคือ “แล้ว mtime จะเปลี่ยนแน่ไหม” ซึ่งพาไปเจอปัญหาที่อยู่ก่อนหน้าชั้น cache ด้วยซ้ำ

rsync ค่าเริ่มต้นตัดสินว่าไฟล์เปลี่ยนไหมจาก size + mtime (quick check) ⇒ เนื้อต่างแต่สองค่านี้เท่ากัน = rsync ข้ามไฟล์ไปเลย ไม่ copy

แล้ว index.html เสี่ยงข้อนี้เชิงโครงสร้าง เพราะ Vite ใช้ hash ยาวคงที่:

<script type="module" src="/assets/index-LThigBuy.js"></script>
<script type="module" src="/assets/index-Xy7Qm2Kp.js"></script>

เนื้อต่าง จำนวนไบต์เท่ากันเป๊ะ — และนี่คือกรณีปกติ ทุก build ที่แก้โค้ด ไม่ใช่ edge case

index.html เหลือ mtime เป็นตัวแปรเดียว ที่กันไม่ให้พลาด

ทุกวันนี้มันยังไม่พังเพราะ build คนละครั้งได้ mtime คนละค่า — แต่นั่นคือ “บังเอิญได้ตัวแปร ที่ยังต่าง” ไม่ใช่ “กลไกรับประกัน” และของแบบนี้จะพังวันที่มีคนเพิ่ม reproducible build (SOURCE_DATE_EPOCH) หรือ normalize timestamp ในภาพ Docker

ทางแก้: 3 บรรทัด

1. no-cache — ไม่ใช่ no-store

ชื่อสองอันนี้ชวนเข้าใจผิดที่สุดใน HTTP:

directiveความหมายจริง
no-storeห้ามเก็บลง cache เลย → ทุกครั้งโหลดใหม่ทั้งไฟล์
no-cacheเก็บได้ แต่ต้องถามก่อนใช้ทุกครั้ง

สำหรับ index.html / config.js เราต้องการอันหลัง — ได้ความถูกต้องเท่ากัน แต่จ่ายแค่ 304 · 0 bytes แทนที่จะโหลดใหม่ทั้งไฟล์:

curl -sS -o /dev/null -w '%{http_code} %{size_download}\n' \
  -H "If-None-Match: \"dkhxan6gc2a52n\"" http://localhost:8899/index.html
# → 304 0

2. matcher กลับด้าน — immutable เฉพาะที่มี hash

พอ no-cache ราคาถูก เราก็ไม่ต้องไล่ enumerate path ทีละอันว่าอันไหนต้อง revalidate กลับด้านเลย: cache ยาวเฉพาะของที่มี hash · ที่เหลือ revalidate หมด

app.example.com {
	root * /srv/web
	encode gzip zstd

	# มี hash ในชื่อ ⇒ เนื้อเปลี่ยน = ชื่อเปลี่ยน ⇒ ปลอดภัยที่จะ cache ยาวสุด
	@immutable path /assets/*
	header @immutable Cache-Control "public, max-age=31536000, immutable"

	# ที่เหลือ = URL คงที่แต่เนื้อเปลี่ยนได้ → ถามก่อนใช้เสมอ
	@revalidate not path /assets/*
	header @revalidate Cache-Control "no-cache"

	try_files {path} /index.html
	file_server
}

ข้อดีที่ได้ฟรีจากการกลับด้าน: SPA route ครอบด้วย. /activities/123 ถูก try_files เสิร์ฟเป็น index.html เหมือนกัน — ถ้าเราไล่ระบุ path เอง อันนี้จะหลุด

วัดยืนยัน:

pathCache-Control
/no-cache
/index.htmlno-cache
/config.jsno-cache
/activities/abcno-cache
/assets/index-abc123.jspublic, max-age=31536000, immutable

3. rsync -c — ตัดสินจากเนื้อ ไม่ใช่ metadata

- rsync -az --delete dist/ deploy@vps:/srv/web/
+ rsync -azc --delete dist/ deploy@vps:/srv/web/

-c ให้ rsync เทียบ checksum ของเนื้อไฟล์ แทน size + mtime ราคาถูกมากสำหรับ dist ของ SPA (ไม่กี่สิบไฟล์ ไม่กี่ MB) และมันตัดปัญหาทั้งคลาสทิ้ง

สายความน่าเชื่อถือที่ได้กลายเป็น:

checksum (rsync) → ไฟล์ถูก copy → mtime เปลี่ยน → ETag เปลี่ยน → เบราว์เซอร์ได้ 200 ของใหม่

แทนที่จะเป็น “หวังว่า mtime จะบังเอิญต่าง”

เคสที่ควรไล่ให้ครบก่อนปิดเรื่อง

สถานการณ์ขนาดmtimeผล
build ใหม่ (แก้โค้ด)เท่าเดิมต่าง✅ copy · ETag เปลี่ยน
deploy tag เดิมซ้ำเท่าเท่า✅ ข้ามถูกแล้ว — เนื้อเหมือนกันจริง
rollback ไป build เก่าเท่าย้อนหลัง✅ rsync เทียบว่า “ต่าง” ไม่ใช่ “ใหม่กว่า” ⇒ copy

ข้อสุดท้ายสำคัญกว่าที่คิด — ระบบ deploy จำนวนมากพลาดตรงนี้เพราะไปใช้ --update (-u) ซึ่ง ข้ามไฟล์ที่ปลายทางใหม่กว่าrollback ไม่ทำงาน โดยไม่มี error

ถ้าย้ายไป S3 / CloudFront / R2 ล่ะ

หลักการเดิมทั้งหมด แต่กลไกเปลี่ยน — จดไว้กันลืม:

เรื่องVPS (Caddy/nginx)object storage + CDN
Cache-Controlheader ใน configตั้งเป็น metadata ตอน upload (--cache-control)
revalidateETag ของ serverETag ของ S3 = MD5 ของ object (แข็งแรงกว่า) แต่ CDN ต้องตั้ง cache behavior แยก
ของเก่าค้างที่ edgeไม่มี edgeต้อง invalidate ตอน deploy ไม่งั้นค้างจนหมด TTL
SPA routingtry_filesCloudFront Function / custom error 404→/index.html
security headersimport ใน configresponse-headers policy — ไม่ย้ายตาม = หายเงียบ

ข่าวดีคือ ชั้นที่ 1 ไม่ขยับเลย: แยกไฟล์ที่มี hash ออกจากไฟล์ที่ URL คงที่ เป็นการออกแบบที่ไม่ผูกกับ host — ย้ายเมื่อไรก็ยังใช้ได้

สรุป

สามชั้นนี้ตัดสินใจแยกกัน และทุกชั้นตัดสินจากข้อมูลที่อ่อนกว่าที่เราคิด:

  1. build hash ให้เฉพาะ asset — entry point (index.html, config.js) ไม่ได้ hash
  2. server ส่ง ETag แต่ไม่ส่ง Cache-Control → เบราว์เซอร์เดาเอง → ETag ไม่ถูกใช้ · และ ETag เองก็มาจาก mtime + size ไม่ใช่เนื้อ
  3. rsync ก็ตัดสินด้วย mtime + size เหมือนกัน → พังตั้งแต่ยังไม่ถึงชั้น cache

แก้ด้วย 3 อย่าง: no-cache สำหรับไฟล์ที่ URL คงที่ · immutable เฉพาะไฟล์ที่มี hash · rsync -c ให้ตัดสินจากเนื้อจริง

บทเรียนที่กว้างกว่านั้น: “มันมี ETag อยู่แล้ว” ไม่ได้แปลว่ามีใครใช้มัน และกลไก ที่ดู “ควรจะทำงาน” ควรถูกวัด ไม่ใช่ถูกสันนิษฐาน — ทุกตัวเลขในบทความนี้มาจาก curl -I กับ Caddy จริง ซึ่งใช้เวลาไม่ถึงสิบนาที แต่เปลี่ยนคำตอบไป 2 รอบ

Supawut Thomas

Supawut Thomas

Software Developer

มีประสบการณ์พัฒนา Software ระดับ Enterprise มากกว่า 10 ปี ผ่านงานจริงหลากหลายโปรเจกต์องค์กร — เชื่อว่าความรู้ที่ดีที่สุดคือความรู้ที่มาจากประสบการณ์จริง และอยากแบ่งปันสิ่งเหล่านั้นให้เพื่อน Developer ทุกคนได้นำไปพัฒนาตัวเองได้ดีขึ้นในทุกๆ วัน