ข้ามไปยังเนื้อหา

สิ่งที่เซิร์ฟเวอร์ต้องมี

VPS เครื่องเดียวรันได้ทั้งเว็บ บนเครื่องนั้น Docker Compose จะรันคอนเทนเนอร์สามตัว คือแอป TomeCMS, PostgreSQL 17 และ SeaweedFS ซึ่งเก็บไฟล์มีเดียและรับคำสั่งแบบ S3 ส่วน TLS คุณต้องตั้ง reverse proxy ไว้ด้านหน้าเอง

ขั้นต่ำ แนะนำ
CPU 1 vCPU 2 vCPU
หน่วยความจำ 2 GB พร้อม swap 2 GB 4 GB
ดิสก์ SSD 25 GB SSD 50 GB และมากกว่านี้ถ้าคลังไฟล์ใหญ่
สถาปัตยกรรม amd64 หรือ arm64 amd64 หรือ arm64
ระบบ Linux 64 บิตที่มี systemd, Docker Engine พร้อมปลั๊กอิน Compose, Node.js 22.12 ขึ้นไป และ Git Ubuntu 24.04 LTS ซึ่งเป็นระบบที่ CI ใช้

ตัวติดตั้งแบบ managed รันด้วยสิทธิ์ root และต้องมี systemd 235 ขึ้นไปด้วย ส่วนการติดตั้งแบบ build จากซอร์สโค้ดด้วยสคริปต์ deploy ต้องการ Linux, Node.js และ Docker และต้องรันด้วยผู้ใช้ที่สั่ง docker ได้

ตัวเลขเหล่านี้มาจากการวัดโค้ดเวอร์ชัน 0.7.0 บนเว็บเปล่าที่ยังไม่มีเนื้อหา ไม่ใช่เว็บที่มีคนใช้งานจริง หลังรับคำขอไปไม่กี่ร้อยครั้ง แอปใช้หน่วยความจำราว 180 MB, PostgreSQL ราว 75 MB และ SeaweedFS ราว 90 MB

หน่วยความจำขึ้นสูงสุดตอน build การติดตั้งแบบ managed ดึง image ที่ build เสร็จแล้วมาใช้ และ build เองแค่ตัวอัปเดตที่มีขนาดเล็ก แต่การติดตั้งแบบ build จากซอร์สโค้ดจะ build image ของแอปบนเซิร์ฟเวอร์เอง และตอนอัปเกรดก็ build ไปพร้อมกับที่เว็บยังเปิดให้บริการอยู่ แค่ npm run build อย่างเดียวก็ใช้ไปราว 700 MB ทั้งที่มี cache อยู่แล้วและรัน npm ci เสร็จไปก่อน การ build ครั้งแรกบนเซิร์ฟเวอร์ใหม่อาจใช้มากกว่านั้นอีก เซิร์ฟเวอร์ 1 GB จึงไม่พอ และขั้นต่ำจึงเป็น 2 GB พร้อม swap

ตอนอัปโหลดภาพ เซิร์ฟเวอร์ต้องใช้หน่วยความจำเพิ่มอีกชั่วครู่เพื่อตรวจไฟล์ ส่วนนี้ยังไม่ได้วัด และอย่าลืมเผื่อให้ระบบปฏิบัติการกับ TLS proxy ด้วย

การติดตั้งแบบ managed ใช้น้อยกว่านั้น เพราะไม่ได้ build แอปบนเครื่อง เซิร์ฟเวอร์ทดสอบของเอกสารนี้มีหน่วยความจำ 1 GB, vCPU ที่ใช้ร่วมกันหนึ่งตัว และ swap 2 GB รันการติดตั้งแบบ managed ที่มีเนื้อหาจริงมาตั้งแต่ 1.0.1 และผ่านการอัปเดตทุกครั้งตั้งแต่นั้น ขณะที่เว็บเปิดใช้งานอยู่ มีหน่วยความจำว่างราว 400 MB และมีการใช้ swap swap จึงเป็นสิ่งที่ทำให้ 1 GB ใช้ได้ วัดบนเครื่องนั้นด้วย 1.3.0 หน้าแรกตอบได้ราว 17 request ต่อวินาทีโดยไม่มีครั้งไหนล้มเหลว เมื่อยิงพร้อมกันสี่ request ซึ่งพอสำหรับคนอ่านพร้อมกันหลายร้อยคนอย่างสบาย หรือเว็บที่มีคนเปิดหลายหมื่นหน้าต่อวัน เพียงพอสำหรับเว็บส่วนตัว ถ้าเว็บคนเข้าเยอะกว่านี้ หรือ build จากซอร์สโค้ด ให้ใช้ 2 GB

