1. ภาพรวมการทำงาน
เริ่มจากเลือกแหล่งรับไฟล์ แล้วใช้ขั้นตอนตรวจและจัดเก็บชุดเดียวกัน
| หัวข้อ | LINE Group | Gmail |
|---|---|---|
| วิธีรับไฟล์ | Webhook จาก LINE | Polling ผ่าน Gmail API |
| ความเร็วโดยทั่วไป | ส่งเข้าระบบทันที | ภายในประมาณ 60 วินาที |
| ป้องกันไฟล์เก่า | line_not_before_ms |
gmail_not_before_ms |
| ป้องกันไฟล์ซ้ำ | webhookEventId และ messageId |
messageId และ partId (ไม่ใช้ attachment retrieval ID) |
| ชื่อซ้ำ | คงชื่อเดิม invoice.pdf ทุกไฟล์ โดย S3 ใช้ UUID ป้องกันการทับกัน |
|
1.1 เตรียมความพร้อมก่อน
รายการกลางที่ควรพร้อมก่อนเริ่ม Infrastructure และตั้งค่า Source โดยสรุปจากขั้นตอนติดตั้งทั้งหมดในคู่มือนี้
Domain / Cloudflare / ผู้ดูแลระบบ
1.2 สิ่งที่ต้องเตรียมสำหรับ LINE Group
บัญชีและกลุ่ม
ระบบปลายทาง
1.3 สิ่งที่ต้องเตรียมสำหรับ Gmail
ระบบปลายทาง
3. ตั้งค่าแหล่งรับไฟล์
เลือก LINE Group หรือ Gmail แล้วทำตามลำดับจากบนลงล่าง
docker compose และ IAM
Role ของ EC2 โดยไม่ใช้ --profile AWS และไม่ mount ~/.aws. ถ้าทดสอบบน Mac/local ให้ใช้
compose override ของ local แยกต่างหาก.อัปเดตคำสั่ง operator
หลัง pull release และสำรอง DB ใช้ compose overlays เดิม:
docker compose build api worker migrate
docker compose stop worker
docker compose run --rm migrate
docker compose up -d --no-deps --force-recreate api worker
หากใช้ admin ให้ build/recreate admin ด้วย การปรับ prompt ไม่มี migration เพิ่ม คำสั่ง backfill แบบถามทีละช่อง:
docker compose run --rm --no-deps worker python manage.py backfill_gmail
docker compose exec api python manage.py provision_source
Required:
✓ Customer: coreth
? Source type
? External reference
? Display name
? Secret reference (depends on earlier answers)
? Access token reference (depends on earlier answers)
? LINE channel destination (depends on earlier answers)
? Gmail query (depends on earlier answers)
? Source type
1. WEB
2. API
3. PRESIGNED
4. LINE_DIRECT
5. LINE_GROUP
6. GMAIL
Select [1-6]: 5
? External reference
Example: dev@lab365.app
Value: <destination>:<groupId>
? Display name
Example: Coreth validation mailbox
Value: Coreth validation LINE group
? Secret reference
Example: aws-secretsmanager://coreth/line
Value: aws-secretsmanager://<LINE_SECRET>
? Access token reference
Example: aws-secretsmanager://coreth/line
Value: aws-secretsmanager://<LINE_SECRET>
? LINE channel destination
Example: U1234567890abcdef
Value: <destination>
source_id=4fced337-… created=True
Customer และ source ดึงมาจากฐานข้อมูลให้เลือกด้วยตัวเลข ไม่ต้องจำ UUID
ถ้ามีตัวเลือกเดียวระบบเลือกให้เองพร้อมแจ้ง ค่าที่ตั้งไว้แล้วจะไม่ถูกถามซ้ำ และ secret จะไม่ถูกแสดงหรือ echo
การส่ง flag แบบเต็มตามตัวอย่างด้านล่างยังใช้ได้เหมือนเดิมทุกประการ
เหมาะกับ script และ CI ซึ่งถ้าค่าไม่ครบจะหยุดทันทีพร้อมบอกชื่อ flag ที่ขาด ไม่ค้างรอ input
ส่วน flag ขั้นสูงอย่าง --gmail-label-folder, --poll-interval-seconds,
--gmail-start-from-now และ --line-start-from-now ยังต้องส่งเป็น flag
เพราะเป็นตัวเลือกเฉพาะกรณี ไม่ได้ถามในโหมดถาม-ตอบmessageId ดาวน์โหลดไฟล์ ตรวจความปลอดภัย และเก็บลง S3
destination → หา groupId ด้วย signed discovery receiver →
ปิด webhook → คืน Cloudflare route → provision Source พร้อม cutoff →
Verify webhook → เปิด Use webhook → ส่งไฟล์ทดสอบ
ค่าที่ต้องได้ก่อน provision
| ค่า | หน้าตา | หาได้จากไหน | เป็น Secret หรือไม่ |
|---|---|---|---|
LINE_CHANNEL_SECRET |
ข้อความยาว | LINE Developers → Messaging API channel → Basic settings → Channel secret | ใช่ |
LINE_CHANNEL_ACCESS_TOKEN |
Token ยาว | LINE Developers → Messaging API channel → Messaging API → Channel access token | ใช่ |
destination |
U... |
Get bot info ของ LINE OA → field userId |
ไม่ใช่ |
groupId |
C... |
Webhook event จากกลุ่ม → events[].source.groupId |
ไม่ใช่ |
สร้าง LINE Official Account และเปิด Messaging API
ทำที่: LINE Official Account Manager และ LINE Developers Console
- สร้างหรือเลือก LINE Official Account ที่จะใช้เป็น Bot รับไฟล์
- เปิดใช้ Messaging API
- ผูกกับ Provider ที่องค์กรควบคุม
- เข้า LINE Developers Console แล้วตรวจว่ามี Messaging API channel ของ OA นี้
- Auto-response ของ LINE OA Manager เป็นข้อความตายตัวและอาจตอบทุกข้อความในกลุ่ม
- Auto message ของ CORETH ส่งผ่าน Messaging API เฉพาะเมื่อรับไฟล์ใหม่สำเร็จ และใส่ชื่อไฟล์จริงให้โดยอัตโนมัติ
ปิด Auto-response เดิม เพื่อไม่ให้ Bot ตอบซ้ำ
- เข้า LINE Official Account Manager และเลือก LINE OA ที่ใช้เป็น Bot รับไฟล์
- ไปที่ Settings → Response settings
- เปิดการทำงานแบบ Webhook หรือ Messaging API ตามหน้าจอที่ใช้งาน
- ปิด Auto-response messages หรือ “ข้อความตอบกลับอัตโนมัติ” ที่ตอบทุกข้อความ
- ถ้ามีข้อความต้อนรับเพื่อนใหม่ สามารถเปิดไว้ได้ เพราะไม่ใช่ข้อความรับไฟล์
ข้อความที่สมาชิกจะได้รับ:
📎 ได้รับไฟล์ xxxxx.pdf แล้ว
ระบบกำลังตรวจสอบและจัดเก็บไฟล์ให้อัตโนมัติค่ะ
xxxxx.pdf เป็นเพียงตัวอย่าง ระบบจะแทนด้วยชื่อจริงจาก LINE เช่น invoice.pdf
หรือ budget.xlsx ส่วนรูปภาพที่ LINE ไม่ส่งชื่อไฟล์มาจะใช้คำว่า รูปภาพ
อนุญาตให้ Bot เข้ากลุ่มและเชิญ LINE OA เข้ากลุ่ม
ทำที่: LINE Developers → Messaging API channel → Messaging API
- เปิด
Allow bot to join group chats - เปิด LINE Group เป้าหมาย
- เชิญ LINE OA เข้าเป็นสมาชิกของกลุ่ม
เอา Channel secret และ Channel access token
Channel secret: LINE Developers → Messaging API channel → Basic
settings → Channel secret
Channel access token: LINE Developers → Messaging API channel → Messaging
API → Channel access token แล้ว Issue token ตามรูปแบบที่ระบบใช้งาน
X-Line-Signature ของ webhook;
Channel access token ใช้เรียก LINE API และดาวน์โหลดไฟล์จาก messageId
เก็บ LINE credential ใน AWS Secrets Manager
สร้าง secret ชื่อ
ใน region โดยเก็บเป็น JSON:
{
"LINE_CHANNEL_SECRET": "<channel-secret>",
"LINE_CHANNEL_ACCESS_TOKEN": "<channel-access-token>"
}
ตรวจเฉพาะ metadata ของ secret:
aws secretsmanager describe-secret \
--secret-id \
--region
ตรวจ webhook route ของ Cloudflare ก่อนเริ่ม discovery
Webhook URL ที่ใช้ตลอดขั้นตอนคือ:
https:///v1/line/webhook
Published application route ปกติต้องเป็น:
- Hostname:
- Path:
/v1/line/webhook - Service:
http://localhost:8080 - HTTP Host Header:
localhost - Enforce Access JWT validation: Off
groupId เราจะเปลี่ยน Service เป็น http://localhost:8099
ชั่วคราว แล้วต้องคืนกลับ 8080 ก่อน provision จริง
หา destination ของ LINE OA
destination คือ Bot user ID ของ LINE OA และต้องเป็นค่า userId รูปแบบ
U...
ทำที่: EC2 → SSM
LINE_TOKEN="$(
aws secretsmanager get-secret-value \
--secret-id \
--region \
--query SecretString \
--output text \
| jq -r '.LINE_CHANNEL_ACCESS_TOKEN'
)"
DESTINATION="$(
curl -sS \
https://api.line.me/v2/bot/info \
-H "Authorization: Bearer ${LINE_TOKEN}" \
| jq -r '.userId'
)"
echo "destination=${DESTINATION}"
unset LINE_TOKEN
destination=U... — อย่าใช้ basicId ที่ขึ้นต้นด้วย @
เปิด signed discovery receiver ชั่วคราวเพื่อหา groupId
ระบบจริงที่ /v1/line/webhook รับเฉพาะ Source ที่ provision แล้ว
จึงต้องใช้ receiver ชั่วคราวที่ ตรวจ X-Line-Signature จริง เพื่ออ่าน
groupId ครั้งแรก
ทำที่: EC2 → SSM session แรก
export LINE_CHANNEL_SECRET="$(
aws secretsmanager get-secret-value \
--secret-id \
--region \
--query SecretString \
--output text \
| jq -r '.LINE_CHANNEL_SECRET'
)"
test -n "$LINE_CHANNEL_SECRET" && echo "LINE secret loaded"
สร้าง receiver ที่ bind เฉพาะ 127.0.0.1:8099:
cat >/tmp/line_group_discovery.py <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
import base64
import hashlib
import hmac
import json
import os
SECRET = os.environ["LINE_CHANNEL_SECRET"].encode()
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
signature = self.headers.get("X-Line-Signature", "")
expected = base64.b64encode(
hmac.new(SECRET, body, hashlib.sha256).digest()
).decode()
if not hmac.compare_digest(signature, expected):
self.send_response(401)
self.end_headers()
self.wfile.write(b"invalid signature")
print("INVALID SIGNATURE", flush=True)
return
try:
payload = json.loads(body)
except Exception:
self.send_response(400)
self.end_headers()
return
destination = payload.get("destination")
for event in payload.get("events", []):
source = event.get("source", {})
if source.get("type") == "group":
print("", flush=True)
print("=== LINE GROUP FOUND ===", flush=True)
print(f"destination = {destination}", flush=True)
print(f"groupId = {source.get('groupId')}", flush=True)
print(f"eventType = {event.get('type')}", flush=True)
print("========================", flush=True)
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
def log_message(self, format, *args):
pass
print("LINE group discovery listening on 127.0.0.1:8099", flush=True)
HTTPServer(("127.0.0.1", 8099), Handler).serve_forever()
PY
python3 /tmp/line_group_discovery.py
LINE group discovery listening on 127.0.0.1:8099 —
ให้เปิด SSM session อีกอันสำหรับขั้นถัดไปให้ LINE ส่ง webhook เข้า discovery receiver แล้วจด groupId
- LINE Developers → Messaging API → ตั้ง Webhook URL เป็น
https:///v1/line/webhook - ตั้ง
Use webhook = Offก่อน - Cloudflare → Tunnel → Published application route ของ LINE → เปลี่ยน Service
จาก
http://localhost:8080เป็นhttp://localhost:8099ชั่วคราว - กลับ LINE Developers → กด
Verifyให้ขึ้น Success - เปิด
Use webhook = On - ส่งข้อความธรรมดา เช่น
testใน LINE Group เป้าหมาย - กลับไปดู terminal ที่รัน discovery receiver
ควรเห็น:
=== LINE GROUP FOUND ===
destination = Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
groupId = Cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
eventType = message
========================
destination = U... และ groupId = C...
อย่าใช้ userId ของสมาชิกในกลุ่มแทน groupId
groupId=C... แล้ว ให้จด destination และ groupId ไว้สำหรับ
provisionปิด discovery และคืน Cloudflare route กลับระบบจริง
- LINE Developers → ตั้ง
Use webhook = Off - Cloudflare Published application route → คืน Service เป็น
http://localhost:8080 - กลับ terminal discovery แล้วกด
Ctrl+C - ลบไฟล์ชั่วคราวและล้าง secret จาก environment
rm -f /tmp/line_group_discovery.py
unset LINE_CHANNEL_SECRET
localhost:8080 และ
Use webhook ยังต้องเป็น Off
Provision LINE Group Source และตั้ง cutoff
ทำที่: EC2 → SSM →
แทนค่า <destination> ด้วยค่า U... และ
<groupId> ด้วยค่า C... ที่ได้จากขั้นก่อนหน้า:
cd
docker compose exec -T api \
python manage.py provision_source \
--customer coreth \
--type LINE_GROUP \
--external-ref '<destination>:<groupId>' \
--channel-destination '<destination>' \
--secret-ref aws-secretsmanager:// \
--access-token-ref aws-secretsmanager:// \
--line-start-from-now \
--name "Coreth validation LINE group"
source_id=<uuid> และมี line_not_before_ms
มากกว่า 0ตรวจ Source ก่อนเปิด webhook จริง
ใช้ source_id ที่ได้จากขั้นก่อนหน้า:
docker compose exec -T api \
python manage.py shell -c "from ingestion.models import Source; s=Source.objects.get(id='<source_id>'); print({'type': s.source_type, 'policy': s.policy, 'enabled': s.enabled})"
type=LINE_GROUP, enabled=True และ cutoff มากกว่า 0
เปิด webhook จริงใน LINE Developers
ทำที่: LINE Developers → Messaging API channel → Messaging API
- Webhook URL:
https:///v1/line/webhook - กด
Verifyให้ขึ้น Success - เปิด
Use webhook - เปิด
Webhook redelivery - เปิด
Error statistics aggregation
LINE_CHANNEL_ACCESS_TOKEN ต้องอยู่ใน AWS Secrets Manager,
Source ต้อง provision ด้วย --access-token-ref, API ต้องออกอินเทอร์เน็ตไป
api.line.me ได้ และ webhook event ต้องมี replyToken
localhost:8080 และไฟล์ใหม่จะได้รับข้อความตอบกลับจาก CORETH
ส่งไฟล์ใหม่เข้ากลุ่มและตรวจว่าเข้า S3
ส่ง PDF, DOCX, CSV หรือ XLSX หลังจาก provision cutoff แล้วเท่านั้น
หลังส่งไฟล์ Bot ควรตอบภายใน webhook รอบเดียว โดยใช้ชื่อไฟล์จริง:
📎 ได้รับไฟล์ invoice.pdf แล้ว
ระบบกำลังตรวจสอบและจัดเก็บไฟล์ให้อัตโนมัติค่ะ
ตรวจ log:
cd
docker compose logs --tail=200 api worker
READY / CLEAN / ACTIVE
และ S3 อยู่ใต้
raw-ready/<customer-uuid>/line/<type>/<file-version-uuid>.<ext>
ทดสอบ duplicate และไฟล์ชื่อซ้ำ
ถ้า LINE redeliver event เดิม ระบบต้องใช้ webhookEventId / messageId
กันไม่ให้สร้าง FileVersion, S3 object หรือส่งข้อความตอบรับใหม่
แต่ถ้าผู้ใช้ส่ง ไฟล์ใหม่ ที่ชื่อเดิม เช่น invoice.pdf อีกครั้ง
ต้องสร้าง FileVersion ใหม่ได้ โดยแยกกันด้วย FileVersion ID และเวลา
LINE Group: ปัญหาที่พบบ่อย
- หา Channel secret ไม่เจอ: เข้า Messaging API channel → Basic settings → Channel secret
- หา access token ไม่เจอ: เข้า Messaging API channel → Messaging API → Channel access token
- destination ไม่ถูก: ต้องใช้
userIdจาก/v2/bot/infoและต้องขึ้นต้นด้วยU - Verify ผ่านแต่ยังไม่มี groupId: Verify อาจไม่มี event; ให้เปิด Use webhook แล้วส่งข้อความในกลุ่ม
- ไม่มี event จากกลุ่ม: ตรวจ
Allow bot to join group chats, Use webhook และว่า OA ยังอยู่ในกลุ่ม - discovery receiver ได้ 401: Channel secret ไม่ตรง หรือ request ไม่มี/มี
X-Line-Signatureไม่ถูกต้อง - หลัง discovery ระบบจริงไม่รับ webhook: ตรวจว่า Cloudflare route ถูกคืนจาก
localhost:8099กลับlocalhost:8080 - HTTP 400: ตรวจ Channel secret, signature, destination, groupId และ Source type
- HTTP 503: ตรวจ IAM, Secrets Manager และฐานข้อมูล
- ACQUISITION_FAILED: ตรวจ Channel access token และ outbound ไป LINE API
- ได้รับไฟล์แต่ Bot ไม่ตอบ: ตรวจ
--access-token-ref, ค่าLINE_CHANNEL_ACCESS_TOKEN, สิทธิ์ token, outbound ไปapi.line.meและค้นหา audit reasonLINE_REPLY_FAILED - Bot ตอบสองข้อความ: ปิด Auto-response messages ใน LINE OA Manager; CORETH จะตอบไฟล์ใหม่เพียงหนึ่งครั้งเอง
- accepted: 0: event อาจเก่ากว่า cutoff หรือเป็น event type ที่ไม่รองรับ
เปิด Gmail API
เข้า Google Cloud Console เลือก project ขององค์กร เปิด APIs & Services แล้ว Enable Gmail API
สร้าง Service account
- Google Cloud Console → IAM & Admin → Service Accounts → Create service account
- ใช้ service account สำหรับ Gmail ingestion โดยเฉพาะ ไม่ต้องให้ Project Owner/Editor
- เปิด Service account → Show advanced settings → Domain-wide delegation แล้วคัดลอก OAuth Client ID
- Keys → Add key → Create new key → JSON
- นำ JSON เข้า AWS Secrets Manager แล้วลบไฟล์ local เมื่อยืนยัน secret เรียบร้อย
signJwt จะต้องปรับโค้ดก่อนอนุญาตใน Google Admin
ให้ Super Admin เข้า Google Admin Console → Security → Access and data control → API controls → Manage Domain Wide Delegation → Add new จากนั้นใส่ OAuth Client ID ของ service account และอนุญาตเพียง scope:
https://www.googleapis.com/auth/gmail.readonly
ระบบจึงอ่าน attachment ได้ แต่ไม่สามารถย้าย ลบ หรือ mark read อีเมล
สร้าง label และ filter สำหรับอีเมลรับไฟล์
- เข้า Gmail ของ
→ Settings → See all settings → Labels → Create new label - ตั้งชื่อ
- Filters and Blocked Addresses → Create a new filter
- กำหนด To =
และ Has attachment - เลือก Apply the label →
แล้วสร้าง filter
สร้าง Gmail secret ใน AWS
สร้าง secret ชื่อ แบบ Other type of
secret →
Plaintext แล้วครอบ Google Service Account JSON ทั้ง object ไว้ใต้
GOOGLE_SERVICE_ACCOUNT_JSON ไม่ต้องตัด field ที่ Google ให้มา:
{
"GMAIL_MAILBOX": "",
"GOOGLE_SERVICE_ACCOUNT_JSON": {
"type": "service_account",
"project_id": "<project-id>",
"private_key_id": "<key-id>",
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
"client_email": "<service-account-email>",
"client_id": "<oauth-client-id>",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "<google-provided-cert-url>",
"universe_domain": "googleapis.com"
}
}
ตรวจสิทธิอ่าน secret โดยไม่ print private key:
aws secretsmanager get-secret-value \
--secret-id \
--region \
--query Name \
--output text
ถ้าต้องการตรวจว่า wrapper มี key ครบโดยไม่แสดงค่าของ private key:
aws secretsmanager get-secret-value \
--secret-id \
--region \
--query SecretString \
--output text \
| jq '{GMAIL_MAILBOX, google_keys: (.GOOGLE_SERVICE_ACCOUNT_JSON | keys)}'
และคำสั่ง
jq ต้องแสดงชื่อ
key โดยไม่แสดงค่า private_keyหยุด worker ก่อนตั้งจุดเริ่ม
docker compose stop worker
ขั้นตอนนี้ป้องกัน worker poll ก่อน Source มี cutoff ที่ต้องการ
สร้าง Customer หากยังไม่มี
docker compose exec -T api \
python manage.py shell -c "from ingestion.models import Customer; c, created=Customer.objects.get_or_create(slug='coreth'); print(c.id, created)"
Provision Gmail และตั้ง cutoff
docker compose exec -T api \
python manage.py provision_source \
--customer coreth \
--type GMAIL \
--external-ref \
--secret-ref aws-secretsmanager:// \
--gmail-query 'to: has:attachment label:""' \
--gmail-label-folder '' \
--poll-interval-seconds 60 \
--gmail-start-from-now \
--name "Coreth validation Gmail"
--gmail-query เลือกเมลที่จะรับ ส่วน --gmail-label-folder กำหนด label
ที่ใช้เป็น folder ใน S3 โดยชื่อจะต้องตรงกับ label จริงใน Gmail; --name เป็นชื่อแสดงผลของ
Source เท่านั้น
ไฟล์ใหม่จะอยู่ที่
raw-ready/<customer-uuid>/gmail/<label-folder>/<type>/<file-version-uuid>.<ext>
ชื่อ label ที่มีช่องว่าง ภาษาไทย หรือ slash จะถูก percent-encode เป็น folder segment เดียว
อัปเดต Source เดิมให้รับหลาย labels
ใช้ mailbox และ secret เดิม ตัวอย่างนี้เพิ่ม ksher-etax โดยให้เป็น folder หลักก่อน label
เดิม ต้องมี label นี้ใน Gmail ก่อนรัน
docker compose exec -T api \
python manage.py provision_source \
--customer coreth \
--type GMAIL \
--external-ref \
--secret-ref aws-secretsmanager:// \
--gmail-query 'to: has:attachment (label:ksher-etax OR label:"")' \
--gmail-label-folder 'ksher-etax' \
--gmail-label-folder '' \
--poll-interval-seconds 60 \
--name "Coreth validation Gmail"
- ไม่ใส่
--gmail-start-from-nowเมื่ออัปเดตเพื่อรักษา cutoff เดิม เมลที่รับก่อน cutoff ยังคงไม่ถูกนำเข้า - query ใหม่แทน query เดิมทั้งหมด รายการ
--gmail-label-folderที่ส่งใหม่แทนรายการเดิมทั้งหมด หากไม่ส่งตัวเลือก folder จะรักษาค่าเดิม - เมลมีหลาย labels จะเลือก label ที่ตรงตามลำดับแรก เก็บไฟล์ครั้งเดียว พร้อมบันทึก labels ทั้งหมดใน metadata
- เมลตรง query แต่ไม่ตรงรายการ folder จะเข้า
_unmatchedlabelหากชื่อ folder label ที่ตั้งไม่มีใน Gmail จะหยุด poll ด้วยGMAIL_FOLDER_LABEL_NOT_FOUND - ไฟล์ที่รับไปแล้วไม่ย้ายและไม่สร้างซ้ำ การเปลี่ยน label ภายหลังไม่เปลี่ยน folder หรือ metadata ที่บันทึกไว้
หากอัปเกรดระบบที่เปิดใช้งานอยู่แล้ว ให้ deploy โค้ดใหม่ทั้งสอง services ก่อนใช้ตัวเลือกใหม่:
docker compose up -d --build api worker
ทดสอบ manual poll รอบว่าง โดยยังไม่เปิด worker
docker compose run --rm worker \
python manage.py poll_gmail --source <source_id>
ส่งอีเมลใหม่และตรวจผล
หลังตั้ง cutoff แล้ว ส่งอีเมลใหม่เข้า แนบ
DOCX, CSV UTF-8 และ XLSX ที่ไม่มี
macro ตรวจว่า Gmail filter ใส่ label ให้ครบ แล้วสั่ง manual poll:
docker compose run --rm worker \
python manage.py poll_gmail --source <source_id>
ตรวจ canonical S3 reference จาก Django ORM โดยไม่ผูกกับ local PostgreSQL:
docker compose exec -T api \
python manage.py shell -c "from ingestion.models import FileVersion; print(list(FileVersion.objects.order_by('-created_at').values('id','original_filename','ingestion_state','trust_state','lifecycle_state','storage_bucket','storage_key','storage_version_id','sha256','size_bytes')[:10]))"
จากนั้นเลือก storage_key และ storage_version_id ของรายการหนึ่งมาตรวจ exact S3
version:
aws s3api head-object \
--bucket \
--key '<storage_key>' \
--version-id '<storage_version_id>' \
--region
Poll ซ้ำเพื่อทดสอบ duplicate
รัน manual poll ด้วย source เดิมอีกครั้ง:
docker compose run --rm worker \
python manage.py poll_gmail --source <source_id>
Attachment เดิมต้องถูกนับเป็น duplicate โดยไม่ดาวน์โหลดซ้ำ และไม่สร้าง FileVersion, staging หรือ raw-ready object เพิ่ม
เปิด worker สำหรับรับอีเมลใหม่ต่อเนื่อง
เมื่อ manual validation และ duplicate test ผ่านแล้ว ให้เปิด worker เพียงตัวเดียวสำหรับ Production:
docker compose up -d worker
docker compose ps worker
docker compose logs --tail=100 worker
Gmail: ปัญหาที่พบบ่อย
- GMAIL_CREDENTIAL_INVALID: ตรวจ wrapper JSON, mailbox และ Domain-wide Delegation
- messages: 0 หลังส่งเมล: ตรวจ recipient, label, query และเวลาเครื่อง
- AccessDenied อ่าน secret: เพิ่ม secretsmanager:GetSecretValue ให้ IAM Role
- AccessDenied PutObject: ตรวจสิทธิ S3 ทั้ง staging/raw-ready/quarantine
- DOCX/XLSX ถูกปฏิเสธ: ตรวจ macro, encryption หรือโครงสร้างไฟล์เสีย
2. Infrastructure และการติดตั้ง
กรอกค่าจริงก่อน แล้วจึงทำขั้นตอนติดตั้งจาก AWS ไปจนถึง runtime ตามลำดับ
กำหนดค่าจริงที่จะใช้ก่อนเริ่ม
ทำที่: กรอกในหน้านี้ก่อนเริ่ม Step ถัดไป ค่าจะถูกบันทึกไว้ใน
localStorage ของ Browser เครื่องนี้โดยอัตโนมัติ
Copy จะคัดลอกค่าที่กรอกล่าสุด ไม่ใช่ placeholder เดิม
,
Bucket = ,
EC2 folder = , Database =
, S3 Policy =
Phase A · AWS
S3 → IAM policies → Database → Secrets → EC2 Role → Instance
Phase B · EC2
SSM → AWS CLI → Docker →
Phase C · Runtime
.env → Build → PostgreSQL → ClamAV → API → Cloudflare → Worker → Validate
สร้าง S3 bucket สำหรับรับไฟล์
ทำที่: AWS Console → S3 → Create bucket
- Bucket name:
- Region:
- Object Ownership: Bucket owner enforced
- Block Public Access: เปิดทั้ง 4 ตัวเลือก
- Bucket Versioning: Enable
- Default encryption: validation ใช้ SSE-S3 (AES256) ได้ หรือใช้ SSE-KMS ตามนโยบายองค์กร
- กด Create bucket
สร้าง IAM policy สำหรับ S3
ทำที่: AWS Console → IAM → Policies → Create policy → JSON
ตั้งชื่อเช่น แล้วใช้ policy นี้:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InspectCorethBucket",
"Effect": "Allow",
"Action": [
"s3:GetBucketVersioning",
"s3:GetBucketPublicAccessBlock",
"s3:GetEncryptionConfiguration",
"s3:GetBucketOwnershipControls",
"s3:ListBucket",
"s3:ListBucketVersions",
"s3:ListBucketMultipartUploads"
],
"Resource": "arn:aws:s3:::"
},
{
"Sid": "UseCorethIngestionObjects",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:DeleteObjectVersion",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": [
"arn:aws:s3:::/staging/*",
"arn:aws:s3:::/raw-ready/*",
"arn:aws:s3:::/quarantine/*"
]
}
]
}
ถ้าใช้ customer-managed KMS ให้เพิ่มสิทธิ KMS เฉพาะ key ที่ใช้ เช่น
kms:Encrypt, kms:Decrypt, kms:GenerateDataKey และ
kms:DescribeKey
สร้าง IAM policy สำหรับ Secrets Manager
ทำที่: AWS Console → IAM → Policies → Create policy → JSON
ตั้งชื่อเช่น เปลี่ยน
เป็น AWS Account ID จริง:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadCorethValidationSecrets",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": [
"arn:aws:secretsmanager:::secret:-*",
"arn:aws:secretsmanager:::secret:-*",
"arn:aws:secretsmanager:::secret:-*",
"arn:aws:secretsmanager:::secret:-*"
]
}
]
}
เลือก PostgreSQL และเตรียม Database ก่อนสร้าง Secrets Manager
Database mode ที่เลือก:
- Supabase Dashboard → Project → Connect / Database settings
- ถ้า EC2 ใช้ IPv4 เป็นหลัก ให้เลือก Session pooler ที่ EC2 เข้าถึงได้
- Host:
- Port:
- Database:
— สำหรับ Supabase ปกติใช้postgres - Username:
- SSL mode:
- เก็บ Database password ไว้กรอกตรงเข้า AWS Secrets Manager เท่านั้น ห้ามใส่ลงฟอร์ม localStorage
database "... " does not exist
ให้ตรวจ dbname และใช้ชื่อ database จริงของ Supabase เช่น postgres
CORETH_ENVIRONMENT=production และให้ service postgres
อยู่ใต้ Docker Compose profile local ชื่อ profile นี้เลือกตำแหน่งฐานข้อมูล
ไม่ได้เปลี่ยน environment เป็น development
- Database:
- User:
- Host ภายใน Docker network:
postgres - Port:
5432 - SSL mode:
disableเฉพาะ Docker network local - Password สร้างตอน setup local ด้วย
openssl rand -hex 32และห้าม commit
สร้าง runtime secrets ใน AWS Secrets Manager
ทำที่: AWS Console → Secrets Manager
1) Database secret: สร้าง
แบบ Other type of secret → Plaintext:
{
"username": "",
"password": "<database-password>",
"host": "",
"port": "",
"dbname": "",
"sslmode": ""
}
CORETH_DATABASE_SECRET_REF
1) Local PostgreSQL database secret: สำหรับ production ต้องสร้าง
ใน AWS Secrets Manager แบบ
Other type of secret → Plaintext:
{
"username": "",
"password": "<same-value-as-POSTGRES_LOCAL_PASSWORD>",
"host": "postgres",
"port": "5432",
"dbname": "",
"sslmode": "disable"
}
POSTGRES_LOCAL_PASSWORD ที่สร้างในขั้นตอน .env ด้านล่าง
และสร้าง secret ให้เรียบร้อยก่อน migrate; host postgres ใช้สำหรับ container
ใน Docker Compose network
Production ต้องกำหนดทั้ง CORETH_DATABASE_SECRET_REF และ
CORETH_DJANGO_SECRET_KEY_REF แม้เลือกฐานข้อมูล local โดย application อ่านค่าจาก
AWS Secrets Manager ส่วน PostgreSQL container ใช้ POSTGRES_LOCAL_PASSWORD
จาก .env permission 600 สำหรับสร้างฐานข้อมูลครั้งแรก
2) Django secret: สุ่มค่าบนเครื่องผู้ดูแล:
openssl rand -hex 64
สร้าง secret แบบ Other type of secret →
Plaintext:
{
"DJANGO_SECRET_KEY": "<random-long-secret>"
}
3) Source secrets: Gmail/LINE สร้างตาม Tab 3 เมื่อเริ่มตั้ง Source โดย IAM Role ต้องอ่าน secret เหล่านั้นได้
สร้าง EC2 IAM Role และผูก policy
ทำที่: AWS Console → IAM → Roles → Create role
- Trusted entity type: AWS service
- Use case: EC2
- Role name:
- Attach
- Attach
- Attach
- สร้าง Role
สร้าง EC2 Ubuntu สำหรับรันระบบ
ทำที่: AWS Console → EC2 → Launch instance
- เลือก Ubuntu LTS
- Production ที่ใช้ Supabase แนะนำอย่างน้อย 2 vCPU / 8 GB RAM (เช่น t3a.large/t3.large) เพราะ ClamAV โหลด signature หลายล้านรายการ; 4 GB ใช้ validation ได้แต่มีโอกาสชน memory และ 2 GB ไม่แนะนำ
- Root EBS: เปิด encryption และกำหนดขนาดให้พอกับ Docker images/logs
- IAM instance profile:
- Security Group: ไม่ต้องเปิด SSH หากใช้ Systems Manager
- ถ้าใช้ Cloudflare Tunnel เป็นทางเข้าแอป ไม่ต้องเปิด inbound 8080 จาก Internet
- Outbound ต้องไป AWS, Google, LINE, Supabase (ถ้าใช้) และ Cloudflare ได้
- Launch instance และรอ Instance state = Running
เข้า EC2 ด้วย Systems Manager และตรวจ identity
ทำที่: AWS Console → EC2 → เลือก instance → Connect → Session Manager → Connect
whoami
pwd
id
จาก SSM session ปกติ whoami ควรคืน user ที่ session ใช้อยู่ เช่น ssm-user
จากนั้นตรวจว่า instance เห็น IAM Role (หลังติดตั้ง AWS CLI ในขั้นถัดไปจะรันซ้ำอีกครั้ง):
ติดตั้งเครื่องมือพื้นฐานและ AWS CLI บน EC2
ทำที่: ภายใน SSM session ของ EC2
set -e
sudo apt update
sudo apt install -y curl unzip git jq ca-certificates
if ! command -v aws >/dev/null 2>&1; then
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) AWS_ARCH="x86_64" ;;
aarch64|arm64) AWS_ARCH="aarch64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${AWS_ARCH}.zip" \
-o /tmp/awscliv2.zip
rm -rf /tmp/aws
unzip -q /tmp/awscliv2.zip -d /tmp
sudo /tmp/aws/install --update
rm -rf /tmp/aws /tmp/awscliv2.zip
fi
aws --version
aws sts get-caller-identity
aws sts get-caller-identity ต้องเป็น assumed-role ของ
ไม่ใช่ IAM user
ติดตั้ง Docker Engine และ Compose plugin
ทำที่: ภายใน SSM session ของ EC2
set -e
TARGET_USER="$(whoami)"
echo "Docker user: $TARGET_USER"
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
sudo systemctl enable --now docker
sudo systemctl enable containerd
sudo usermod -aG docker "$TARGET_USER"
sudo docker --version
sudo docker compose version
echo
echo "Docker installed"
echo "Added $TARGET_USER to docker group"
echo "Reconnect SSM session once before using docker without sudo"
หลังคำสั่งจบ ให้ปิด SSM session แล้วเปิดใหม่หนึ่งครั้ง จากนั้นทดสอบ:
whoami
docker ps
docker compose version
docker ได้โดยไม่ต้อง sudoสร้าง application directory และตั้ง permission
ทำที่: SSM session ใหม่ หลัง Docker group มีผล
ตั้งแต่นี้ source code และ configuration ของระบบให้อยู่ใน
set -e
TARGET_USER="$(whoami)"
APP_DIR=""
sudo install -d -m 0755 \
-o "$TARGET_USER" \
-g "$TARGET_USER" \
/home/docker
sudo install -d -m 0750 \
-o "$TARGET_USER" \
-g "$TARGET_USER" \
"$APP_DIR"
cd "$APP_DIR"
echo "Current directory: $(pwd)"
stat -c '%U:%G %a %n' /home/docker "$APP_DIR"
/home/docker ควรเป็น mode 755 และ
เป็น mode 750 โดย owner/group ต้องเป็น user จาก
whoami
chown -R /home/docker แบบกว้าง ๆ หากภายหลังมี
service อื่นอยู่ใต้ /home/docker; ขั้นตอนนี้กำหนด ownership เฉพาะ directory ที่ต้องใช้.
Clone source code ลง application directory
ทำที่: EC2 → SSM ภายใน
ใช้ URL ของ repository ผ่านช่องทางที่องค์กรอนุมัติ และ clone ลง directory นี้โดยตรง:
set -e
APP_DIR=""
cd "$APP_DIR"
git clone .
git status --short --branch
git rev-parse --short HEAD
pwd
pwd ต้องคืน
และ Git ต้องแสดง branch/commit ที่ต้องการ deployตรวจสิทธิ AWS จาก EC2 ก่อนเปิดแอป
ทำที่: EC2 → SSM
cd
aws sts get-caller-identity
aws secretsmanager get-secret-value --secret-id --region --query Name --output text
aws s3api get-bucket-versioning --bucket --region
aws s3api get-public-access-block --bucket --region
aws s3api get-bucket-encryption --bucket --region
aws s3api get-bucket-ownership-controls --bucket --region
Smoke test สิทธิ S3 จาก EC2
ทำที่: EC2 → SSM
ทดสอบ Put → Head exact VersionId → Delete exact VersionId ใน staging/ ก่อนให้ worker
ใช้งานจริง:
set -e
TEST_KEY="staging/iam-smoke-$(date +%s).txt"
printf 'coreth-s3-smoke-test\n' > /tmp/coreth-s3-smoke.txt
VERSION_ID=$(aws s3api put-object \
--bucket \
--key "$TEST_KEY" \
--body /tmp/coreth-s3-smoke.txt \
--region \
--query VersionId \
--output text)
echo "VersionId=$VERSION_ID"
aws s3api head-object \
--bucket \
--key "$TEST_KEY" \
--version-id "$VERSION_ID" \
--region
aws s3api delete-object \
--bucket \
--key "$TEST_KEY" \
--version-id "$VERSION_ID" \
--region
rm -f /tmp/coreth-s3-smoke.txt
สร้าง .env ใน application directory
ทำที่: EC2 → SSM →
cd
cp .env.example .env
chmod 600 .env
nano .env
Production + Supabase:
CORETH_ENVIRONMENT=production
AWS_REGION=
CORETH_RAW_BUCKET=
CORETH_S3_KMS_KEY_ID=
CORETH_DATABASE_SECRET_REF=aws-secretsmanager://
CORETH_DJANGO_SECRET_KEY_REF=aws-secretsmanager://
CORETH_SITE_ADDRESS=http://localhost
CORETH_SERVICE_MODE=api
CORETH_ADMIN_PORT=8081
CORETH_SECURE_SSL_REDIRECT=false
CORETH_CSRF_TRUSTED_ORIGINS=https://
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,api,,
CORETH_SECURE_SSL_REDIRECT=false
เพราะ public HTTPS จบที่ Cloudflare แต่ Tunnel เชื่อม Caddy ภายในด้วย HTTP หากเปิดค่านี้ Django จะ
redirect ไป
https://localhost ส่วน Secure cookie ยังเปิดอัตโนมัติจาก
CORETH_ENVIRONMENT=production
.env; เก็บแค่ Secret reference และ CSRF
origin ต้องตรงกับ Explorer hostnameProduction + Local PostgreSQL:
LOCAL_DB_PASSWORD="$(openssl rand -hex 32)"
printf '\nPOSTGRES_DB=\nPOSTGRES_USER=\nPOSTGRES_LOCAL_PASSWORD=%s\n' \
"$LOCAL_DB_PASSWORD" >> .env
unset LOCAL_DB_PASSWORD
chmod 600 .env
ตั้งค่า production ต่อไปนี้ใน .env โดยใช้ชื่อ secret จากฟอร์ม configuration
และใช้ password ที่สร้างด้านบนใน Database secret ด้วย:
CORETH_ENVIRONMENT=production
AWS_REGION=
CORETH_RAW_BUCKET=
CORETH_S3_KMS_KEY_ID=
CORETH_DATABASE_SECRET_REF=aws-secretsmanager://
CORETH_DJANGO_SECRET_KEY_REF=aws-secretsmanager://
CORETH_SITE_ADDRESS=http://localhost
CORETH_SERVICE_MODE=api
CORETH_ADMIN_PORT=8081
CORETH_SECURE_SSL_REDIRECT=false
CORETH_CSRF_TRUSTED_ORIGINS=https://
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,api,,
POSTGRES_LOCAL_PASSWORD และสร้าง Django secret
ที่มี key DJANGO_SECRET_KEY ด้วย จากนั้นให้ container อ่านทั้งสอง secret ได้
ผ่าน EC2 IAM Role หรือ compose override สำหรับ AWS credentials บนเครื่อง local
runtime จะใช้ Database secret แทน DATABASE_URL จาก compose
และจะหยุดทำงานหากไม่ได้กำหนด reference ทั้งสองค่าใน production
เมื่อใช้ Cloudflare Tunnel ให้คง CORETH_SECURE_SSL_REDIRECT=false
เพราะ HTTPS จบที่ Cloudflare และใช้ Explorer ผ่าน HTTPS เนื่องจาก production เปิด Secure cookie
Database/User ใช้ /
ตาม Docker Compose
.env.example, Django
settings
และ compose ของ repository หากมีตัวแปรเพิ่มให้ใช้ของ project เป็นหลัก
.env ต้องเป็น permission 600 และอยู่ใต้ application directory เท่านั้น
จัด Docker Compose ตามฐานข้อมูลที่เลือก
ทำที่: EC2 → SSM → application directory
ให้ service postgres อยู่ใน profile local เพื่อเปิดเฉพาะเมื่อเลือก Local
PostgreSQL:
services:
postgres:
image: postgres:17-alpine
profiles:
- local
environment:
POSTGRES_DB: ${POSTGRES_DB:-}
POSTGRES_USER: ${POSTGRES_USER:-}
POSTGRES_PASSWORD: ${POSTGRES_LOCAL_PASSWORD:?POSTGRES_LOCAL_PASSWORD is required}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- backend
api, worker และ
migrate
ไม่ควรผูก depends_on: postgres ร่วมกันทั้งสองโหมด เพราะ Supabase ไม่ใช้ container นี้
สำหรับ Production + Local PostgreSQL ให้เปิด postgres ก่อนด้วย --profile local
และรอ healthy ก่อน migrate
cd
docker compose config --quiet
docker compose config --services
# หา dependency ที่ยังผูกกับ postgres
docker compose config | grep -n -B4 -A8 'postgres' || true
docker compose up -d ไม่เปิด service ใน profile local โดยอัตโนมัติ
สำหรับ Production + Local PostgreSQL ต้องเปิดด้วย
docker compose --profile local up -d postgres
ตามขั้นตอนถัดไป ส่วน Supabase ไม่ต้องเปิด postgres
Build image และเปิด dependency ตาม Database mode
ทำที่: EC2 → SSM → application directory
cd
docker compose build
Production + Supabase: ไม่เปิด local PostgreSQL
docker compose up -d redis clamav
docker compose ps
Production + Local PostgreSQL:
docker compose --profile local up -d postgres redis clamav
docker compose --profile local ps
ตรวจ PostgreSQL ก่อน migrate
ทำที่: EC2 → SSM
Production + Supabase: ตรวจค่าที่เก็บใน Database secret โดยไม่แสดง password:
aws secretsmanager get-secret-value \
--secret-id \
--region \
--query SecretString \
--output text \
| jq 'del(.password)'
dbname ปกติควรเป็น
cd
docker compose --profile local exec -T postgres \
pg_isready -U -d
docker compose --profile local exec -T postgres \
psql -U -d \
-c 'select version();'
รัน migration
ทำที่: EC2 → SSM → application directory
cd
docker compose run --rm migrate
ถ้า repository ไม่มี service ชื่อ migrate ให้ใช้ command ที่ compose ของ project กำหนดจริง
เช่น
python manage.py migrate --noinput ผ่าน container ของ API
migrate ต้องอ่าน
CORETH_DATABASE_SECRET_REF และ CORETH_DJANGO_SECRET_KEY_REF ได้
สำหรับ Supabase ให้ Database secret ชี้ไปที่ Supabase; สำหรับ Local PostgreSQL ให้ชี้ไปที่
postgres:5432 และเปิด postgres ด้วย --profile local
ให้ healthy ก่อนรัน migrate โดยคง CORETH_ENVIRONMENT=production
ตรวจ ClamAV, FreshClam, DNS และ egress
ทำที่: EC2 → SSM → application directory
ClamAV ต้องออก Internet เพื่ออัปเดต signature จาก database.clamav.net
หาก backend เป็น internal: true ให้ต่อ clamav เข้ากับ egress
network เพิ่ม
clamav:
image: clamav/clamav:1.4
dns:
- 169.254.169.253
- 1.1.1.1
networks:
- backend
- egress
networks:
backend:
internal: true
egress:
driver: bridge
egress;
api และ worker ก็ต้องมี outbound ไป AWS / Supabase / Google / LINE
ตามงานที่ใช้
cd
docker compose up -d --force-recreate clamav
docker compose logs --tail=200 clamav
docker compose exec -T clamav \
getent hosts database.clamav.net
docker compose exec -T clamav \
clamdscan --ping 3
daily.cld/cvd สำเร็จ, DNS resolve ได้ และ
clamdscan --ping ได้ PONG
Socket for clamd not found ต่อเนื่อง: ตรวจ free -h และ
OOM log;
Production แนะนำ RAM 8 GB เพื่อให้ clamd โหลด signatures ได้เสถียร
เปิด API และ Caddy ก่อนเปิด worker
ทำที่: EC2 → SSM → application directory
cd
docker compose up -d api caddy
docker compose ps
docker compose logs --tail=100 api caddy
สร้าง root user สำหรับ Explorer
ทำที่: EC2 → SSM → application directory หลัง migration สำเร็จและ API ทำงานแล้ว
เปิด service admin ซึ่ง bind เฉพาะ loopback port 8081:
docker compose --profile admin up -d admin
docker compose --profile admin ps
ตรวจหรือสร้าง Customer ก่อน คำสั่งนี้จะไม่สร้างซ้ำหากมี slug coreth อยู่แล้ว:
cd
docker compose --profile admin exec -T admin \
python manage.py shell -c "from ingestion.models import Customer; c, created = Customer.objects.get_or_create(slug='coreth'); print({'customer_id': str(c.id), 'created': created})"
สร้าง root โดยเปลี่ยนอีเมลให้เป็นอีเมลผู้ดูแลจริง:
docker compose --profile admin exec admin \
python manage.py bootstrap_root_user \
--customer coreth \
--username root \
--email root@coreth.co
- คำสั่งจะถามรหัสผ่าน 2 ครั้งและไม่แสดงรหัสผ่านบนหน้าจอ
- ห้ามเติม
-Tในคำสั่งสร้าง root เพราะต้องรับรหัสผ่านจาก keyboard - ไม่มี default password ในโค้ด, Git หรือ
.env - หากลืมรหัสผ่าน ให้รันคำสั่งเดิมพร้อม
--reset-password
เมื่อ Login แล้ว root สามารถเปิดแท็บ จัดการผู้ใช้ เพื่อสร้างบัญชี ปิดบัญชี ตั้งรหัสผ่านใหม่ และกำหนดสิทธิ์ดูไฟล์ เปิดไฟล์ จัดการสถานะไฟล์ อัปโหลดไฟล์ หรือจัดการผู้ใช้ได้
ปิด Explorer โดยไม่หยุด LINE/Gmail/API:
docker compose --profile admin stop admin
รัน Doctor และแยก Infrastructure FAIL ออกจาก Pre-source FAIL
ทำที่: EC2 → SSM → application directory
cd
docker compose exec -T api \
python manage.py file_ingestion_doctor
ก่อน provision Source ค่าที่ควรผ่านอย่างน้อย:
POSTGRESQL = PASSMIGRATIONS = PASSS3_SECURITY = PASSCLAMAV = PASSWORKER = PASSเมื่อ worker ถูกเปิด
SECRET_REFERENCES อาจยัง FAIL เพราะ configured = 0 ก่อนสร้าง LINE/Gmail Source;
DB_S3_RECONCILIATION อาจ WARN และ BACKUP/RESTORE_REHEARSAL
อาจยัง FAIL จนกว่าจะทำ operational validation ภายหลัง
สร้าง Cloudflare Access Application สำหรับ Explorer
ทำที่: Cloudflare Dashboard → Zero Trust → Access controls → Applications
cloudflared Tunnel; ไม่ต้องเปิด Mesh เพื่อให้ Explorer/Webhook ทำงาน
- Create application → Self-hosted / Self-hosted and private
- Application name:
- Public hostname:
- สร้าง Policy: Action = Allow
- Include เฉพาะอีเมล/โดเมนองค์กรที่อนุญาต เช่น
@coreth.co - เลือก Identity Provider เช่น Google Workspace หรือ One-time PIN ตามองค์กร
- ห้าม Allow Everyone สำหรับ Explorer
- Save Application ให้เสร็จก่อนผูก JWT validation ใน Tunnel route
Unable to find your Access application
สร้าง Cloudflare Tunnel และติดตั้ง cloudflared บน EC2
ทำที่: Cloudflare Dashboard → Zero Trust → Networks → Tunnels & Mesh → Create tunnel
- เลือก Tunnel แบบ Cloudflared ไม่ใช่ Mesh/WARP Connector
- ตั้งชื่อ
- หน้า Install connector: EC2 เป็น Ubuntu ให้เลือก platform Debian
- Cloudflare จะแสดง installation command พร้อม tunnel token
- Copy command ที่ Cloudflare สร้างให้จริงไป run ใน EC2 SSM session
- ห้ามบันทึก tunnel token ลง Git,
.env, HTML guide หรือ chat - รอ Tunnel / Connector status = Healthy
sudo systemctl status cloudflared --no-pager
sudo journalctl -u cloudflared -n 100 --no-pager
cloudflared เป็น system service ของ EC2; application source ยังคงอยู่ที่
สร้าง Published application routes สำหรับ Explorer และ LINE webhook
ทำที่: Cloudflare Dashboard → Networks → Tunnels & Mesh →
→
Published application routes
coreth.co เช่นชื่อด้านล่าง
แทนรูปแบบ files.thinkberry-file-ingestion.coreth.co
เพื่อหลีกเลี่ยงปัญหา TLS ของ wildcard certificate ชั้นเดียว
Route 1 — Explorer
- Hostname:
- Path: เว้นว่าง
- Service type: HTTP
- Service URL:
http://localhost:8081 - HTTP Host Header:
localhost - Enforce Access JWT validation: On
- เลือก Access Application:
ที่ hostname ตรงกับ Explorer hostname นี้
Route 2 — LINE webhook
- Hostname:
- Path:
/v1/line/webhook - Service type: HTTP
- Service URL:
http://localhost:8080 - HTTP Host Header:
localhost - Enforce Access JWT validation: Off
- ไม่ผูก Access login — LINE Platform login ผ่าน Cloudflare Access ไม่ได้
- Django ต้องตรวจ
X-Line-Signatureจาก raw request body ทุก request
/v1/line/webhook
ตรวจ DNS หลัง Save:
dig @1.1.1.1 +short
dig @1.1.1.1 +short
ตรวจ LINE route จากเครื่องภายนอก:
curl -i \
-X POST \
'https:///v1/line/webhook' \
-H 'Content-Type: application/json' \
-d '{}'
X-Line-Signature;
ถ้าเห็น Access login = hooks ถูกผูก Access ผิด, ถ้า 502 = cloudflared ไป localhost:8080 ไม่ถึง
ตรวจ Explorer: เปิด Incognito ไปที่
https://
ต้องเจอ Cloudflare Access login ก่อน จากนั้นต้องเจอหน้า Login ของ CORETH Explorer
ให้ Login ด้วย root และตรวจว่าเห็นแท็บ ไฟล์ กับ จัดการผู้ใช้
สลับมุมมอง list/grid ได้, breadcrumb ย้อนระดับได้ และไฟล์ DOCX ดาวน์โหลดด้วยชื่อเดิม
ปิด public inbound ที่ไม่จำเป็น
ทำที่: AWS Console → EC2 → Security Groups
- ปิด inbound 8080/8443 จาก Internet
- ถ้าใช้ Cloudflare Tunnel ทั้งหมด สามารถไม่เปิด inbound 80/443 ของแอปได้
- คงช่องทางดูแลเครื่องผ่าน Systems Manager
- อย่าสร้าง DNS-only record ที่ชี้ public IP ของ EC2 สำหรับบริการนี้
ตั้ง Source จาก Tab 3 แล้วจึงเปิด worker
ทำที่: กลับไป Tab 3 ของคู่มือนี้
ทำ LINE หรือ Gmail ตาม flow ของ source นั้น โดยตั้ง cutoff/provision ให้เสร็จก่อนเปิดรับ event จริง
หลัง Source ถูกต้องแล้วกลับมาที่ EC2:
cd
docker compose up -d worker
docker compose ps
docker compose logs --tail=200 api worker
Final validation ก่อนถือว่าติดตั้งเสร็จ
ทำที่: EC2 SSM + Browser + AWS Console
cd
docker compose ps
docker compose exec -T api python manage.py file_ingestion_doctor
docker compose logs --tail=200 api worker
- S3 private, encrypted, Versioning Enabled และ BucketOwnerEnforced
- EC2 ใช้ assumed IAM Role และไม่มี AWS access key ในเครื่อง
- Application อยู่ใน
และ permission ถูกต้อง .envเป็น mode 600- Database เชื่อมต่อและ migrations ผ่าน
- สร้าง root ของ Explorer แล้ว และ Login ด้วย username/password ได้
- root สร้างผู้ใช้และกำหนดสิทธิ์จัดการ user/file ได้
- ClamAV พร้อมสแกน
- Cloudflare Tunnel Healthy
- Explorer ถูก Cloudflare Access ป้องกัน
- Explorer เปิด/ปิดได้ด้วย service
adminและ bind เฉพาะ127.0.0.1:8081 - LINE/Gmail รับเฉพาะ event หลัง cutoff
- ไฟล์ผ่าน validation แล้วไป READY/CLEAN/ACTIVE
- storage bucket/key/VersionId ใน PostgreSQL ตรงกับ exact S3 version
- duplicate event ไม่สร้าง FileVersion/S3 object ใหม่
- Explorer Published route = files-thinkberry-file-ingestion.coreth.co และ Access JWT = On
- LINE Published route = hooks-thinkberry-file-ingestion.coreth.co/v1/line/webhook และ Access JWT = Off
- Production ทั้งสองโหมดใช้ Database Secret และ Django Secret พร้อมตั้ง CORETH_ENVIRONMENT=production
- Supabase: ไม่มี local postgres container ทำงาน
- Local PostgreSQL: postgres container healthy และ Database secret ตรงกับฐานข้อมูลใน container