SSD Nodes Learn 🎉 VPS เริ่ม $5.50/เดือน
คู่มือ Matt Connorโดย Matt Connor

วิธีติดตั้ง ntfy บน VPS ของตัวเองด้วย Docker Compose

เรียนรู้วิธีติดตั้งเซิร์ฟเวอร์ ntfy บน VPS ของคุณเองผ่าน Docker Compose พร้อมตั้งค่า TLS และระบบ ACL เพื่อความปลอดภัยในการส่งแจ้งเตือนจาก cron และ systemd OnFailure

หน้าที่ของเซิร์ฟเวอร์ ntfy ที่โฮสต์ด้วยตนเอง

เซิร์ฟเวอร์ ntfy ที่คุณโฮสต์เองจะเปลี่ยนคำขอ HTTP POST ให้กลายเป็นการแจ้งเตือนแบบ push บนโทรศัพท์ของคุณ คุณสามารถเผยแพร่ข้อความด้วย curl แล้วข้อความจะไปปรากฏบนแอป Android, แอป iOS, แท็บเบราว์เซอร์ หรืออุปกรณ์ใดก็ตามที่สามารถเปิดการเชื่อมต่อ HTTP ค้างไว้ได้ โดยไม่จำเป็นต้องติดตั้งไลบรารีฝั่งไคลเอ็นต์หรือรัน message broker ใดๆ เพิ่มเติม

ntfy ระบุข้อความด้วย topic ซึ่ง topic คือชื่อที่อยู่ใน URL path เช่น https://ntfy.example.com/alerts และมันจะถูกสร้างขึ้นทันทีที่มีคนเผยแพร่ข้อความไปยังชื่อนั้น ในการติดตั้งค่าเริ่มต้น ใครก็ตามที่ทราบชื่อ topic จะสามารถอ่านและเขียนข้อความใน topic นั้นได้ ด้วยเหตุนี้เอกสารของโครงการจึงเปรียบเทียบชื่อ topic ว่ามีค่าเสมือนรหัสผ่าน รูปแบบนี้อาจเหมาะสมสำหรับบริการสาธารณะอย่าง ntfy.sh แต่ไม่ปลอดภัยสำหรับเซิร์ฟเวอร์ที่ใช้แจ้งเตือนความล้มเหลวของการสำรองข้อมูล ดังนั้นคู่มือนี้จึงแนะนำให้เปิดใช้งานการยืนยันตัวตนก่อนที่จะมีการส่งข้อความแรกเกิดขึ้น

สิ่งที่ต้องเตรียมก่อนเริ่มต้น

คุณต้องมี VPS ที่รัน Ubuntu 24.04 หรือ Debian 13 พร้อมติดตั้ง Docker Engine และปลั๊กอิน Compose รวมถึงชื่อโดเมน และใช้ RAM เพียงเล็กน้อย ให้สร้าง DNS (domain name system) A record โดยชี้ ntfy.example.com ไปยังที่อยู่ IP สาธารณะของเซิร์ฟเวอร์ จากนั้นตรวจสอบให้แน่ใจว่าโดเมนดังกล่าวสามารถ resolve ได้จริงก่อนที่จะดำเนินการขั้นตอนอื่น

dig +short ntfy.example.com
sudo ufw allow 80,443/tcp
sudo ufw status

dig จะต้องแสดงผลเป็น IP ของเซิร์ฟเวอร์คุณ หากคำสั่งนี้ไม่แสดงผลใดๆ การออกใบรับรองจะล้มเหลว เนื่องจากผู้ออกใบรับรอง (certificate authority) จะตรวจสอบชื่อโดเมนจากภายนอก พอร์ต 80 จะต้องเปิดไว้เนื่องจาก ACME (automatic certificate management environment) ซึ่งเป็นโปรโตคอลเบื้องหลัง Let's Encrypt จำเป็นต้องใช้พอร์ตนี้สำหรับการทำ HTTP challenge โดยตัวคอนเทนเนอร์ ntfy เองไม่จำเป็นต้องเปิดพอร์ตสาธารณะแต่อย่างใด

เขียนไฟล์คอนฟิกของ ntfy

อิมเมจ Docker ไม่มีไฟล์คอนฟิกมาให้ คุณจึงต้องสร้างขึ้นมาเอง คำสั่งทั้งหมดในคู่มือนี้จะอ่านค่าจากไฟล์ดังกล่าว ก่อนอื่นให้หา user ID และ group ID ที่คอนเทนเนอร์จะใช้รัน

id -u
id -g
sudo install -d -o "$(id -u)" -g "$(id -g)" /etc/ntfy /var/cache/ntfy /var/lib/ntfy
sudo nano /etc/ntfy/server.yml
base-url: "https://ntfy.example.com"
listen-http: ":2586"
behind-proxy: true
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "12h"
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-login: true
enable-signup: false