image ของ PostgreSQL กับ SeaweedFS รวมกันราว 1.1 GB และ image ของแอปอีกหลายร้อย MB การ build แต่ละครั้งทิ้ง cache ไว้ขนาดใกล้เคียงกัน ซึ่งเรียกพื้นที่คืนได้ด้วย docker builder prune

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

เว็บหนึ่งเว็บต้องมีสอง origin ที่เป็น HTTPS ทั้งคู่ และต้องชี้ DNS มาที่เซิร์ฟเวอร์ให้เรียบร้อยก่อนติดตั้ง

  • origin ของ CMS เช่น https://cms.example.com เป็นที่ที่ผู้อ่านและแอดมินเข้ามา
  • origin ของมีเดีย เช่น https://media.example.com เป็น endpoint S3 ที่ให้บริการไฟล์

มีเดียต้องมี origin แยก เพราะเบราว์เซอร์อัปโหลดไฟล์ตรงเข้า bucket และผู้อ่านก็โหลดภาพจากที่นี่ สคริปต์ deploy รับเฉพาะ HTTPS บนชื่อโฮสต์สาธารณะสำหรับทั้งสอง origin และจะไม่รับชื่อในเครื่องอย่าง localhost, IP ในวงภายใน หรือโดเมนสำหรับทดสอบ

ตั้ง DNS ไว้ก่อน เพราะ record ใหม่ต้องใช้เวลาสักพักกว่าจะกระจายไปถึงทุกที่ สคริปต์ไม่รอ DNS โดยตรวจแค่รูปแบบของที่อยู่ ส่วน Caddy ที่ prepare-vps.sh ตั้งให้จะขอใบรับรองซ้ำไปเรื่อย ๆ จนกว่าชื่อจะชี้มาที่เซิร์ฟเวอร์ ตัวช่วยตั้งค่าครั้งแรกที่ /install จะเปิดได้เมื่อชื่อชี้มาแล้ว

ที่ผู้ให้บริการ DNS ของโดเมน ซึ่งส่วนใหญ่คือที่ที่คุณจดโดเมนไว้ ให้เพิ่ม record ให้แต่ละชื่อ โดยใส่ค่าเป็น IPv4 สาธารณะที่ผู้ให้บริการ VPS ให้มา

ประเภท ชื่อ ค่า
A cms 203.0.113.10 ซึ่งคือที่อยู่ของเซิร์ฟเวอร์คุณ
A media ที่อยู่เดียวกัน
  • ชื่อ ผู้ให้บริการส่วนใหญ่ให้ใส่แค่ส่วนหน้าโดเมน คือ cms บางเจ้าให้ใส่ชื่อเต็ม cms.example.com จะให้ CMS อยู่ที่ตัวโดเมนเลยก็ได้ โดยใช้ชื่อ @ แต่มีเดียต้องมีชื่อแยกของตัวเองเสมอ
  • IPv6 เพิ่ม record AAAA ก็ต่อเมื่อเซิร์ฟเวอร์มี IPv6 ที่ใช้งานได้จริงและเปิดพอร์ต 80 กับ 443 ไว้ Let’s Encrypt ลอง IPv6 ก่อน ถ้า AAAA ชี้ไปที่ที่ไม่ตอบ อาจขอใบรับรองไม่ได้
  • record เก่า ลบ record A, AAAA หรือ CNAME อื่นของชื่อเดียวกันออก เช่น หน้าพักโดเมนของผู้ให้บริการ ชื่อที่ตอบเป็นเซิร์ฟเวอร์สองเครื่องจะได้ใบรับรองบ้างไม่ได้บ้าง
  • Cloudflare และ DNS ที่ทำ proxy ให้ ตั้งทั้งสอง record เป็น DNS only (เมฆสีเทาบน Cloudflare) เพื่อให้ Caddy คุยกับ Let’s Encrypt ได้โดยตรง คู่มือนี้ครอบคลุมเฉพาะแบบนี้
  • TTL ใช้ค่าเริ่มต้นของผู้ให้บริการได้เลย ชื่อใหม่จะตอบภายในไม่กี่นาที ส่วนชื่อที่เคยชี้ไปที่อื่นอาจยังตอบค่าเดิมอยู่ได้นานเท่ากับ TTL เดิม

