← บทความทั้งหมด
คู่มือ25 ต.ค. 2569· อ่าน 8 นาที· Kanawoot K.

รัน Easy CMS หลาย server ด้วย Docker และ Postgres

Easy CMS สอง instance หลัง load balancer บน Postgres และ bucket เดียวกันด้วย Docker Compose ปิดตัวไหนเว็บก็ยังตอบ

server เดียวพอสำหรับเว็บส่วนใหญ่ แต่ server ตัวที่สองทำให้ deploy ได้โดยไม่มี downtime และเว็บยังทำงานเมื่อ server ตัวหนึ่งล่ม คู่มือนี้ตั้ง Easy CMS สอง instance ด้วย Docker Compose: image อย่างเป็นทางการ, Postgres, bucket สำหรับไฟล์ upload และ Caddy ข้างหน้า

คู่มือนี้ใช้ standalone server (easy-cms serve) แอป Next.js หรือ Nuxt ทำแบบเดียวกันได้ โดยรัน image ของแอปคุณหลายตัวแทน

สิ่งที่ต้องมี

  • Docker ที่มี Compose

  • bucket แบบ S3 และ key: S3, Cloudflare R2 หรือ MinIO เพราะเมื่อมีสอง server ไฟล์ upload อยู่บนดิสก์ของ server ไม่ได้

  • โปรเจกต์ที่มี easy-cms.config.ts และ migration ใน easy-cms/migrations (รัน easy-cms migrate:create init ครั้งเดียวในช่วงพัฒนา)

1. Config

ทุกค่ามาจาก environment ทั้งสอง instance จึงใช้ image เดียวกัน:

import { defineConfig } from '@easy-cms/core'
import { postgres } from '@easy-cms/db-postgres'
import { s3Storage } from '@easy-cms/storage-s3'

export default defineConfig({
  secret: process.env.EASY_CMS_SECRET as string,
  serverURL: process.env.SERVER_URL,
  db: postgres({ url: process.env.DATABASE_URL as string, max: 5 }),
  upload: {
    storage: s3Storage({
      bucket: process.env.S3_BUCKET as string,
      region: process.env.S3_REGION ?? 'auto',
      endpoint: process.env.S3_ENDPOINT,
      accessKeyId: process.env.S3_ACCESS_KEY_ID,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
    }),
  },
  collections: [
    { slug: 'posts', drafts: true, schedule: true, fields: [{ name: 'title', type: 'text' }] },
  ],
})

2. Image

image อย่างเป็นทางการมี Easy CMS และ adapter อยู่แล้ว image ของคุณเพิ่มแค่ config และ migration:

FROM ghcr.io/maritonx/easy-cms:1
COPY easy-cms.config.ts ./
COPY easy-cms ./easy-cms

3. Compose

compose.yaml รัน Postgres, service migrate ที่รันครั้งเดียว, สอง instance และ load balancer:

x-app: &app
  build: .
  environment:
    EASY_CMS_SECRET: ${EASY_CMS_SECRET:?set EASY_CMS_SECRET in .env}
    SERVER_URL: ${SERVER_URL:-http://localhost:8080}
    DATABASE_URL: postgres://cms:${POSTGRES_PASSWORD:?}@db:5432/cms
    S3_BUCKET: ${S3_BUCKET:?}
    S3_REGION: ${S3_REGION:-auto}
    S3_ENDPOINT: ${S3_ENDPOINT:-}
    S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:?}
    S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?}
    # Migrations run once, in the migrate service.
    EASY_CMS_MIGRATE: "false"
  depends_on:
    migrate:
      condition: service_completed_successfully
  restart: unless-stopped

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: cms
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
      POSTGRES_DB: cms
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cms -d cms"]
      interval: 5s
      retries: 10
    restart: unless-stopped

  migrate:
    <<: *app
    entrypoint: ["easy-cms", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  app1: *app
  app2: *app

  lb:
    image: caddy:2
    ports:
      - "8080:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    depends_on: [app1, app2]
    restart: unless-stopped

volumes:
  pgdata:

instance ทั้งสองรอ migrate เสร็จก่อน ฐานข้อมูลจึงเป็นเวอร์ชันล่าสุดก่อนตัวไหนจะรับ request และ migration รันครั้งเดียว ไม่ใช่รันในทั้งสองตัว

4. Load balancer

Caddy ส่ง request ให้ทั้งสอง instance สลับกัน และหยุดส่งให้ตัวที่ /readyz ไม่ตอบ 200:

:80 {
	reverse_proxy app1:4000 app2:4000 {
		lb_policy round_robin
		health_uri /readyz
		health_interval 5s
	}
}

ไม่ต้องใช้ sticky session เพราะ session, การจำกัดการ login และคิวงานอยู่ใน Postgres instance ไหนก็ตอบ request ไหนก็ได้

5. เริ่มระบบ

ใส่ค่าลับใน .env ข้าง compose.yaml:

printf 'EASY_CMS_SECRET=%s\nPOSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 16)" > .env

เพิ่ม S3_BUCKET, S3_ACCESS_KEY_ID และ S3_SECRET_ACCESS_KEY ถ้าใช้ R2 หรือ MinIO ให้เพิ่ม S3_ENDPOINT ด้วย แล้วรัน:

docker compose up -d --build
docker compose exec app1 easy-cms admin:create --email you@example.com

เปิด http://localhost:8080/admin แล้ว login ไฟล์ upload ไปอยู่ใน bucket ไฟล์ที่ upload ตอนที่ instance หนึ่งตอบอยู่ จึงเปิดผ่านอีกตัวได้

6. ลองดู

ปิด instance หนึ่งแล้วใช้ admin ต่อ:

docker compose stop app1

ภายในไม่กี่วินาที Caddy จะรู้ว่า app1 ไม่ตอบ /readyz และส่งทุก request ไปที่ app2 เปิดกลับด้วย docker compose start app1

deploy เวอร์ชันใหม่

build ใหม่ migrate ครั้งเดียว แล้วเปลี่ยน instance ทีละตัว จะมีตัวหนึ่งตอบอยู่เสมอ:

docker compose build
docker compose run --rm migrate
docker compose up -d --no-deps app1
docker compose up -d --no-deps app2

ก่อนขึ้นระบบจริง

  • ใส่ HTTPS ข้างหน้า: ใส่ domain ใน Caddyfile แทน :80 แล้ว Caddy จะขอ certificate ให้เอง และตั้ง SERVER_URL เป็นที่อยู่จริง

  • ให้จำนวน instance × max น้อยกว่า max_connections ของ Postgres พอสมควร และใส่ PgBouncer เมื่อมีหลาย instance ดูConnection

  • เปิด log แบบ JSON (EASY_CMS_LOG_FORMAT=json) ถ้าเก็บ log และ backup ทั้งฐานข้อมูลและ bucket

หน้าสถาปัตยกรรมสำหรับ production มีทุกอย่างที่รองรับ และคู่มือ Docker ครอบคลุมกรณี server เดียว