มี 4 บรรทัดที่สำคัญมาก base-url ต้องเป็นที่อยู่ HTTPS สาธารณะที่ถูกต้องเท่านั้น เพราะ ntfy ใช้ค่านี้ในการสร้างลิงก์ไฟล์แนบและคำขอของเว็บแอป หากค่านี้ผิด เว็บแอปจะโหลดขึ้นมาได้แต่จะทำงานไม่ได้เลย listen-http: ":2586" คือการ bind เข้ากับทุกอินเทอร์เฟซภายในคอนเทนเนอร์ ซึ่งดูเหมือนไม่ปลอดภัยแต่เป็นสิ่งที่ถูกต้องแล้ว เนื่องจากคอนเทนเนอร์มี network namespace ของตัวเอง การ bind เข้ากับ 127.0.0.1 จะทำให้พอร์ตไม่สามารถเข้าถึงได้จากโฮสต์ และพอร์ตที่เผยแพร่ผ่าน Docker จะไม่สามารถเชื่อมต่อได้ auth-default-access: "deny-all" คือมาตรการรักษาความปลอดภัยหลัก เพราะจะปฏิเสธการอ่านและเขียนของทุกคนที่ไม่มีสิทธิ์อนุญาตโดยชัดแจ้ง behind-proxy: true สั่งให้ ntfy ดึงที่อยู่ของไคลเอนต์มาจาก header X-Forwarded-For เพื่อให้การจำกัดอัตรา (rate limit) นับจำนวนผู้ใช้งานจริง แทนที่จะนับว่า reverse proxy เป็นไคลเอนต์เพียงรายเดียวที่ใช้งานหนัก

enable-login: true ช่วยให้เว็บแอปและแอปบนโทรศัพท์สามารถล็อกอินด้วยรหัสผ่านได้ enable-signup ต้องตั้งค่าเป็น false เสมอ เนื่องจากการเปิดให้สร้างบัญชีเองบนเซิร์ฟเวอร์ส่วนตัวเปรียบเสมือนการเปิดประตูทิ้งไว้

sudo chown "$(id -u):$(id -g)" /etc/ntfy/server.yml
sudo chmod 600 /etc/ntfy/server.yml

การรัน ntfy ด้วย Docker Compose

ใส่เนื้อหานี้ลงใน /opt/ntfy/compose.yaml โดยแทนที่ 1000:1000 ด้วยตัวเลขสองตัวคือ id -u และ id -g ที่แสดงอยู่ด้านบน

services:
  ntfy:
    image: binwiederhier/ntfy:v2.27.0
    container_name: ntfy
    command: serve
    user: "1000:1000"
    environment:
      - TZ=UTC
    volumes:
      - /etc/ntfy:/etc/ntfy
      - /var/cache/ntfy:/var/cache/ntfy
      - /var/lib/ntfy:/var/lib/ntfy
    ports:
      - "127.0.0.1:2586:2586"
    restart: unless-stopped
cd /opt/ntfy
sudo docker compose up -d
sudo docker compose logs ntfy
curl -s http://127.0.0.1:2586/v1/health

เซิร์ฟเวอร์ที่ทำงานปกติจะตอบสนองด้วย {"healthy":true} มีรายละเอียดสองประการในไฟล์ compose นั้นที่ตั้งใจกำหนดไว้ ประการแรก image ถูกล็อกเวอร์ชันไว้ที่ v2.27.0 ซึ่งเป็น release ปัจจุบัน ณ เดือนสิงหาคม 2026 แทนที่จะใช้ latest เพราะหากใช้ latest แล้วมีการปล่อย docker compose pull ถัดไป เวอร์ชันบนเซิร์ฟเวอร์ของคุณจะเปลี่ยนไปโดยที่คุณเพิ่งจะทราบจาก changelog ในภายหลัง ประการที่สอง พอร์ตถูกประกาศไว้เป็น 127.0.0.1:2586:2586 เพื่อให้ container สามารถเข้าถึงได้จาก loopback address ของ host เท่านั้น หากคุณเขียนเป็น 2586:2586 แทน Docker จะแทรกกฎ firewall ของตนเองนำหน้ากฎของคุณ ซึ่งหมายความว่าพอร์ตจะตอบสนองจากอินเทอร์เน็ตแม้ว่า ufw status จะระบุว่าพอร์ตนั้นปิดอยู่ก็ตาม

หาก curl แสดงผลเป็น Connection refused ให้ตรวจสอบ log ของ container หากพบข้อผิดพลาดด้านสิทธิ์การเข้าถึงที่ /var/lib/ntfy/user.db แสดงว่าบรรทัด user: ไม่ตรงกับเจ้าของไดเรกทอรีเหล่านั้น ทำให้ process ไม่สามารถสร้างฐานข้อมูลของตนเองได้และหยุดทำงาน คู่มือพื้นฐาน Docker Compose สำหรับ VPS ได้อธิบายเรื่องความเป็นเจ้าของ volume และนโยบายการ restart ไว้โดยละเอียดแล้ว

การติดตั้ง TLS ด้วย Caddy

Caddy จะร้องขอและต่ออายุใบรับรองด้วยตนเอง ซึ่งเป็นวิธีที่รวดเร็วที่สุดในการใช้งาน TLS (transport layer security)

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

แทนที่เนื้อหาใน /etc/caddy/Caddyfile ด้วยสามบรรทัดต่อไปนี้

ntfy.example.com {
    reverse_proxy 127.0.0.1:2586
}
sudo systemctl reload caddy
curl -s https://ntfy.example.com/v1/health