ตรวจได้ด้วยคำสั่งนี้บนเครื่องของคุณเอง (บน Windows ใช้ nslookup)

Terminal window
dig +short cms.example.com
dig +short media.example.com

แต่ละบรรทัดควรแสดงแค่ที่อยู่ของเซิร์ฟเวอร์คุณ prepare-vps.sh ก็แสดงว่าทั้งสองชื่อชี้ไปที่ไหนในขั้น DNS แต่บอกไม่ได้ว่าเป็นเครื่องที่ถูกหรือไม่ เพราะผู้ให้บริการบางเจ้า เช่น AWS ไม่ได้ผูกที่อยู่สาธารณะไว้กับ interface ใดของเซิร์ฟเวอร์

origin ของ CMS ต้องเป็นชื่อโฮสต์ ใช้ IP ไม่ได้ เจ้าของเว็บเข้าสู่ระบบด้วย passkey ซึ่งผูกกับชื่อโฮสต์ของ CMS และเบราว์เซอร์ไม่ยอมสร้าง passkey ให้ IP ถ้าใช้ IP สาธารณะ สคริปต์ deploy จะผ่าน แต่ตัวช่วยตั้งค่าครั้งแรกจะบันทึก passkey ของเจ้าของเว็บไม่ได้

  • จดโดเมน โดเมนเดียวใช้ได้ทั้งสอง origin โดยตั้งเป็นสองชื่อภายใต้โดเมนนั้น เช่น cms. และ media. แบบนี้ใช้ต่อได้ยาว

  • ถ้าแค่ลองใช้ ให้ใช้ชื่อที่มีที่อยู่ของเซิร์ฟเวอร์อยู่ในตัว บริการอย่าง sslip.io ตอบ IP ที่เขียนไว้ในชื่อกลับมาให้ จึงไม่ต้องตั้ง DNS เอง และ Caddy ขอใบรับรองให้ชื่อแบบนี้ได้เหมือนชื่ออื่น ถ้าเซิร์ฟเวอร์อยู่ที่ 203.0.113.10

    Terminal window
    export TOME_CMS_PUBLIC_URL=https://cms.203-0-113-10.sslip.io
    export S3_ENDPOINT=https://media.203-0-113-10.sslip.io
  • ถ้าอยากดูหน้าตาก่อน รัน TomeCMS บนเครื่องตัวเองด้วย npm run dev:macos ซึ่งไม่ต้องใช้ทั้งโดเมนและ HTTPS

เว็บที่ตั้งไว้ลองย้ายไปโดเมนของตัวเองทีหลังได้ แต่ passkey ของเจ้าของเว็บผูกกับชื่อเดิม จึงเข้าสู่ระบบที่ชื่อใหม่ไม่ได้ ให้ชี้ชื่อใหม่มาที่เซิร์ฟเวอร์ รัน prepare-vps.sh แล้วตามด้วย ./scripts/deploy-vps.sh --force ด้วยที่อยู่ใหม่ จากนั้นเข้าสู่ระบบด้วยรหัสกู้คืน ซึ่งจะสร้าง passkey ให้ชื่อใหม่ เตรียมรหัสไว้ก่อนย้าย ส่วนที่อยู่ของมีเดียคำนวณใหม่ทุกครั้งที่แสดงหน้า ภาพจึงไปใช้ origin มีเดียใหม่เอง

