Docker สำหรับผู้เช่าหลายราย โดยไม่ให้ความล้มเหลวลามโดยไม่ตั้งใจ
บทความต่อยอดฉบับปัจจุบันที่เน้นการใช้งานจริง โดยแปลงเอกสารจากผู้ให้บริการให้เป็นการควบคุมงาน การตัดสินใจย้ายระบบ และเกณฑ์การปล่อยระบบที่ตรวจสอบได้
สิ่งที่เปลี่ยนในปี 2026
เรากลับมาทบทวนหัวข้อนี้เพราะขอบเขตการดำเนินงานเปลี่ยนไป หลักการที่ใช้ได้ในระยะยาวยังคงมีคุณค่า แต่เวอร์ชันปัจจุบันทำให้ทางลัดบางอย่างในอดีตไม่ครบถ้วนหรือมีความเสี่ยง บทความนี้เริ่มจากเอกสารทางการที่มีอยู่ ณ วันที่ 14 กรกฎาคม 2026 แยกข้อเท็จจริงออกจากการตัดสินใจเฉพาะระบบ และถือว่าการแก้ไขค่ากำหนดทุกครั้งเป็นการเปลี่ยนแปลงระบบใช้งานจริงที่ต้องควบคุม
แหล่งข้อมูลปฐมภูมิปัจจุบัน
วิธีใช้บทความปรับปรุง
เริ่มจากอ่านแหล่งข้อมูล บันทึกเวอร์ชันที่นำขึ้นใช้งานจริง และกำหนดผลลัพธ์ที่สังเกตได้ก่อนแก้ไขค่ากำหนด ทดสอบการเปลี่ยนแปลงที่เล็กที่สุดและย้อนกลับได้ในสภาพแวดล้อมที่เป็นตัวแทนของระบบจริง เกณฑ์ยอมรับไม่ใช่การรันคำสั่งสำเร็จ แต่คือสถานะบริการ ความสมบูรณ์ของข้อมูล เวลาหน่วง ขอบเขตความปลอดภัย และเวลาที่ใช้ในการย้อนกลับการเปลี่ยนแปลง หากคู่มือเดิมมีลำดับการวินิจฉัยที่ยังมีประโยชน์ เราจะคงไว้ด้านล่างเป็นพื้นฐานการดำเนินงาน แต่ตัวอย่างที่ขึ้นกับเวอร์ชันยังต้องตรวจเทียบกับเอกสารปัจจุบัน
- ซ้อน reverse proxy: สถาปัตยกรรมใช้งานจริง
- กู้คืนบริการที่ติดตั้งและดูแลเอง: กลยุทธ์สำรองข้อมูลของเรา
- คอนเทนเนอร์ Docker กว่าร้อยตัว: ตรวจสถานะรายเดือน
พื้นฐานการดำเนินงาน
วิธีสร้างสภาพแวดล้อมการพัฒนาที่รองรับผู้เช่าหลายรายจากเทมเพลต มี 16 บริการในคอนเทนเนอร์ ทำงานออฟไลน์ได้ และยังเข้าถึงออนไลน์ผ่านการกำหนดเส้นทางตามซับโดเมน ไฟล์กำหนดค่า สคริปต์ และตัวอย่างคำสั่งด้านล่างนำมาจากคู่มือต้นฉบับ และได้แก้ไขในจุดที่จำเป็น
การจัดการสภาพแวดล้อมการพัฒนาสำหรับลูกค้าหลายรายมักต้องเลือกระหว่างการตั้งค่าด้วยมือที่ซับซ้อนกับโซลูชันคลาวด์ราคาแพง การนำระบบขึ้นใช้งานด้วยมือใช้เวลาและเกิดข้อผิดพลาดง่าย แพลตฟอร์มคลาวด์สะดวก แต่ทำให้ผูกติดกับผู้ให้บริการและมีค่าใช้จ่ายต่อเนื่องที่เพิ่มตามการใช้งาน
วันนี้เราจะอธิบายวิธีสร้างชุดระบบพัฒนาที่รองรับผู้เช่าหลายรายและขยายขนาดได้ โดยแยกสภาพแวดล้อมลูกค้าอย่างรัดกุมและนำระบบขึ้นใช้งานอัตโนมัติได้ พร้อมควบคุมโครงสร้างพื้นฐานทั้งหมด แนวทางนี้ต่อยอดจากหลักการติดตั้งและดูแลระบบเองของเรา เช่นเดียวกับที่เราแสดงวิธีติดตั้งและดูแล n8n เองเพื่อทำเวิร์กโฟลว์ให้เป็นอัตโนมัติ และนำ Windmill ขึ้นใช้งานด้วย Docker เพื่อควบคุมการดำเนินงานทั้งหมด
เครื่องมือที่เราใช้
เริ่มจากทำความเข้าใจบทบาทของแต่ละส่วนในสถาปัตยกรรมครบชุดที่มี 16 คอนเทนเนอร์:
Docker: พื้นฐานการใช้คอนเทนเนอร์
Docker ให้การแยกและความสม่ำเสมอที่จำเป็นสำหรับสภาพแวดล้อมหลายผู้เช่า ลูกค้าแต่ละรายมีคอนเทนเนอร์ของตนเองที่ใช้ค่ากำหนดเหมือนกัน ซึ่งช่วยให้สิ่งที่ทำงานได้ในสภาพแวดล้อมพัฒนาทำงานได้เมื่อใช้งานจริงด้วย เปรียบเหมือนมีเซิร์ฟเวอร์หลายเครื่องที่แยกจากกันบนฮาร์ดแวร์เดียวกัน แต่ยังใช้ CPU หน่วยความจำ ดิสก์ และเคอร์เนลร่วมกัน
ข้อได้เปรียบสำคัญคือการแยกลูกค้าอย่างรัดกุม ข้อมูล ค่ากำหนด และการปรับแต่งของลูกค้าแต่ละรายอยู่ในคอนเทนเนอร์ เครือข่าย และวอลุ่มที่แยกกัน จึงไม่รบกวนของลูกค้ารายอื่น เรื่องนี้สำคัญเมื่อดูแลลูกค้าธุรกิจหลายรายที่มีข้อกำหนดและความต้องการด้านความปลอดภัยต่างกัน
Traefik: reverse proxy และตัวกระจายโหลดอัจฉริยะ
Traefik ควบคุมทราฟฟิกอย่างชาญฉลาด โดยส่งคำขอไปยังสภาพแวดล้อมลูกค้าที่ถูกต้องตามชื่อโดเมนโดยอัตโนมัติ แทนที่จะตั้งกฎ Apache หรือ Nginx ที่ซับซ้อนด้วยมือ Traefik อ่านป้ายกำกับจากคอนเทนเนอร์ Docker และตั้งเส้นทางให้อัตโนมัติ
ลองมอง Traefik เป็นพนักงานต้อนรับที่รู้ว่าผู้มาเยือนแต่ละคน (คำขอ) ต้องไปสำนักงานใด (คอนเทนเนอร์) โดยคุณไม่ต้องบอกทางทุกครั้ง ในระบบของเรา Traefik จัดการการสิ้นสุดการเชื่อมต่อ SSL ค้นพบบริการอัตโนมัติ และมีแดชบอร์ดเฝ้าติดตามระบบอย่างละเอียด
Cloudflare Tunnels: เข้าถึงจากภายนอกอย่างปลอดภัย
Cloudflare Tunnels ช่วยให้เข้าถึงชุดระบบพัฒนาในเครื่องได้อย่างปลอดภัย โดยไม่ต้องตั้งไฟร์วอลล์ที่ซับซ้อนหรือใช้ VPN โดเมนของลูกค้าแต่ละรายมีช่องทางเชื่อมต่อของตนเอง ทำให้เส้นทางเข้าถึงจากภายนอกของลูกค้าแต่ละรายแยกกัน
ข้อดีคือสภาพแวดล้อมพัฒนายังอยู่ในเครื่องและปลอดภัย แต่ลูกค้าเข้าถึงบริการของตนได้จากทุกที่ด้วยการยืนยันตัวตนที่เหมาะสม คล้ายกับการตั้งค่าเข้าถึงจากภายนอกอย่างปลอดภัยในคู่มือติดตั้ง n8n ของเรา
ชุดบริการครบชุด: ทุกสิ่งที่ลูกค้าต้องการ
ชุดระบบหลายผู้เช่าของเรามีบริการหลักสี่หมวดใน 16 คอนเทนเนอร์ต่อลูกค้า:
การทำเวิร์กโฟลว์ให้เป็นอัตโนมัติและตรรกะธุรกิจ:
- n8n: แพลตฟอร์มครบชุดสำหรับทำเวิร์กโฟลว์และกระบวนการธุรกิจให้เป็นอัตโนมัติ
- Authentik: การเข้าสู่ระบบครั้งเดียว (SSO) และการจัดการตัวตนระดับองค์กร (3 คอนเทนเนอร์: เซิร์ฟเวอร์ ตัวประมวลผลงาน แคช Redis)
ฐานข้อมูลและบริการ backend:
- PostgreSQL: ฐานข้อมูล backend ที่มั่นคง รองรับทุกบริการพร้อมจัดการกลุ่มการเชื่อมต่ออย่างมีประสิทธิภาพ
- Supabase Stack: บริการ backend ครบชุดผ่าน 6 คอนเทนเนอร์เฉพาะทาง (Studio, Meta, Auth, REST API, Realtime, Kong Gateway)
- NocoDB: ส่วนติดต่อฐานข้อมูลที่ไม่ต้องเขียนโค้ดสำหรับจัดการข้อมูลลูกค้า
AI และความสามารถอัจฉริยะ:
- Ollama: โมเดลภาษา AI ในเครื่องที่เร่งการประมวลผลด้วย GPU เพื่อระบบอัตโนมัติอัจฉริยะ
- Qdrant (แบบเลือกใช้): ฐานข้อมูลเวกเตอร์สำหรับเวิร์กโฟลว์ AI ขั้นสูงและการค้นหาความคล้ายคลึง
โครงสร้างพื้นฐานและการเฝ้าติดตามระบบ:
- Cloudflare Tunnel: การเชื่อมต่อภายนอกอย่างปลอดภัย
- Traefik: reverse proxy พร้อม SSL อัตโนมัติและแดชบอร์ดเฝ้าติดตามระบบ
ทุกส่วนทำงานร่วมกันอย่างไร
นี่คือขั้นตอนทั้งหมดเมื่อลูกค้าเข้าถึงสภาพแวดล้อมของตน:
- ลูกค้าเปิดโดเมนเฉพาะของตน (เช่น workflows.client-a.com)
- Cloudflare Tunnel ส่งคำขอไปยังอินสแตนซ์ Traefik ในเครื่อง
- Traefik อ่านโดเมน ใช้มิดเดิลแวร์ (การยืนยันตัวตน SSL การจำกัดอัตราการเรียกใช้) และส่งต่อไปยังคอนเทนเนอร์ลูกค้าที่ถูกต้อง
- Authentik จัดการการยืนยันตัวตนแบบ SSO ข้ามบริการทั้งหมดหากตั้งค่าไว้
- ลูกค้าได้สภาพแวดล้อมที่แยกเป็นของตนเอง พร้อมข้อมูลและค่ากำหนดของตน
- ไม่สามารถเข้าถึงสภาพแวดล้อมของลูกค้ารายอื่นผ่านเส้นทางของลูกค้ารายนี้ได้
ทุกส่วนจัดเป็นระเบียบและแยกจากกัน ลูกค้าแต่ละรายมีโครงสร้างซับโดเมนของตน เช่น auth.client-a.com, database.client-a.com และ backend.client-a.com
การตั้งค่าระบบ: ขั้นตอนปฏิบัติ
เตรียมพื้นฐานให้พร้อม
เริ่มจากติดตั้ง Docker Desktop และเตรียมระบบจัดการโดเมน เราแนะนำโครงสร้าง DNS แบบ wildcard เพื่อเพิ่มลูกค้าใหม่ได้ง่าย:
# Install Docker Desktop (macOS)
brew install --cask docker
# Verify installation
docker --version
docker compose version
# Ensure sufficient resources for multi-container environments
# Recommended: 16GB RAM, 8+ CPU cores, 500GB+ SSD storage
สร้างระบบเทมเพลต
หัวใจคือแนวทางที่ใช้เทมเพลต แทนการตั้งค่าลูกค้าแต่ละรายด้วยมือ เราสร้างเทมเพลตที่นำขึ้นใช้งานได้ทันทีด้วยค่ากำหนดเฉพาะลูกค้า
สร้างโครงสร้างไดเรกทอรีครบชุด:
mkdir -p development-stack/{template,deployments}
cd development-stack/template
# Create service-specific configuration directories
mkdir -p {traefik,authentik,supabase,init}
ค่ากำหนดเทมเพลตสำหรับหลายบริการครบชุด
สร้างเทมเพลต docker-compose.yml ที่ครอบคลุมบริการทั้ง 16 ตัว:
networks:
tenant:
name: ${TENANT_NETWORK}
driver: bridge
services:
# External Connectivity (public hostnames point to https://traefik:443 in the Cloudflare dashboard)
cloudflare-tunnel:
image: cloudflare/cloudflared:2026.7.2
container_name: ${TENANT_PREFIX}-tunnel
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TOKEN}
networks:
- tenant
restart: unless-stopped
# Reverse Proxy & Load Balancer
# No host ports 80/443: client traffic arrives through the tunnel, so several
# clients can share one host. Certificates use the Cloudflare DNS challenge.
traefik:
image: traefik:v3.7.9
container_name: ${TENANT_PREFIX}-traefik
command:
- "--api.dashboard=true"
- "--ping=true"
- "--providers.docker=true"
- "--providers.docker.network=${TENANT_NETWORK}"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.constraints=Label(`com.docker.compose.project`,`${TENANT_PREFIX}`)"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.asdefault=true"
- "--entrypoints.traefik.address=:8080"
- "--certificatesresolvers.letsencrypt.acme.email=${ADMIN_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
environment:
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN}
ports:
# Dashboard on localhost only (http://localhost:PORT/dashboard/)
- "127.0.0.1:${TRAEFIK_DASHBOARD_PORT}:8080"
networks:
- tenant
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/acme.json:/acme.json
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=PathPrefix(`/`)"
- "traefik.http.routers.dashboard.entrypoints=traefik"
- "traefik.http.routers.dashboard.service=api@internal"
restart: unless-stopped
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 30s
timeout: 10s
retries: 3
# Database Backend
postgres:
image: postgres:15.18-alpine
container_name: ${TENANT_PREFIX}-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d
networks:
- tenant
ports:
# Localhost only, for admin tools on the host
- "127.0.0.1:${POSTGRES_PORT}:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 30s
timeout: 10s
retries: 5
deploy:
resources:
limits:
memory: 2G
reservations:
memory: 1G
# Workflow Automation
n8n:
image: n8nio/n8n:2.31.7
container_name: ${TENANT_PREFIX}-n8n
environment:
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: ${POSTGRES_USER}
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
N8N_PROTOCOL: ${N8N_PROTOCOL}
N8N_HOST: ${N8N_DOMAIN}
N8N_PORT: 5678
N8N_SECURE_COOKIE: ${N8N_SECURE_COOKIE}
WEBHOOK_URL: https://${N8N_DOMAIN}
N8N_EDITOR_BASE_URL: https://${N8N_DOMAIN}
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: 168
volumes:
- n8n_data:/home/node/.n8n
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.n8n.rule=Host(`${N8N_DOMAIN}`)"
- "traefik.http.routers.n8n.tls.certresolver=letsencrypt"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
- "traefik.http.routers.n8n.middlewares=${AUTH_MIDDLEWARE}"
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
# No-Code Database Interface
nocodb:
image: nocodb/nocodb:2026.07.0
container_name: ${TENANT_PREFIX}-nocodb
environment:
NC_DB: "pg://postgres:5432?u=${POSTGRES_USER}&p=${POSTGRES_PASSWORD}&d=nocodb"
NC_PUBLIC_URL: https://${NOCODB_DOMAIN}
NC_DISABLE_TELE: "true"
NC_ADMIN_EMAIL: ${ADMIN_EMAIL}
NC_ADMIN_PASSWORD: ${NOCODB_ADMIN_PASSWORD}
volumes:
- nocodb_data:/usr/app/data
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.nocodb.rule=Host(`${NOCODB_DOMAIN}`)"
- "traefik.http.routers.nocodb.tls.certresolver=letsencrypt"
- "traefik.http.services.nocodb.loadbalancer.server.port=8080"
- "traefik.http.routers.nocodb.middlewares=${AUTH_MIDDLEWARE}"
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
# Supabase Backend Stack (5 containers)
supabase-studio:
image: supabase/studio:2026.07.27-sha-cbb076d
container_name: ${TENANT_PREFIX}-supabase-studio
environment:
STUDIO_PG_META_URL: http://supabase-meta:8080
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DEFAULT_ORGANIZATION_NAME: ${CLIENT_NAME}
DEFAULT_PROJECT_NAME: ${CLIENT_NAME} Project
SUPABASE_PUBLIC_URL: https://${SUPABASE_DOMAIN}
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.supabase-studio.rule=Host(`${SUPABASE_DOMAIN}`)"
- "traefik.http.routers.supabase-studio.tls.certresolver=letsencrypt"
- "traefik.http.services.supabase-studio.loadbalancer.server.port=3000"
- "traefik.http.routers.supabase-studio.middlewares=${AUTH_MIDDLEWARE}"
restart: unless-stopped
healthcheck:
disable: true
depends_on:
postgres:
condition: service_healthy
supabase-meta:
image: supabase/postgres-meta:v0.96.6
container_name: ${TENANT_PREFIX}-supabase-meta
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: postgres
PG_META_DB_PORT: 5432
PG_META_DB_NAME: supabase
PG_META_DB_USER: ${POSTGRES_USER}
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
networks:
- tenant
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
supabase-auth:
image: supabase/gotrue:v2.193.1
container_name: ${TENANT_PREFIX}-supabase-auth
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: https://api.${CLIENT_DOMAIN}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/supabase
GOTRUE_SITE_URL: https://${SUPABASE_DOMAIN}
GOTRUE_JWT_SECRET: ${SUPABASE_JWT_SECRET}
GOTRUE_JWT_EXP: 3600
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
networks:
- tenant
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
supabase-rest:
image: postgrest/postgrest:v13.0.8
container_name: ${TENANT_PREFIX}-supabase-rest
environment:
PGRST_DB_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/supabase
PGRST_DB_SCHEMAS: public
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${SUPABASE_JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
networks:
- tenant
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
supabase-realtime:
image: supabase/realtime:v2.120.3
container_name: ${TENANT_PREFIX}-supabase-realtime
environment:
PORT: 4000
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: supabase
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${SUPABASE_JWT_SECRET}
SECRET_KEY_BASE: ${SUPABASE_JWT_SECRET}
METRICS_JWT_SECRET: ${SUPABASE_JWT_SECRET}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
APP_NAME: realtime
SEED_SELF_HOST: "true"
networks:
- tenant
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
supabase-kong:
image: kong:3.9.1
container_name: ${TENANT_PREFIX}-supabase-kong
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-size-limiting,cors,key-auth,rate-limiting
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
volumes:
- ./supabase/kong.yml:/var/lib/kong/kong.yml:ro
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.kong.rule=Host(`api.${CLIENT_DOMAIN}`)"
- "traefik.http.routers.kong.tls.certresolver=letsencrypt"
- "traefik.http.services.kong.loadbalancer.server.port=8000"
- "traefik.http.routers.kong.middlewares=${AUTH_MIDDLEWARE}"
restart: unless-stopped
# Local AI Language Models
ollama:
image: ollama/ollama:0.32.3
container_name: ${TENANT_PREFIX}-ollama
environment:
OLLAMA_HOST: 0.0.0.0:11434
OLLAMA_ORIGINS: "*"
volumes:
- ollama_data:/root/.ollama
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.ollama.rule=Host(`ai.${CLIENT_DOMAIN}`)"
- "traefik.http.routers.ollama.tls.certresolver=letsencrypt"
- "traefik.http.services.ollama.loadbalancer.server.port=11434"
- "traefik.http.routers.ollama.middlewares=${AUTH_MIDDLEWARE}"
restart: unless-stopped
deploy:
resources:
limits:
memory: 16G
reservations:
memory: 8G
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 3
# Enterprise SSO Authentication (3 containers)
authentik-redis:
image: redis:7.4.9-alpine
container_name: ${TENANT_PREFIX}-authentik-redis
command: --save 60 1 --loglevel warning
networks:
- tenant
volumes:
- authentik_redis_data:/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 30s
timeout: 3s
retries: 3
authentik-server:
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG}
container_name: ${TENANT_PREFIX}-authentik-server
command: server
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
AUTHENTIK_POSTGRESQL__HOST: postgres
AUTHENTIK_POSTGRESQL__USER: ${POSTGRES_USER}
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
AUTHENTIK_REDIS__HOST: authentik-redis
volumes:
- authentik_media:/media
- authentik_templates:/templates
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.authentik.rule=Host(`auth.${CLIENT_DOMAIN}`)"
- "traefik.http.routers.authentik.tls.certresolver=letsencrypt"
- "traefik.http.services.authentik.loadbalancer.server.port=9000"
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
authentik-redis:
condition: service_healthy
authentik-worker:
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG}
container_name: ${TENANT_PREFIX}-authentik-worker
command: worker
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
AUTHENTIK_POSTGRESQL__HOST: postgres
AUTHENTIK_POSTGRESQL__USER: ${POSTGRES_USER}
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
AUTHENTIK_REDIS__HOST: authentik-redis
volumes:
- authentik_media:/media
- authentik_templates:/templates
- /var/run/docker.sock:/var/run/docker.sock
networks:
- tenant
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
authentik-redis:
condition: service_healthy
# Test Service for Health Monitoring
whoami:
image: traefik/whoami:v1.11.0
container_name: ${TENANT_PREFIX}-whoami
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`test.${CLIENT_DOMAIN}`)"
- "traefik.http.routers.whoami.tls.certresolver=letsencrypt"
- "traefik.http.services.whoami.loadbalancer.server.port=80"
restart: unless-stopped
volumes:
postgres_data:
n8n_data:
nocodb_data:
ollama_data:
authentik_redis_data:
authentik_media:
authentik_templates:
เทมเพลตสภาพแวดล้อมครบชุด
สร้าง .env.template สำหรับตัวแปรเฉพาะลูกค้าให้ครบ:
# Client Configuration
CLIENT_NAME=CLIENT_NAME_PLACEHOLDER
CLIENT_DOMAIN=CLIENT_DOMAIN_PLACEHOLDER
TENANT_PREFIX=CLIENT_PREFIX_PLACEHOLDER
TENANT_NETWORK=CLIENT_NETWORK_PLACEHOLDER
# Compose project name: keeps volume names and Traefik's container filter per client
COMPOSE_PROJECT_NAME=CLIENT_PREFIX_PLACEHOLDER
# Service Domains (Subdomain-based routing)
N8N_DOMAIN=workflows.CLIENT_DOMAIN_PLACEHOLDER
NOCODB_DOMAIN=database.CLIENT_DOMAIN_PLACEHOLDER
SUPABASE_DOMAIN=backend.CLIENT_DOMAIN_PLACEHOLDER
AUTHENTIK_DOMAIN=auth.CLIENT_DOMAIN_PLACEHOLDER
# Host Ports (localhost only, offset per client by the deployment script)
TRAEFIK_DASHBOARD_PORT=8080
POSTGRES_PORT=5432
# Admin Configuration
ADMIN_EMAIL=admin@CLIENT_DOMAIN_PLACEHOLDER
# Database Configuration
POSTGRES_DB=main_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=SECURE_PASSWORD_PLACEHOLDER
# Service-Specific Passwords
NOCODB_ADMIN_PASSWORD=NOCODB_PASSWORD_PLACEHOLDER
SUPABASE_JWT_SECRET=SUPABASE_JWT_PLACEHOLDER
# Authentik SSO Configuration
AUTHENTIK_SECRET_KEY=AUTHENTIK_SECRET_PLACEHOLDER
AUTHENTIK_TAG=2025.8.6
# n8n Configuration
N8N_PROTOCOL=https
N8N_SECURE_COOKIE=true
# Cloudflare Integration (tunnel token, and an API token with Zone:DNS:Edit for certificates)
CLOUDFLARE_TOKEN=CLOUDFLARE_TOKEN_PLACEHOLDER
CF_DNS_API_TOKEN=CF_DNS_API_TOKEN_PLACEHOLDER
# Authentication Middleware (set to 'auth-global' for SSO, leave empty for no auth)
AUTH_MIDDLEWARE=
สคริปต์ตั้งต้นฐานข้อมูล
สร้างขั้นตอนตั้งต้นฐานข้อมูลครบชุดใน init/01-create-multiple-databases.sql:
-- Create databases for all services
CREATE DATABASE n8n;
CREATE DATABASE nocodb;
CREATE DATABASE supabase;
CREATE DATABASE authentik;
-- Enable required extensions for Supabase
-- (pgjwt is not part of the official postgres image, and a failing statement aborts the init)
\c supabase
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Schemas and role the Supabase services expect
CREATE SCHEMA IF NOT EXISTS auth;
CREATE SCHEMA IF NOT EXISTS _realtime;
CREATE ROLE anon NOLOGIN;
GRANT USAGE ON SCHEMA public TO anon;
-- Enable required extensions for Authentik
\c authentik
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
\echo 'Multiple databases and extensions created successfully'
ค่ากำหนด Supabase Kong Gateway
สร้าง supabase/kong.yml สำหรับกำหนดเส้นทาง API gateway:
_format_version: "3.0"
services:
- name: auth-v1-open
url: http://supabase-auth:9999/verify
plugins:
- name: cors
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
methods:
- POST
- OPTIONS
- name: auth-v1-open-callback
url: http://supabase-auth:9999/callback
plugins:
- name: cors
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
methods:
- GET
- POST
- OPTIONS
- name: auth-v1
_comment: "GoTrue: /auth/v1/* -> http://supabase-auth:9999/*"
url: http://supabase-auth:9999/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- name: rest-v1
_comment: "PostgREST: /rest/v1/* -> http://supabase-rest:3000/*"
url: http://supabase-rest:3000/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- name: realtime-v1
_comment: "Realtime: /realtime/v1/* -> ws://supabase-realtime:4000/socket/*"
url: http://supabase-realtime:4000/socket/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
routes:
- name: realtime-v1-all
strip_path: true
paths:
- /realtime/v1/
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
consumers:
- username: anon
keyauth_credentials:
- key: your-anon-key-here
- username: service_role
keyauth_credentials:
- key: your-service-role-key-here
plugins:
- name: cors
config:
origins:
- "*"
methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
headers:
- Accept
- Accept-Version
- Content-Length
- Content-MD5
- Content-Type
- Date
- X-Auth-Token
- Authorization
- X-Forwarded-For
- X-Forwarded-Proto
- X-Forwarded-Port
exposed_headers:
- X-Auth-Token
credentials: true
max_age: 3600
สคริปต์นำระบบขึ้นใช้งานอัตโนมัติ
สคริปต์นำระบบขึ้นใช้งานครบชุดที่สร้างสภาพแวดล้อมลูกค้าใหม่ได้ในไม่กี่นาที:
#!/bin/bash
# deploy-client.sh - Complete Multi-Tenant Deployment
set -euo pipefail
CLIENT_DOMAIN=${1:-}
CLIENT_NAME=${2:-}
CLOUDFLARE_TOKEN=${3:-}
if [ -z "$CLIENT_DOMAIN" ] || [ -z "$CLIENT_NAME" ] || [ -z "$CLOUDFLARE_TOKEN" ] || [ -z "${CF_DNS_API_TOKEN:-}" ]; then
echo "Usage: CF_DNS_API_TOKEN=... ./deploy-client.sh example.com 'Client Name' 'cloudflare-tunnel-token'"
echo ""
echo "Example: ./deploy-client.sh client-a.com 'Client A Corporation' 'your-cloudflare-token'"
echo "CF_DNS_API_TOKEN: Cloudflare API token with Zone:DNS:Edit, used for certificates"
exit 1
fi
# Run from the template directory, wherever the script is called from
cd "$(dirname "$0")"
CLIENT_PREFIX=$(echo "$CLIENT_DOMAIN" | sed 's/[.-]//g' | tr '[:upper:]' '[:lower:]')
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
echo "🚀 Deploying complete multi-tenant environment..."
echo "📋 Configuration:"
echo " Domain: $CLIENT_DOMAIN"
echo " Name: $CLIENT_NAME"
echo " Prefix: $CLIENT_PREFIX"
echo " Timestamp: $TIMESTAMP"
echo ""
# Create deployment directory
DEPLOY_DIR="../deployments/$CLIENT_DOMAIN"
mkdir -p "$DEPLOY_DIR"/{traefik,authentik,supabase,init,logs}
echo "📁 Created deployment directory structure"
# Copy template files
cp docker-compose.yml "$DEPLOY_DIR/"
cp -R traefik authentik supabase init "$DEPLOY_DIR/"
echo "📋 Copied configuration templates"
# Generate secure passwords and secrets
POSTGRES_PASSWORD=$(openssl rand -hex 16)
NOCODB_PASSWORD=$(openssl rand -hex 12)
SUPABASE_JWT_SECRET=$(openssl rand -hex 32)
AUTHENTIK_SECRET=$(openssl rand -hex 32)
echo "🔐 Generated secure credentials"
# Offset the localhost-only host ports per client (80/443 are not published at all)
PORT_OFFSET=$(($(echo "$CLIENT_PREFIX" | cksum | cut -f1 -d' ') % 1000))
TRAEFIK_DASHBOARD_PORT=$((8080 + PORT_OFFSET))
POSTGRES_PORT=$((5432 + PORT_OFFSET))
# Create comprehensive environment file (readable by the owner only)
umask 077
sed -e "s/CLIENT_NAME_PLACEHOLDER/$CLIENT_NAME/g" \
-e "s/CLIENT_DOMAIN_PLACEHOLDER/$CLIENT_DOMAIN/g" \
-e "s/CLIENT_PREFIX_PLACEHOLDER/$CLIENT_PREFIX/g" \
-e "s/CLIENT_NETWORK_PLACEHOLDER/${CLIENT_PREFIX}-network/g" \
-e "s/SECURE_PASSWORD_PLACEHOLDER/$POSTGRES_PASSWORD/g" \
-e "s/NOCODB_PASSWORD_PLACEHOLDER/$NOCODB_PASSWORD/g" \
-e "s/SUPABASE_JWT_PLACEHOLDER/$SUPABASE_JWT_SECRET/g" \
-e "s/AUTHENTIK_SECRET_PLACEHOLDER/$AUTHENTIK_SECRET/g" \
-e "s/CLOUDFLARE_TOKEN_PLACEHOLDER/$CLOUDFLARE_TOKEN/g" \
-e "s/CF_DNS_API_TOKEN_PLACEHOLDER/$CF_DNS_API_TOKEN/g" \
-e "s/^TRAEFIK_DASHBOARD_PORT=.*/TRAEFIK_DASHBOARD_PORT=$TRAEFIK_DASHBOARD_PORT/" \
-e "s/^POSTGRES_PORT=.*/POSTGRES_PORT=$POSTGRES_PORT/" \
.env.template > "$DEPLOY_DIR/.env"
echo "⚙️ Generated environment configuration"
# Create ACME file for Traefik SSL
touch "$DEPLOY_DIR/traefik/acme.json"
chmod 600 "$DEPLOY_DIR/traefik/acme.json"
# Initialize deployment
cd "$DEPLOY_DIR"
echo "🐋 Starting Docker containers..."
echo " This may take several minutes for first-time image downloads"
# Start all services (each waits for the database health check)
docker compose up -d
echo ""
echo "✅ Multi-tenant environment deployed successfully!"
echo ""
echo "🌐 Access URLs:"
echo " 🔧 Traefik Dashboard: http://localhost:$TRAEFIK_DASHBOARD_PORT/dashboard/"
echo " 🔀 Workflows (n8n): https://workflows.$CLIENT_DOMAIN"
echo " 🗄️ Database (NocoDB): https://database.$CLIENT_DOMAIN"
echo " 🔧 Backend (Supabase): https://backend.$CLIENT_DOMAIN"
echo " 🤖 AI (Ollama): https://ai.$CLIENT_DOMAIN"
echo " 🔐 Authentication: https://auth.$CLIENT_DOMAIN"
echo " 🔌 API Gateway: https://api.$CLIENT_DOMAIN"
echo " 🧪 Test Service: https://test.$CLIENT_DOMAIN"
echo ""
echo "📊 Container Status:"
docker compose ps
echo ""
echo "🔑 Generated credentials are stored in $DEPLOY_DIR/.env (mode 600)"
echo ""
echo "📝 Next Steps:"
echo " 1. In the Cloudflare tunnel, route *.${CLIENT_DOMAIN} to https://traefik:443 (No TLS Verify)"
echo " 2. Wait 2-3 minutes for all services to initialize"
echo " 3. Access services via the URLs above"
echo " 4. Configure SSO via auth.$CLIENT_DOMAIN if needed"
echo ""
echo "📚 Documentation: Visit tva.sg for setup guides and troubleshooting"
echo "💬 Support: Contact us via tva.sg/contact for assistance"
การใช้ชุดระบบหลายผู้เช่า
นำระบบขึ้นใช้งานให้ลูกค้าใหม่
การสร้างสภาพแวดล้อมลูกค้าใหม่ทำได้ง่ายด้วยสคริปต์ครบชุดของเรา:
# Set once per shell: Cloudflare API token with Zone:DNS:Edit (used for certificates)
export CF_DNS_API_TOKEN="your-cloudflare-dns-token"
# Deploy Client A with full enterprise stack
./deploy-client.sh client-a.com "Client A Corporation" "your-cloudflare-token"
# Deploy Client B with different domain
./deploy-client.sh client-b.org "Client B Industries" "your-cloudflare-token"
# Deploy Startup C
./deploy-client.sh startup-c.io "Startup C" "your-cloudflare-token"
การนำระบบขึ้นใช้งานแต่ละครั้งสร้าง:
- เครือข่าย Docker เฉพาะลูกค้าพร้อม 16 คอนเทนเนอร์
- โวลุมข้อมูลแยกสำหรับจัดเก็บข้อมูลถาวร
- คอนเทนเนอร์บริการเฉพาะพร้อมเฝ้าติดตามสถานะการทำงาน
- ค่ากำหนด Cloudflare tunnel แยกแต่ละราย
- เส้นทางโดเมนเฉพาะพร้อมใบรับรอง SSL
- โครงสร้างพื้นฐาน SSO ระดับองค์กรที่พร้อมเปิดใช้
จัดการหลายสภาพแวดล้อม
เฝ้าติดตามสภาพแวดล้อมลูกค้าทั้งหมดจากส่วนกลาง:
# Check all running environments across clients
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(client|startup)"
# View comprehensive logs for specific client
cd deployments/client-a.com
docker compose logs -f --tail=50 n8n
# Health check all services for a client
docker compose ps
docker compose exec postgres pg_isready
# Restart specific services
docker compose restart nocodb supabase-studio
# Update all services to the image versions now pinned in docker-compose.yml
docker compose pull && docker compose up -d
เจาะลึกสถาปัตยกรรมคอนเทนเนอร์
สถาปัตยกรรมครบชุด 16 คอนเทนเนอร์ต่อลูกค้าประกอบด้วย:
ชั้นโครงสร้างพื้นฐาน (4 คอนเทนเนอร์):
- cloudflare-tunnel: การเชื่อมต่อภายนอกอย่างปลอดภัย
- traefik: reverse proxy พร้อม SSL อัตโนมัติและการค้นพบบริการ
- postgres: ฐานข้อมูลกลางพร้อมจัดการกลุ่มการเชื่อมต่อ
- whoami: เฝ้าติดตามสถานะการทำงานและตรวจยืนยันเส้นทาง
ชั้นแอปพลิเคชัน (7 คอนเทนเนอร์):
- n8n: การทำเวิร์กโฟลว์ให้เป็นอัตโนมัติโดยใช้ PostgreSQL เป็น backend
- nocodb: ส่วนติดต่อฐานข้อมูลที่ไม่ต้องเขียนโค้ด
- supabase-studio: แดชบอร์ดพัฒนา backend
- supabase-meta: บริการตรวจสอบโครงสร้างฐานข้อมูล
- supabase-auth: การยืนยันตัวตนและการจัดการผู้ใช้
- supabase-rest: REST API ที่สร้างอัตโนมัติ
- supabase-realtime: การสมัครรับข้อมูลและการอัปเดตแบบเรียลไทม์
ชั้น AI และ gateway (2 คอนเทนเนอร์):
- ollama: AI ในเครื่องที่รองรับการเร่งการประมวลผลด้วย GPU
- supabase-kong: API gateway พร้อมจำกัดอัตราการเรียกใช้และ CORS
ชั้นความปลอดภัยระดับองค์กร (3 คอนเทนเนอร์):
- authentik-server: เซิร์ฟเวอร์ยืนยันตัวตนแบบ SSO
- authentik-worker: งานเบื้องหลังและการแจ้งเตือน
- authentik-redis: การจัดการเซสชันและแคช
ปรับขนาดทรัพยากรต่อลูกค้า
ปรับทรัพยากรตามความต้องการและรูปแบบการใช้งานของลูกค้า:
# High-performance client configuration (docker-compose.override.yml)
services:
n8n:
deploy:
resources:
limits:
cpus: '4.0'
memory: 8G
reservations:
cpus: '2.0'
memory: 4G
postgres:
command: postgres -c max_connections=200 -c shared_buffers=1GB -c effective_cache_size=3GB
deploy:
resources:
limits:
cpus: '2.0'
memory: 4G
reservations:
cpus: '1.0'
memory: 2G
ollama:
deploy:
resources:
limits:
memory: 32G
reservations:
memory: 16G
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
ประโยชน์ที่เกิดขึ้นจริงต่อธุรกิจ
แยกลูกค้าอย่างสมบูรณ์พร้อมคุณสมบัติระดับองค์กร
ลูกค้าแต่ละรายได้ชุดระบบครบของตนเอง ทั้ง SSO ระดับองค์กร ความสามารถ AI และโครงสร้างพื้นฐาน backend ข้อมูล ค่ากำหนด การปรับแต่ง และนโยบายความปลอดภัยแยกจากกัน ปัญหาระดับแอปพลิเคชันในสภาพแวดล้อมของลูกค้ารายหนึ่งจำกัดอยู่ในสภาพแวดล้อมนั้น แต่เนื่องจากลูกค้าทั้งหมดใช้โฮสต์เดียวกัน ปัญหาระดับโฮสต์ เช่น ทรัพยากร CPU หน่วยความจำ หรือดิสก์หมด ยังอาจกระทบทุกคนได้ ลักษณะนี้คล้ายกับการแยกในการติดตั้ง n8n แต่ละชุดของเรา
เพิ่มลูกค้าใหม่อย่างรวดเร็วพร้อมคุณสมบัติครบ
ลูกค้าใหม่เริ่มใช้ชุดระบบพัฒนาและระบบอัตโนมัติครบได้ในเวลาต่ำกว่า 10 นาที สคริปต์นำระบบขึ้นใช้งานจัดการค่ากำหนด การสร้างข้อมูลรับรอง และการเริ่มต้นบริการโดยอัตโนมัติ ส่วนระเบียน DNS ของ Cloudflare และ SSO ที่เลือกเปิดใช้ยังเป็นขั้นตอนสั้น ๆ ที่ต้องทำด้วยตนเอง
ค่าใช้จ่ายระดับองค์กรที่คาดการณ์ได้
หลังตั้งค่าครั้งแรก ไม่มีค่าโฮสต์ต่อลูกค้าเพิ่มเติมนอกเหนือจากโครงสร้างพื้นฐานหลัก ต่างจาก SaaS ที่คิดค่าบริการต่อผู้ใช้ ต่อเวิร์กโฟลว์ หรือต่อการเรียก API คุณจ่ายค่าเซิร์ฟเวอร์เป็นจำนวนคงที่และรันสภาพแวดล้อมลูกค้าพร้อมคุณสมบัติระดับองค์กรครบชุดได้มากเท่าที่ทรัพยากรของเซิร์ฟเวอร์รองรับ
ภาพลักษณ์แบรนด์ที่เป็นมืออาชีพและสม่ำเสมอ
ลูกค้าแต่ละรายมีโดเมนแบรนด์ของตนพร้อมซับโดเมนที่เป็นมืออาชีพ (workflows.client.com, auth.client.com เป็นต้น) และปรับแต่งสภาพแวดล้อมได้ทั้งหมด ไม่มีข้อความ “powered by” ที่ท้ายหน้าหรือส่วนติดต่อร่วมที่ลดเอกลักษณ์แบรนด์
การเชื่อมต่อ n8n: ขยายการทำเวิร์กโฟลว์ระดับองค์กรให้เป็นอัตโนมัติ
นี่คือจุดที่ระบบมีความสามารถมากขึ้น เช่นเดียวกับที่เราแสดงวิธีติดตั้งและดูแล n8n เองเพื่อทำเวิร์กโฟลว์ให้เป็นอัตโนมัติ ระบบหลายผู้เช่านี้ให้ลูกค้าแต่ละรายมีอินสแตนซ์ n8n ครบชุดของตน เชื่อมต่อกับชุดระบบองค์กรทั้งหมด
ลูกค้าแต่ละรายสร้างเวิร์กโฟลว์ที่ซับซ้อนเพื่อ:
- เชื่อมต่อฐานข้อมูลของตน (NocoDB, Supabase PostgreSQL)
- ใช้โมเดล AI ของตน (Ollama) เพื่อระบบอัตโนมัติอัจฉริยะ
- ยืนยันตัวตนผ่าน SSO ระดับองค์กร (Authentik)
- เชื่อมต่อเครื่องมือธุรกิจและ API เฉพาะของตน
- ประมวลผลข้อมูลของตนภายในสภาพแวดล้อมที่แยกเป็นของตนเอง
การรวมระบบเหล่านี้เป็นแพลตฟอร์มส่งมอบบริการให้ลูกค้าที่มีประสิทธิภาพ ซึ่งช่วยให้คุณ:
- นำความสามารถระบบอัตโนมัติที่เป็นมาตรฐานขึ้นใช้งานอย่างรวดเร็ว
- ปรับแต่งเวิร์กโฟลว์ต่อลูกค้าโดยไม่กระทบรายอื่น
- ขยายการส่งมอบบริการโดยต้นทุนไม่เพิ่มเป็นเส้นตรง
- รักษาอำนาจควบคุมข้อมูลทั้งหมดของลูกค้าแต่ละราย
- ให้การควบคุมการเข้าถึงผ่าน SSO เป็นพื้นฐานสำหรับงานด้านความปลอดภัยและการปฏิบัติตามกฎระเบียบ
แนวทางนี้ต่อยอดจากหลักการเดียวกับคู่มือตั้งค่า Windmill ด้วย Docker ของเรา แต่ขยายเป็นสถาปัตยกรรมหลายผู้เช่าเต็มรูปแบบ
ตัวเลือกการตั้งค่าขั้นสูง
ใช้ SSO ระดับองค์กรด้วย Authentik
เปิดการเข้าสู่ระบบครั้งเดียวข้ามบริการลูกค้าทั้งหมด โดยตั้งค่า forward authentication ของ Authentik:
# Add to the authentik-server labels in docker-compose.yml
- "traefik.http.middlewares.auth-global.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.auth-global.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.auth-global.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid"
จากนั้นปรับป้ายกำกับบริการให้ใช้มิดเดิลแวร์:
labels:
- "traefik.http.routers.n8n.middlewares=auth-global"
- "traefik.http.routers.nocodb.middlewares=auth-global"
- "traefik.http.routers.supabase-studio.middlewares=auth-global"
เพิ่มฐานข้อมูลเวกเตอร์สำหรับ AI ขั้นสูง
เพิ่มความสามารถ AI ด้วยฐานข้อมูลเวกเตอร์ Qdrant:
qdrant:
image: qdrant/qdrant:v1.18.3
container_name: ${TENANT_PREFIX}-qdrant
environment:
QDRANT__SERVICE__HTTP_PORT: 6333
QDRANT__SERVICE__GRPC_PORT: 6334
volumes:
- qdrant_data:/qdrant/storage
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.qdrant.rule=Host(`vector.${CLIENT_DOMAIN}`)"
- "traefik.http.routers.qdrant.tls.certresolver=letsencrypt"
- "traefik.http.services.qdrant.loadbalancer.server.port=6333"
- "traefik.http.routers.qdrant.middlewares=${AUTH_MIDDLEWARE}"
# Also add qdrant_data: under the top-level volumes:
ใช้สถาปัตยกรรม AI แบบผสม
เพื่อประสิทธิภาพสูงสุด ลองใช้แนวทางผสมที่รวม AI ในคอนเทนเนอร์กับ AI ที่รันโดยตรงบนเครื่อง:
# Install Ollama natively on host for GPU acceleration
brew install ollama
# Configure containers to use native Ollama
# In docker-compose.yml, services can access via host.docker.internal:11434
n8n:
environment:
- OLLAMA_HOST=host.docker.internal:11434
เรื่องนี้สำคัญบน macOS เพราะ Docker Desktop ไม่รองรับ GPU ในคอนเทนเนอร์ ขณะที่ Ollama ที่ทำงานแบบเนทีฟใช้การเร่งด้วย GPU ได้โดยตรง ในชุดระบบของเรา วิธีนี้เพิ่มประสิทธิภาพประมาณ 5-6 เท่า ขณะที่บริการอื่นยังทำงานในคอนเทนเนอร์
ชุดระบบเฝ้าติดตามและตรวจสอบสถานะภายใน
เพิ่มการเฝ้าติดตามระบบให้ครบต่อลูกค้า:
prometheus:
image: prom/prometheus:v3.13.1
container_name: ${TENANT_PREFIX}-prometheus
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.prometheus.rule=Host(`metrics.${CLIENT_DOMAIN}`)"
- "traefik.http.routers.prometheus.tls.certresolver=letsencrypt"
- "traefik.http.routers.prometheus.middlewares=${AUTH_MIDDLEWARE}"
grafana:
image: grafana/grafana:13.1.1
container_name: ${TENANT_PREFIX}-grafana
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/dashboards:/var/lib/grafana/dashboards
networks:
- tenant
labels:
- "traefik.enable=true"
- "traefik.http.routers.grafana.rule=Host(`monitoring.${CLIENT_DOMAIN}`)"
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
- "traefik.http.routers.grafana.middlewares=${AUTH_MIDDLEWARE}"
# Also add prometheus_data: and grafana_data: under the top-level volumes:
ปัญหาที่พบบ่อยและวิธีแก้
ข้อผิดพลาด “Service Unavailable” หรือ HTTP 502
โดยทั่วไปหมายถึง Traefik ติดต่อคอนเทนเนอร์เป้าหมายไม่ได้ ตรวจสอบว่า:
# Verify container is running and healthy
docker compose ps
docker compose logs traefik --tail=20
# Check container is on correct network
docker network ls
docker network inspect ${CLIENT_PREFIX}-network
# Verify Traefik labels are correct
docker compose config --services
ปัญหาการแปลงชื่อ DNS
การตั้งค่า DNS แบบ wildcard สำคัญต่อการกำหนดเส้นทางซับโดเมน:
# Correct Cloudflare DNS configuration
*.client-a.com CNAME tunnel-uuid.cfargotunnel.com
*.client-b.org CNAME tunnel-uuid2.cfargotunnel.com
# Test DNS resolution
nslookup workflows.client-a.com
dig workflows.client-a.com
ทรัพยากรไม่เพียงพอเมื่อมีลูกค้าหลายราย
เฝ้าติดตามการใช้ทรัพยากรในสภาพแวดล้อมลูกค้าทั้งหมด:
# Check overall system resource usage
docker stats --no-stream
htop
# Check disk usage per client
du -sh deployments/*/
df -h
# Monitor container memory usage
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}"
กลุ่มการเชื่อมต่อฐานข้อมูลเต็ม
เมื่อมีลูกค้าหลายราย อาจใช้การเชื่อมต่อ PostgreSQL ถึงขีดจำกัด ตั้งค่าต่อการนำระบบขึ้นใช้งานแต่ละชุด:
# Connect to client database
docker compose exec postgres psql -U postgres
-- Increase connection limit
ALTER SYSTEM SET max_connections = 300;
ALTER SYSTEM SET shared_buffers = '256MB';
ALTER SYSTEM SET effective_cache_size = '1GB';
-- Apply: effective_cache_size reloads, max_connections and shared_buffers need a restart
SELECT pg_reload_conf();
\q
# Restart PostgreSQL
docker compose restart postgres
ปัญหาการตั้งค่า Authentik SSO
ปัญหาการตั้งค่า SSO ที่พบบ่อยและวิธีแก้:
# Check Authentik containers are running
docker compose ps | grep authentik
# Verify database initialization
docker compose exec postgres psql -U postgres -d authentik -c "\dt"
# Check Authentik logs for startup issues
docker compose logs authentik-server --tail=50
# Get a recovery link to log in as admin if needed
docker compose exec authentik-worker ak create_recovery_key 10 akadmin
ปัญหาการเชื่อมต่อ Cloudflare Tunnel
ตรวจหาสาเหตุของปัญหาการเชื่อมต่อช่องทาง:
# Check tunnel status
docker compose logs cloudflare-tunnel --tail=20
# Verify tunnel configuration in Cloudflare dashboard
# Ensure wildcard routing: *.client-domain.com
# Test tunnel connectivity
curl -I https://test.client-domain.com
ข้อควรพิจารณาด้านโครงสร้างพื้นฐาน
กำหนดขนาดโครงสร้างพื้นฐานสำหรับลูกค้าหลายราย
สำหรับระบบทั่วไปที่รองรับลูกค้า 10-15 รายพร้อมกัน โดยแต่ละรายใช้ชุดระบบครบ 16 คอนเทนเนอร์:
ข้อกำหนดขั้นต่ำ:
- CPU: 16-24 แกน (2 แกนต่อสภาพแวดล้อมลูกค้าที่ใช้งานอยู่)
- RAM: 64-128GB (4-8GB ต่อลูกค้าตามการใช้งาน AI)
- พื้นที่จัดเก็บ: NVMe SSD ขนาด 2TB+ (ฐานข้อมูล โมเดล AI และบันทึกเติบโตตามเวลา)
- เครือข่าย: การเชื่อมต่อ Gigabit เพื่อให้ลูกค้าเข้าถึงได้รวดเร็ว
คำแนะนำสำหรับสภาพแวดล้อมใช้งานจริง:
- เซิร์ฟเวอร์: Hetzner CCX62 หรือเทียบเท่า (48 vCPU, 192GB RAM)
- พื้นที่จัดเก็บ: NVMe 4TB พร้อมระบบสำรองข้อมูลอัตโนมัติ
- เครือข่าย: การเชื่อมต่อสำรองหลายทาง
- การเฝ้าติดตามระบบ: ชุดระบบตรวจสอบสถานะภายในครบชุดพร้อมการแจ้งเตือน
กลยุทธ์สำรองข้อมูลสำหรับสภาพแวดล้อมลูกค้าหลายราย
จัดระบบสำรองข้อมูลอัตโนมัติแยกต่อลูกค้า:
#!/bin/bash
# backup-all-clients.sh - Comprehensive backup solution
set -euo pipefail
umask 077
BACKUP_DIR="/opt/backups"
BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
# Path to development-stack/deployments
DEPLOYMENTS_DIR="/opt/development-stack/deployments"
mkdir -p "$BACKUP_DIR"
for client_dir in "$DEPLOYMENTS_DIR"/*/; do
CLIENT_DOMAIN=$(basename "$client_dir")
# Volume names start with the compose project name (TENANT_PREFIX in .env)
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' "$client_dir/.env" | cut -d= -f2)
echo "📦 Backing up client: $CLIENT_DOMAIN"
# Subshell: the cd applies to this client only; set -e stops the script on any failure
(
cd "$client_dir"
# Backup databases with compression
docker compose exec -T postgres pg_dumpall -U postgres | gzip > "${BACKUP_DIR}/${CLIENT_DOMAIN}_db_${BACKUP_DATE}.sql.gz"
# Backup persistent volumes
docker run --rm \
-v "${BACKUP_DIR}":/backup \
-v "${PROJECT}_n8n_data":/data/n8n:ro \
-v "${PROJECT}_nocodb_data":/data/nocodb:ro \
-v "${PROJECT}_ollama_data":/data/ollama:ro \
alpine:3.22 tar czf "/backup/${CLIENT_DOMAIN}_volumes_${BACKUP_DATE}.tar.gz" -C /data .
# Backup configuration files
tar czf "${BACKUP_DIR}/${CLIENT_DOMAIN}_config_${BACKUP_DATE}.tar.gz" \
docker-compose.yml .env traefik/ supabase/ authentik/ init/
)
echo "✅ Backup completed for $CLIENT_DOMAIN"
done
# Cleanup old backups (keep 30 days)
find "$BACKUP_DIR" -name "*.gz" -mtime +30 -delete
# Optional: Upload to cloud storage
# rclone sync "$BACKUP_DIR" s3:backup-bucket/multi-tenant/
เสริมความปลอดภัยสำหรับสภาพแวดล้อมใช้งานจริง
ใช้แนวปฏิบัติด้านความปลอดภัยที่แนะนำให้ครบ:
# Enhanced Traefik security configuration
traefik:
command:
- "--api.dashboard=true"
- "--ping=true"
- "--log.level=WARN"
- "--accesslog=true"
- "--providers.docker=true"
- "--providers.docker.network=${TENANT_NETWORK}"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.constraints=Label(`com.docker.compose.project`,`${TENANT_PREFIX}`)"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.asdefault=true"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.traefik.address=:8080"
- "--certificatesresolvers.letsencrypt.acme.email=${ADMIN_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
- "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare"
labels:
# Security headers middleware
- "traefik.http.middlewares.security.headers.customRequestHeaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.security.headers.customResponseHeaders.X-Frame-Options=DENY"
- "traefik.http.middlewares.security.headers.customResponseHeaders.X-Content-Type-Options=nosniff"
- "traefik.http.middlewares.security.headers.customResponseHeaders.Strict-Transport-Security=max-age=31536000"
- "traefik.http.middlewares.security.headers.customResponseHeaders.Content-Security-Policy=default-src 'self'"
# Rate limiting middleware
- "traefik.http.middlewares.ratelimit.ratelimit.burst=100"
- "traefik.http.middlewares.ratelimit.ratelimit.average=50"
ใช้มิดเดิลแวร์ด้านความปลอดภัยกับบริการลูกค้าทั้งหมด:
labels:
# Without SSO
- "traefik.http.routers.n8n.middlewares=security,ratelimit"
# With SSO enabled (AUTH_MIDDLEWARE=auth-global)
# - "traefik.http.routers.n8n.middlewares=security,ratelimit,auth-global"
วิเคราะห์ต้นทุน: ตัวเลขที่สำคัญ
ต้นทุน SaaS แบบเดิม (ลูกค้าองค์กร 10 รายที่ใช้คุณสมบัติครบ)
ค่าใช้จ่ายรายเดือนต่อลูกค้า:
- n8n Pro: $50/เดือนต่อลูกค้า = $500/เดือน
- Supabase Pro: $25/เดือนต่อลูกค้า = $250/เดือน
- แพลตฟอร์ม NoCode (Airtable): $20/เดือนต่อลูกค้า = $200/เดือน
- SSO ระดับองค์กร (Auth0): $23/เดือนต่อลูกค้า = $230/เดือน
- ค่าใช้จ่าย AI API (OpenAI): $50/เดือนต่อลูกค้า = $500/เดือน
- รวม: $1,680/เดือน = $20,160/ปี
ต้นทุนชุดระบบองค์กรหลายผู้เช่าที่ติดตั้งและดูแลเอง
ค่าโครงสร้างพื้นฐานรายปี:
- เซิร์ฟเวอร์คลาวด์ที่มี vCPU เฉพาะ (Hetzner CCX62): $350/เดือน = $4,200/ปี
- ค่าโดเมน (ลูกค้า 10 ราย): $120/ปี
- Cloudflare Pro (แบบเลือกใช้): $240/ปี
- รวม: $4,560/ปี
ประหยัดต่อปี: $15,600 (ลดต้นทุน 77%)
ตัวเลขเหล่านี้มาจากคู่มือต้นฉบับเดือนสิงหาคม 2025 ณ เดือนตุลาคม 2026 แผน B2C Essentials ของ Auth0 เริ่มต้นที่ $35/เดือน และ Hetzner ไม่ได้แสดง CCX62 ในรายการแล้ว โดยรุ่นต่อมาคือ CCX63 ที่มี 48 vCPU และหน่วยความจำ 192 GB มีราคาตามรายการ €853.99/เดือน ไม่รวมภาษีมูลค่าเพิ่ม โดยรวมที่อยู่ IPv4 หลักหนึ่งที่อยู่ คำนวณทั้งสองทางเลือกใหม่ด้วยราคาปัจจุบันก่อนตัดสินใจ
คุณยังได้:
- อำนาจควบคุมข้อมูลและความเป็นส่วนตัวอย่างสมบูรณ์
- การปรับแต่งไม่จำกัดและการใช้แบรนด์ของตนเอง
- ไม่มีการผูกติดกับผู้ให้บริการหรือขีดจำกัดอัตราการเรียกใช้จากแผน SaaS
- การควบคุมด้านความปลอดภัยและการปฏิบัติตามกฎระเบียบที่คุณจัดการเอง
- ความสามารถในการเสนอบริการขายต่อ
- การควบคุมการอัปเดตและคุณสมบัติทั้งหมด
แนวทางนี้มีประโยชน์มากเป็นพิเศษเมื่อพิจารณาว่าระบบของเรามีคุณสมบัติระดับองค์กรที่ปกติจะมีค่าใช้จ่ายสูงกว่ามากหากสมัคร SaaS คล้ายกับประโยชน์ด้านต้นทุนที่เราแสดงในการวิเคราะห์การติดตั้งและดูแล n8n เอง
จากการตั้งค่าสู่การตัดสินใจดำเนินงาน
คำถามในทางปฏิบัติไม่ใช่ว่าตั้งค่าแพลตฟอร์มได้หรือไม่ แต่คือทีมอธิบายได้หรือไม่ว่าใครรับผิดชอบ ตรวจพบค่าที่เบี่ยงจากที่กำหนด กู้คืนโดยไม่ต้องแก้ปัญหาเฉพาะหน้า และพิสูจน์ได้ว่าการเปลี่ยนแปลงทำให้ผลลัพธ์ที่ต้องการดีขึ้น เราจึงผูกค่ากำหนดกับผู้รับผิดชอบ ค่าอ้างอิงตั้งต้น วิธีการย้อนกลับ และช่วงตรวจยืนยันผล เพื่อเปลี่ยนการแก้ไขครั้งเดียวให้เป็นขีดความสามารถในการดำเนินงาน บันทึกการเปลี่ยนแปลงระบบใช้งานจริงควรระบุเหตุผล หลักฐานที่ทบทวน ขอบเขตที่แน่นอน และผู้มีอำนาจหยุดหรือย้อนกลับ หลังปล่อยระบบ ให้เทียบตัวชี้วัดที่ตกลงกันกับค่าอ้างอิงตั้งต้นและเก็บผลไว้กับบันทึกการเปลี่ยนแปลง หากไม่ดีขึ้นตามคาด การย้อนกลับถือเป็นผลลัพธ์ที่ยอมรับได้ ไม่ใช่ความล้มเหลว วินัยนี้สำคัญกว่าค่ากำหนดใดค่าหนึ่ง บันทึกเดียวกันยังให้ผู้ดูแลคนถัดไปมีจุดเริ่มต้นที่เชื่อถือได้ และทำให้การเพิ่มประสิทธิภาพในภายหลังเป็นการตัดสินใจจากการวัดผล แทนการคาดเดาอีกรอบ