การเข้าถึง {"healthy":true} ผ่าน HTTPS ได้สำเร็จหมายความว่าเส้นทางทั้งหมดทำงานได้ตามปกติ หากได้รับ 502 จาก Caddy แสดงว่า ntfy ไม่ได้เปิดรับการเชื่อมต่อ ให้ตรวจสอบด้วย sudo ss -lntp | grep 2586 ข้อผิดพลาดเกี่ยวกับใบรับรองมักเกิดจากระเบียน DNS ไม่ถูกต้องหรือพอร์ต 80 ถูกบล็อก โดย sudo journalctl -u caddy -n 50 จะระบุว่าเกิดจากสาเหตุใด

หากคุณใช้งาน nginx อยู่แล้ว ให้คัดลอกการตั้งค่า proxy ตามที่ ntfy ระบุไว้ ได้แก่ proxy_http_version 1.1, proxy_buffering off, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for และตั้งค่า read และ send timeout อย่างน้อยสามนาที เนื่องจากผู้รับ (subscriber) จะเปิดการเชื่อมต่อ HTTP ค้างไว้ตลอดเวลาที่รอรับข้อมูล ในขณะที่ nginx จะปิดการเชื่อมต่อ upstream ที่ไม่ได้ใช้งานหลังจาก 60 วินาทีโดยค่าเริ่มต้น ส่งผลให้ผู้รับต้องเชื่อมต่อใหม่ซ้ำๆ และข้อความที่ส่งมาในช่วงเวลาดังกล่าวจะสูญหายไป

การสร้างผู้ใช้และการจำกัดสิทธิ์หัวข้อ

การยืนยันตัวตนถูกเปิดใช้งานแล้วและขณะนี้ยังไม่มีใครเข้าถึงสิ่งใดได้ ซึ่งเป็นจุดประสงค์หลัก ให้สร้างบัญชีผู้ดูแลระบบหนึ่งบัญชีสำหรับตัวคุณเอง และบัญชีเครื่องหนึ่งบัญชีสำหรับสคริปต์ คำสั่งเหล่านี้อ่าน /etc/ntfy/server.yml จากภายในคอนเทนเนอร์ ซึ่งเป็นเหตุผลว่าทำไมไฟล์คอนฟิกจึงต้องถูก mount เป็น volume

sudo docker compose exec ntfy ntfy user add --role=admin admin
sudo docker compose exec ntfy ntfy user add robot
sudo docker compose exec ntfy ntfy user list

แต่ละคำสั่งจะแจ้งให้คุณระบุรหัสผ่าน ผู้ดูแลระบบจะเพิกเฉยต่อรายการควบคุมการเข้าถึงและสามารถอ่านหรือเขียนหัวข้อใดก็ได้ ดังนั้นให้เก็บบัญชีนั้นไว้สำหรับตัวคุณเองและแอปบนโทรศัพท์ robot เป็นผู้ใช้ทั่วไปที่ไม่มีสิทธิ์เข้าถึงใดๆ จนกว่าคุณจะกำหนดสิทธิ์ให้

sudo docker compose exec ntfy ntfy access robot alerts write
sudo docker compose exec ntfy ntfy access robot "alerts_*" write
sudo docker compose exec ntfy ntfy access

รายการ ACL (access control list) ประกอบด้วยผู้ใช้ หัวข้อ และสิทธิ์การเข้าถึง หัวข้ออาจเป็นชื่อเฉพาะหรือรูปแบบที่ * ใช้แทนค่าใดๆ ก็ได้ ดังนั้น alerts_* จึงครอบคลุมถึง alerts_backup และ alerts_db โดยไม่จำเป็นต้องใช้คำสั่งแยกตามโฮสต์ สิทธิ์ write หมายถึงการเผยแพร่ข้อมูลเท่านั้น ดังนั้นหากโทเค็นจากงาน cron ถูกขโมยไป ผู้บุกรุกจะไม่สามารถสมัครรับข้อมูลและอ่านสิ่งที่ส่งไปก่อนหน้านี้ได้ ชื่อผู้ใช้พิเศษ everyone ใช้กำหนดสิ่งที่ผู้เยี่ยมชมที่ไม่ได้ยืนยันตัวตนสามารถทำได้ ซึ่งคุณควรใช้เฉพาะเมื่อต้องการเปิดเผยข้อมูลต่อสาธารณะอย่างตั้งใจเท่านั้น เช่น ntfy access everyone status read

สคริปต์ควรใช้โทเค็นแทนการใช้รหัสผ่านของคุณ

sudo docker compose exec ntfy ntfy token add robot

คำสั่งนี้จะแสดงโทเค็นที่ขึ้นต้นด้วย tk_ โทเค็นจะได้รับสิทธิ์การเข้าถึงทั้งหมดตามผู้ใช้ที่เป็นเจ้าของ ดังนั้นโทเค็นนี้จึงสามารถเผยแพร่ข้อมูลไปยังหัวข้อ alerts ได้เท่านั้นและไม่สามารถทำอย่างอื่นได้ ntfy token list ใช้เพื่อแสดงรายการที่มีอยู่ และ ntfy token remove ใช้เพื่อเพิกถอนโทเค็นโดยไม่ส่งผลกระทบต่อรหัสผ่านของผู้ใช้