proxy และใบรับรองเป็นส่วนที่คุณต้องเตรียมเอง เว้นแต่ใช้ prepare-vps.sh ตั้ง Caddy ให้ ตั้งให้แต่ละ origin ส่งต่อไปที่พอร์ตของตัวเองบนเซิร์ฟเวอร์

Origin ส่งต่อไปที่
CMS https://cms.example.com 127.0.0.1:4321
มีเดีย https://media.example.com 127.0.0.1:9000

การตั้งค่า proxy สองข้อมีผลกับการอัปโหลด ข้อแรก origin ของมีเดียต้องส่ง header Host ต่อไปตามเดิมโดยไม่แก้ เพราะที่อยู่สำหรับอัปโหลดแต่ละครั้งถูกเซ็นไว้กับชื่อโฮสต์นั้น ข้อที่สอง ต้องรับ request body ได้ถึง 25 MB ซึ่งเป็นขนาดเอกสารใหญ่สุดที่คลังไฟล์รับ (ภาพรับได้ถึง 8 MB) proxy บางตัวส่งชื่อโฮสต์ของตัวเองต่อไป หรือจำกัด body ไว้ที่ 1 MB ถ้าไม่ได้ตั้งค่าเพิ่ม nginx เป็นแบบนั้นทั้งสองข้อ

proxy ยังต้องบอกแอปด้วยว่าใครเป็นคนเชื่อมต่อ โดยตั้งหรือเติม address ที่มันเห็นลงใน header X-Forwarded-For Caddy ทำให้เอง ส่วน nginx ต้องตั้ง proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; แอปอ่านรายการสุดท้ายในนั้น ซึ่ง proxy เขียนเอง และเชื่อเฉพาะเมื่อการเชื่อมต่อมาจากฝั่ง proxy คือ address แบบ loopback หรืออยู่ใน 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 หรือ fc00::/7 ขีดจำกัดของการเข้าสู่ระบบ การกู้คืน การติดตั้งและการอัปเดตเริ่มอ่าน header นี้ตั้งแต่ 1.1.2 ขีดจำกัดของการค้นหาบทความตั้งแต่ 1.2.0 ส่วนตัวเลขผู้อ่านใน “สถิติ” อ่านมาตั้งแต่แรก

มีสองแบบที่ตั้งผิดแล้วเกิดผล proxy ที่ไม่ใส่ header นี้ทำให้ผู้เข้าชมทุกคนนับเป็น proxy ตัวเดียว เข้าสู่ระบบพลาดสิบครั้งหรือค้นหกสิบครั้งจากใครก็ได้จึงใช้โควตาของทุกคนหมด ส่วน proxy ที่ส่ง header ของผู้เข้าชมผ่านไปตรง ๆ ซึ่ง nginx ทำเมื่อไม่มีบรรทัดข้างบน เปิดให้ผู้เข้าชมคนนั้นเลือก address ที่ขีดจำกัดใช้นับเองได้ หลัง CDN proxy จะเห็นแค่ address ของ CDN เว้นแต่ตั้งให้เชื่อ header ของ CDN ขีดจำกัดจึงนับ address ของ CDN แทน

Compose ผูก PostgreSQL (5432), SeaweedFS (9000) และแอป (4321) ไว้กับ 127.0.0.1 เท่านั้น จากภายนอกจึงเข้าถึงได้ผ่าน proxy ทางเดียว ไฟร์วอลล์เปิดแค่ SSH, HTTP และ HTTPS ก็พอ

สคริปต์ติดตั้งไม่แตะไฟร์วอลล์และไม่ขอใบรับรองให้ แต่ prepare-vps.sh ทำได้ทั้งสองอย่าง โดยเปิดใน ufw แค่ SSH, 80 และ 443 และให้ Caddy ขอใบรับรองเอง ถ้าไม่ใช้สคริปต์นี้ ทั้งสองอย่างคุณต้องตั้งเอง