ส่งข้อความแรกของคุณและตรวจสอบว่าการล็อกทำงานได้จริง

เริ่มต้นด้วยการตรวจสอบว่าประตูถูกปิดอยู่

curl -s -o /dev/null -w '%{http_code}\n' -d "hello" https://ntfy.example.com/alerts

คำสั่งดังกล่าวจะแสดงผล 403 และ 403 คือคำตอบที่ถูกต้อง: auth-default-access: "deny-all" ปฏิเสธการเผยแพร่แบบไม่ระบุตัวตน ตอนนี้ให้ส่งข้อความจริง

curl -H "Authorization: Bearer tk_REPLACE_WITH_YOUR_TOKEN" \
  -H "Title: Nightly backup finished" \
  -H "Priority: default" \
  -H "Tags: white_check_mark" \
  -d "42 GB copied in 11 minutes" \
  https://ntfy.example.com/alerts

เซิร์ฟเวอร์จะตอบกลับด้วยข้อความที่จัดเก็บไว้ในรูปแบบ JSON ซึ่งเป็นวิธีที่คุณจะทราบว่าข้อความได้รับการยอมรับแทนที่จะถูกละทิ้ง Title คือบรรทัดแรกที่เป็นตัวหนา Priority มีค่าตั้งแต่ 1 ถึง 5 หรือระบุด้วยชื่อจาก min ถึง urgent ซึ่งจะเป็นตัวกำหนดว่าโทรศัพท์จะส่งเสียงหรือไม่ Tags จะกลายเป็นอีโมจิบนการแจ้งเตือนเมื่อชื่อตรงกับรหัสย่อของอีโมจิที่รู้จัก และจะยังคงเป็นข้อความธรรมดาหากไม่ตรงกัน

หากต้องการติดตามหัวข้อจากเทอร์มินัล ให้ทำการสตรีมข้อมูล:

curl -s -u admin https://ntfy.example.com/alerts/raw

curl จะแจ้งให้คุณป้อนรหัสผ่าน ข้อความแต่ละข้อความจะมาถึงทีละหนึ่งบรรทัด และบรรทัดว่างที่ปรากฏขึ้นเป็นระยะคือสัญญาณ keepalive การเปิด https://ntfy.example.com ในเบราว์เซอร์และลงชื่อเข้าใช้ด้วยบัญชีเดียวกันจะทำให้คุณใช้งานสตรีมเดียวกันในรูปแบบเว็บแอปได้

กำหนดอัตราการเข้าถึงเพื่อป้องกันไม่ให้สคริปต์เดียวส่งคำขอท่วมเซิร์ฟเวอร์

โดยค่าเริ่มต้น ผู้เข้าชมแต่ละรายจะได้รับโควตา 60 คำขอ ซึ่งจะถูกเติมกลับในอัตรา 1 คำขอทุกๆ 5 วินาที ค่านี้ถือว่าเพียงพอสำหรับเซิร์ฟเวอร์ส่วนตัว แต่สคริปต์ที่ติดอยู่ในลูปการลองใหม่ (retry loop) จะใช้โควตานี้จนหมด ให้เพิ่มการจำกัดอัตราใน server.yml

visitor-request-limit-burst: 30
visitor-request-limit-replenish: "10s"
visitor-message-daily-limit: 500
sudo docker compose restart ntfy

ผู้เข้าชมที่เกินขีดจำกัดจะได้รับ HTTP 429 แทนที่จะได้รับข้อความตามปกติ การจำกัดนี้จะนับตามที่อยู่ของผู้เข้าชม ซึ่งเป็นเหตุผลว่าทำไม behind-proxy: true จึงมีความสำคัญมาก หากไม่มีการตั้งค่านี้ ntfy จะเห็นเพียงที่อยู่ของ Caddy เท่านั้น ทำให้ไคลเอนต์ทุกรายถูกนับเป็นผู้เข้าชมคนเดียวกัน และสคริปต์ที่ทำงานผิดปกติเพียงตัวเดียวจะใช้โควตาจนหมด ซึ่งส่งผลกระทบต่อโทรศัพท์และเซิร์ฟเวอร์เครื่องอื่นของคุณที่ใช้งานร่วมกัน

การแจ้งเตือนเมื่อ cron job ล้มเหลว

อย่าใส่ token ไว้ในบรรทัดคำสั่ง ps aux จะแสดงบรรทัดคำสั่งเต็มของทุกกระบวนการที่กำลังทำงานให้ผู้ใช้ทุกคนบนเครื่องเห็น ดังนั้น token ที่ส่งผ่าน -H จึงถูกอ่านได้โดยบัญชีผู้ใช้ในเครื่องทุกบัญชีตราบเท่าที่ curl ยังทำงานอยู่ การใช้ไฟล์คอนฟิกของ curl จะช่วยป้องกันปัญหานี้ได้

sudo install -d -m 700 /etc/ntfy-alert
printf 'header = "Authorization: Bearer tk_REPLACE_WITH_YOUR_TOKEN"\n' | sudo tee /etc/ntfy-alert/curlrc
sudo chmod 600 /etc/ntfy-alert/curlrc

จากนั้นให้ครอบคำสั่งงานของคุณ บันทึกไฟล์นี้เป็น /usr/local/bin/backup-with-alert.sh และตั้งค่า chmod 750 ให้เรียบร้อย

#!/bin/bash
out=$(/usr/local/bin/backup.sh 2>&1)
code=$?
if [ "$code" -ne 0 ]; then
  printf '%s' "$out" | tail -c 1000 | curl -K /etc/ntfy-alert/curlrc \
    -H "Title: backup.sh failed with exit $code" \
    -H "Priority: high" \
    -H "Tags: warning" \
    --data-binary @- \
    https://ntfy.example.com/alerts
fi
exit "$code"
17 3 * * * /usr/local/bin/backup-with-alert.sh >> /var/log/backup-alert.log 2>&1

$? จะถูกบันทึกไว้ในบรรทัดถัดจากคำสั่งทันที เพราะคำสั่งถัดไปที่ทำงานจะเขียนทับค่านี้ ผลลัพธ์จะถูกส่งผ่าน tail -c 1000 เนื่องจาก ntfy มีการจำกัดขนาดข้อความสูงสุดและการแจ้งเตือนไม่ใช่เครื่องมือสำหรับดู log การปิดท้ายด้วย exit "$code" จะช่วยรักษาค่าสถานะเดิมไว้ เพื่อให้ระบบอื่นที่เฝ้าติดตามงานนี้ยังคงเห็นว่าเกิดความล้มเหลว ทดสอบการทำงานทั้งหมดโดยชี้สคริปต์ไปที่ /bin/false สำหรับการรันหนึ่งครั้ง

การมีเงื่อนไขความล้มเหลวที่ไม่เคยทำงานนั้นแย่ยิ่งกว่าการไม่มีการแจ้งเตือน เพราะมันทำให้เข้าใจผิดว่าความเงียบหมายถึงความสำเร็จ Cron จะกำหนด environment ที่เกือบว่างเปล่าและ PATH ที่สั้นกว่า shell ของคุณมากให้กับงานของคุณ ดังนั้นสคริปต์ที่ทำงานได้เมื่อคุณรันด้วยตัวเองอาจหยุดทำงานก่อนที่จะไปถึงบรรทัด curl คู่มือว่าด้วยสาเหตุที่ cron job ไม่ทำงาน ครอบคลุมถึงกับดักด้าน environment เหล่านี้ ให้ใช้ absolute path เสมอ และอ่านไฟล์ log หลังจากที่งานรันตามกำหนดการครั้งแรกแทนการคาดเดาเอาเอง

การแจ้งเตือนเมื่อ systemd unit ล้มเหลว

Cron เหมาะสำหรับงานที่ตั้งเวลาไว้ แต่บริการที่ทำงานต่อเนื่องยาวนานจำเป็นต้องใช้ OnFailure= ซึ่ง systemd จะเรียกใช้งานเมื่อ unit เข้าสู่สถานะ failed ให้สร้าง template unit ขึ้นมาหนึ่งชุดแล้วนำไปใช้ซ้ำกับทุกบริการบนเซิร์ฟเวอร์ โดยบันทึกไฟล์นี้เป็น /etc/systemd/system/ntfy-unit-failed@.service

[Unit]
Description=Send an ntfy alert because %i failed

[Service]
Type=oneshot
ExecStart=/usr/local/bin/ntfy-unit-failed %i

จากนั้นให้ /usr/local/bin/ntfy-unit-failed โดยกำหนดสิทธิ์เป็น 750:

#!/bin/bash
unit="$1"
journalctl -u "$unit" -n 15 --no-pager -o cat | tail -c 1000 | curl -K /etc/ntfy-alert/curlrc \
  -H "Title: $unit failed on $(hostname -s)" \
  -H "Priority: urgent" \
  -H "Tags: rotating_light" \
  --data-binary @- \
  https://ntfy.example.com/alerts

แนบไฟล์นี้เข้ากับบริการด้วย drop-in เพื่อป้องกันไม่ให้การอัปเกรดแพ็กเกจเขียนทับการตั้งค่าของคุณ

sudo systemctl edit myapp.service
[Unit]
OnFailure=ntfy-unit-failed@%n.service

%n จะขยายผลเป็นชื่อ unit เต็ม ทำให้ instance กลายเป็น ntfy-unit-failed@myapp.service และ %i ภายใน template จะส่ง myapp.service ไปยังสคริปต์ในฐานะอาร์กิวเมนต์ตัวแรก นี่คือสิ่งที่ทำให้ template เดียวสามารถรองรับทุก unit ได้ ทดสอบการทำงานด้วย unit ที่ตั้งใจให้ล้มเหลว โดยบันทึกเป็น /etc/systemd/system/ntfy-selftest.service

[Unit]
Description=Deliberately failing unit
OnFailure=ntfy-unit-failed@%n.service

[Service]
Type=oneshot
ExecStart=/bin/false
sudo systemctl daemon-reload
sudo systemctl start ntfy-selftest.service

คำสั่ง start จะจบการทำงานด้วยสถานะ non-zero และแสดงข้อความ Job for ntfy-selftest.service failed because the control process exited with error code จากนั้นโทรศัพท์ของคุณควรจะสั่นแจ้งเตือนในอีกประมาณหนึ่งวินาทีถัดมา ให้ลบ unit ทดสอบทิ้งหลังจากเสร็จสิ้น

มีกับดักหนึ่งจุดที่ควรระวัง OnFailure= จะทำงานก็ต่อเมื่อ unit เข้าสู่สถานะ failed เท่านั้น และบริการที่มี Restart=always อาจไม่มีวันเข้าสู่สถานะดังกล่าวได้ เพราะ systemd จะพยายามรีสตาร์ทบริการนั้นซ้ำไปเรื่อยๆ unit จะถือว่าล้มเหลวก็ต่อเมื่อเกินจำนวน StartLimitBurst ครั้งภายในระยะเวลา StartLimitIntervalSec เท่านั้น ให้กำหนดค่าทั้งสองนี้ในบริการที่คุณต้องการรับการแจ้งเตือน มิฉะนั้นบริการที่ค้าง (crash loop) อาจทำงานผิดพลาดเงียบๆ อยู่หลายวัน Timers เป็นทางเลือกที่สะอาดกว่ารูปแบบ cron ข้างต้น เนื่องจาก service unit ของ timer จะได้รับ OnFailure= โดยอัตโนมัติ และ คู่มือการใช้งาน systemd services และ timers บน VPS จะอธิบายขั้นตอนการแปลงรูปแบบดังกล่าวไว้โดยละเอียด

การเชื่อมต่อระบบติดตามสถานะ uptime เข้ากับหัวข้อเดียวกัน

Uptime Kuma ซึ่งเป็นระบบติดตามสถานะแบบ self-hosted มีประเภทการแจ้งเตือนแบบ ntfy มาให้ในตัว ให้เปิดไปที่ Settings จากนั้นเลือก Notifications แล้วเลือก Setup Notification จากนั้นเลือก Ntfy โดยตั้งค่า server URL เป็น https://ntfy.example.com และระบุหัวข้อ (topic) เป็น alerts เลือกความสำคัญ (priority) และวาง access token robot ลงไป ควรส่งการแจ้งเตือนทดสอบก่อนทำการบันทึก เพราะหากระบุชื่อหัวข้อผิด ระบบจะไม่แจ้งเตือนใดๆ หากสิทธิ์ write ที่ได้รับไม่ครอบคลุมหัวข้อนั้น

ข้อจำกัดตามความเป็นจริงของการตั้งค่านี้คือ หากระบบติดตามทำงานอยู่บน VPS เดียวกัน มันจะไม่สามารถแจ้งเตือนคุณได้หาก VPS นั้นล่ม และ ntfy ก็ไม่สามารถส่งข่าวได้หากตัว ntfy เองล่ม คุณควรเรียกใช้งานระบบติดตามบนเครื่องอื่น และเพิ่มช่องทางการแจ้งเตือนสำรอง เช่น อีเมล สำหรับระบบติดตามที่คอยตรวจสอบ ntfy อีกทีหนึ่ง ประเภทการตรวจสอบแบบ Push ของ Uptime Kuma ช่วยปิดจุดบอดอีกด้านได้ โดยให้ cron job ของคุณเรียก URL แบบ push หลังจากทำงานสำเร็จ และ Kuma จะแจ้งเตือนเมื่อการเรียกเหล่านั้นหยุดลง อย่างไรก็ตาม เงื่อนไขความล้มเหลวจะทำงานก็ต่อเมื่อ job เริ่มทำงานเท่านั้น ดังนั้นมันจึงไม่สามารถบอกได้ว่า job ที่ไม่เคยเริ่มทำงานนั้นเกิดปัญหาขึ้น

ntfy แบบ self-hosted ใช้งานบน Android และ iPhone ได้หรือไม่

บน Android สามารถใช้งานได้โดยไม่มีข้อจำกัด ติดตั้งแอปจาก Google Play หรือ F-Droid เปิด Settings ตั้งค่า default server เป็น https://ntfy.example.com เพิ่มบัญชีของคุณในหน้าการจัดการผู้ใช้ จากนั้นกดติดตาม alerts ระบบ instant delivery จะรัน foreground service ไว้เพื่อให้ข้อความส่งถึงแม้โทรศัพท์จะอยู่ในโหมด doze และการแจ้งเตือนแบบถาวรที่ปรากฏขึ้นเป็นข้อกำหนดของ Android สำหรับ foreground service ไม่ใช่ข้อผิดพลาดของแอป ตัว build จาก F-Droid ไม่มีโค้ดของ Firebase อยู่เลย ดังนั้นทุกการติดตามจะใช้ instant delivery ทั้งหมด นอกจากนี้ ntfy ยังทำหน้าที่เป็น UnifiedPush distributor ซึ่งเป็นทางเลือกแบบเปิดแทนบริการ push ของ Google ทำให้แอปอื่นที่รองรับ UnifiedPush สามารถส่งข้อความผ่านเซิร์ฟเวอร์ของคุณได้เช่นกัน

บน iOS การใช้งานมีข้อกำหนดหนึ่งประการที่ไม่สามารถหลีกเลี่ยงได้ Apple จะปลุกแอปที่ทำงานเบื้องหลังผ่าน APNs (Apple push notification service) เท่านั้น และมีเพียงผู้ถือครอง signing credentials ของแอปเท่านั้นที่สามารถส่งข้อมูลไปยังแอปได้ เซิร์ฟเวอร์ของคุณจึงไม่มีทางส่งข้อมูลถึงแอปได้โดยตรง ntfy แก้ปัญหานี้ด้วย relay: เซิร์ฟเวอร์ของคุณจะส่ง poll_request ที่มี message ID ไปยัง ntfy.sh ซึ่งจะส่งต่อผ่าน Firebase และ APNs เพื่อปลุกแอป จากนั้นแอปจะดึงเนื้อหาข้อความจากเซิร์ฟเวอร์ของคุณอีกครั้ง

upstream-base-url: "https://ntfy.sh"

โปรดทำความเข้าใจเกี่ยวกับผลกระทบของวิธีนี้ เนื้อหาข้อความจะยังคงอยู่บนเซิร์ฟเวอร์ของคุณ แต่ข้อมูลที่ระบุว่ามีการส่งข้อความและ ID ของข้อความนั้นจะผ่านโครงสร้างพื้นฐานที่คุณไม่ได้เป็นผู้ดูแล หากไม่มีการตั้งค่านี้ การแจ้งเตือนบน iPhone จากเซิร์ฟเวอร์ self-hosted จะล่าช้าหรือไม่ได้รับเลยเนื่องจากไม่มีสิ่งใดปลุกแอปให้ทำงาน วิธีเดียวที่จะยกเลิกการใช้ relay คือการ build และเผยแพร่แอป iOS ด้วยตนเองโดยใช้บัญชี Apple developer และ APNs keys ของคุณเอง ซึ่งมีค่าใช้จ่ายรายปีและต้องทำการ build ใหม่ทุกครั้งที่มีการอัปเดต หากการใช้ relay ไม่เป็นที่ยอมรับสำหรับการใช้งานของคุณ แนะนำให้ใช้การแจ้งเตือนบน Android หรือผ่านเว็บแอปบนเดสก์ท็อปแทน

การสำรองข้อมูล การอัปเกรด และการตรึงอิมเมจ

ไม่สามารถสร้างไฟล์สองรายการนี้ขึ้นใหม่ได้ คือ /etc/ntfy/server.yml และ /var/lib/ntfy/user.db ไฟล์ที่สองเก็บข้อมูลผู้ใช้ทั้งหมด แฮชรหัสผ่าน รายการ ACL และโทเค็น ดังนั้นให้จัดการไฟล์นี้เสมือนเป็น private key

sudo tar czf ntfy-backup.tgz -C / etc/ntfy var/lib/ntfy
sudo chmod 600 ntfy-backup.tgz

ให้คัดลอกไฟล์ดังกล่าวออกจากเซิร์ฟเวอร์ cache.db เก็บเฉพาะข้อความล่าสุดในช่วง 12 ชั่วโมงที่ผ่านมาตามการตั้งค่า cache-duration ด้านบน ดังนั้นการสูญเสียไฟล์นี้จึงไม่มีผลกระทบใดๆ ที่ต้องกังวล การอัปเกรดทำได้โดยการแก้ไข tag ในไฟล์ compose แล้วทำการ pull อิมเมจใหม่

sudo docker compose pull
sudo docker compose up -d
curl -s https://ntfy.example.com/v1/health

โปรดอ่านบันทึกประจำรุ่น (release notes) ก่อนเสมอ ฐานข้อมูล SQLite จะทำการ migrate โดยอัตโนมัติเมื่อเริ่มระบบ ดังนั้นการย้อนกลับ (rollback) ไปยัง tag เวอร์ชันเก่าหลังจากมีการเปลี่ยนแปลง schema จึงไม่ปลอดภัย ควรเก็บไฟล์สำรองข้อมูลที่คุณเพิ่งทำไว้จนกว่าเวอร์ชันใหม่จะทำงานได้อย่างปกติเป็นเวลาหนึ่งวัน

Gotify และ Apprise

Gotify เป็นตัวเลือกที่มีขนาดเล็กกว่า โดยเป็น binary ไฟล์เดียวที่มี web UI และแอปบน Android แต่ไม่มีระบบ topic wildcards และไม่มีแอปอย่างเป็นทางการสำหรับ iOS ซึ่งเหมาะสำหรับเซิร์ฟเวอร์ส่วนตัวที่ใช้งานเฉพาะบน Android เท่านั้น ส่วน Apprise เป็นไลบรารีภาษา Python และเครื่องมือบรรทัดคำสั่ง (command line tool) ไม่ใช่เซิร์ฟเวอร์ โดยทำหน้าที่กระจายข้อความเดียวไปยังบริการต่างๆ มากกว่าหนึ่งร้อยรายการ รวมถึง ntfy ซึ่งเหมาะสำหรับสคริปต์ที่ต้องการส่งการแจ้งเตือนไปยังหลายช่องทางพร้อมกัน สำหรับ ntfy นั้นเป็นบริการที่มีทั้งเซิร์ฟเวอร์, HTTP API และแอปบนแพลตฟอร์มมือถือทั้งสองระบบ จึงเป็นคำตอบมาตรฐานสำหรับการตั้งค่าการแจ้งเตือนจากเซิร์ฟเวอร์เช่าทั่วไป

FAQ

ทำไมการส่งข้อความไปยังเซิร์ฟเวอร์ ntfy ของฉันถึงได้รับข้อผิดพลาด 403?

เมื่อมีการตั้งค่า auth-default-access: "deny-all" ใน server.yml การส่งข้อความแบบไม่ระบุตัวตนจะถูกปฏิเสธ ซึ่งเป็นพฤติกรรมที่ตั้งใจให้เป็นเช่นนั้น ให้ส่งข้อมูลยืนยันตัวตนด้วย -u user:pass หรือ -H "Authorization: Bearer tk_..." หากคุณส่ง token ไปแล้วแต่ยังได้รับ 403 แสดงว่าผู้ใช้ที่อยู่เบื้องหลัง token นั้นไม่มีรายการ ACL ที่ตรงกับหัวข้อ (topic) ดังกล่าว ให้รันคำสั่ง ntfy access เพื่อแสดงรายการทั้งหมด โปรดจำไว้ว่าสิทธิ์ write ไม่ครอบคลุมถึงการสมัครรับข้อความ (subscribe) ดังนั้นบัญชีที่ส่งข้อความได้ตามปกติจะยังคงถูกปฏิเสธหากพยายามอ่านข้อความในหัวข้อเดียวกัน

การแจ้งเตือนบน iPhone ทำงานร่วมกับเซิร์ฟเวอร์ ntfy ที่โฮสต์เองได้หรือไม่?

การแจ้งเตือนสามารถทำงานได้ผ่าน relay ซึ่งเป็นสิ่งที่หลีกเลี่ยงไม่ได้ เนื่องจาก Apple จะปลุกแอปพลิเคชันผ่าน APNs (Apple push notification service) เท่านั้น และมีเพียงผู้เผยแพร่แอปเท่านั้นที่สามารถส่งข้อมูลไปยังบริการนี้ได้ ดังนั้น ntfy จึงส่ง poll_request ที่มี ID ของข้อความไปยัง ntfy.sh เพื่อให้ relay ต่อไปยังอุปกรณ์ของคุณ ให้ตั้งค่า upstream-base-url: "https://ntfy.sh" ใน server.yml แล้วรีสตาร์ทคอนเทนเนอร์ เนื้อหาของข้อความจะยังคงถูกดึงมาจากเซิร์ฟเวอร์ของคุณโดยตรง หากไม่มีการตั้งค่านี้ การแจ้งเตือนบน iOS จะล่าช้าหรือไม่ปรากฏเลย

ทำไมการแจ้งเตือน ntfy จาก cron job ของฉันถึงไม่ทำงาน?

ให้รันคำสั่ง curl ด้วยตนเองก่อนเพื่อตรวจสอบว่า token และหัวข้อถูกต้อง หากรันด้วยตนเองได้แต่ไม่ทำงานผ่าน cron แสดงว่าปัญหาเกิดขึ้นก่อนขั้นตอนการแจ้งเตือน เนื่องจาก cron รันงานด้วยสภาพแวดล้อมที่จำกัดและมี PATH ที่สั้น สคริปต์ที่เรียกใช้คำสั่งโดยไม่ระบุ path เต็มอาจหยุดทำงานก่อนจะถึงบรรทัดคำสั่ง curl ให้ใช้ absolute path, เปลี่ยนทิศทาง output ของงานไปยังไฟล์ log และตรวจสอบไฟล์นั้นหลังจากรันงานในรอบถัดไป หากได้รับคำตอบเป็น 429 แทนที่จะเป็นการส่งข้อความสำเร็จ แสดงว่าระบบจำกัดอัตราการส่ง (rate limit) กำลังทำงานและสคริปต์ของคุณพยายามส่งถี่เกินไป

ฉันควรเปิดเผย ntfy บนอินเทอร์เน็ตสาธารณะหรือไม่?

แอปบนโทรศัพท์จำเป็นต้องเข้าถึงเซิร์ฟเวอร์จากเครือข่ายมือถือ ดังนั้นการตั้งค่า HTTPS สาธารณะที่ใช้ auth-default-access: "deny-all" ร่วมกับการกำหนด ACL รายหัวข้อจึงเป็นวิธีมาตรฐาน ซึ่งมีความปลอดภัยตราบใดที่ไม่มีหัวข้อใดที่อนุญาตให้ everyone อ่านได้ การใช้งานผ่าน VPN เท่านั้นจะเหมาะสมก็ต่อเมื่อผู้รับข้อความทุกคนเป็นเครื่องที่คุณควบคุมเอง แต่ไม่เหมาะสำหรับโทรศัพท์มือถือ เนื่องจากแอปจะรับข้อความได้เฉพาะในขณะที่เชื่อมต่อ VPN อยู่เท่านั้น ทำให้การแจ้งเตือนถูกค้างไว้จนกว่าโทรศัพท์จะเชื่อมต่อกลับเข้ามาใหม่