SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Tự host ntfy trên VPS để gửi cảnh báo server

Chạy ntfy trên VPS Ubuntu 24.04 hoặc Debian 13 bằng Docker Compose, bật TLS, giới hạn topic bằng user và ACL, rồi gửi cảnh báo từ cron và systemd.

Máy chủ ntfy tự host làm gì

Máy chủ ntfy tự host chuyển một HTTP POST thành push notification trên điện thoại của bạn. Bạn publish bằng curl, rồi message sẽ đến ứng dụng Android, ứng dụng iOS, tab trình duyệt hoặc bất kỳ client nào có thể duy trì một kết nối HTTP mở. Bạn không cần cài client library và cũng không cần chạy message broker.

ntfy định địa chỉ message bằng topic. Topic là một tên trong URL path, chẳng hạn https://ntfy.example.com/alerts, và được tạo ngay khi có người publish vào đó. Với cài đặt mặc định, bất kỳ ai biết tên topic đều có thể đọc và ghi vào topic đó. Vì vậy, tài liệu của chính dự án so sánh tên topic với password. Mô hình này phù hợp với dịch vụ ntfy.sh công khai. Nhưng nó không phù hợp với một server nhận các cảnh báo backup thất bại của bạn. Vì vậy, hướng dẫn này bật authentication trước khi message đầu tiên được gửi.

Bạn cần chuẩn bị gì trước khi bắt đầu

Bạn cần một VPS chạy Ubuntu 24.04 hoặc Debian 13 với Docker Engine và plugin Compose, một tên miền và rất ít RAM. Tạo bản ghi A của DNS (domain name system) trỏ ntfy.example.com đến địa chỉ IP public của máy chủ, rồi xác nhận tên miền phân giải đúng trước khi làm bước nào khác.

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

dig phải in ra IP của máy chủ. Việc cấp chứng chỉ sẽ thất bại nếu lệnh không in ra gì, vì certificate authority kiểm tra tên miền từ bên ngoài. Cổng 80 phải được mở vì ACME (automatic certificate management environment), giao thức đứng sau Let's Encrypt, dùng cổng này cho HTTP challenge. Container ntfy không cần public port.

Tạo file cấu hình ntfy

Docker image không có sẵn file cấu hình, nên bạn phải tự tạo file này. Mọi command ở các phần sau của hướng dẫn đều đọc cấu hình từ file đó. Trước tiên, hãy lấy user ID và group ID mà container sẽ chạy với các ID này.

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

Trong các dòng này, 4 dòng có vai trò quyết định. base-url phải là địa chỉ HTTPS public chính xác, vì ntfy dùng địa chỉ này để tạo link attachment và các request của web app. Nếu giá trị sai, web app vẫn tải được nhưng mọi thao tác sau đó đều fail. listen-http: ":2586" bind vào mọi interface bên trong container. Cách này có vẻ quá rộng nhưng là cấu hình đúng: container có network namespace riêng, nên nếu bind vào 127.0.0.1 trong container thì port sẽ không thể truy cập từ host và Docker sẽ không kết nối được đến published port. auth-default-access: "deny-all" là toàn bộ chính sách bảo mật, vì nó từ chối quyền đọc và ghi đối với mọi người dùng không được cấp quyền rõ ràng. behind-proxy: true yêu cầu ntfy lấy địa chỉ client từ header X-Forwarded-For, để rate limit đếm đúng visitor thay vì coi reverse proxy là một client duy nhất nhưng có lưu lượng rất lớn.

enable-login: true cho phép web app và các app trên điện thoại đăng nhập bằng password. enable-signup vẫn để false, vì cho phép người dùng tự tạo account trên server private sẽ tạo ra một điểm truy cập mở không cần thiết.

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

Chạy ntfy bằng Docker Compose

Đặt nội dung này vào /opt/ntfy/compose.yaml, thay 1000:1000 bằng hai số id -uid -g đã in ở trên.

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

Server hoạt động bình thường sẽ trả lời {"healthy":true}. Hai chi tiết trong file compose này được đặt có chủ ý. Image được ghim vào v2.27.0, bản release hiện tại tính đến tháng 8 năm 2026, thay vì latest, vì với latest, docker compose pull tiếp theo sẽ thay đổi phiên bản server và bạn chỉ phát hiện ra sau đó qua changelog. Cổng được publish dưới dạng 127.0.0.1:2586:2586, nên container chỉ có thể truy cập từ địa chỉ loopback của host. Nếu viết 2586:2586, Docker sẽ chèn các rule firewall của nó trước rule của bạn. Khi đó, cổng vẫn trả lời từ Internet dù ufw status cho biết cổng đã đóng.

Nếu curl in ra Connection refused, hãy xem log của container. Lỗi quyền trên /var/lib/ntfy/user.db có nghĩa là dòng user: không khớp với owner của các thư mục đó. Vì vậy, process không thể tạo database riêng và thoát. Hướng dẫn cơ bản về Docker Compose cho VPS giải thích chi tiết hơn về quyền sở hữu volume và chính sách restart.

Đặt TLS phía trước bằng Caddy

Caddy tự yêu cầu và gia hạn chứng chỉ. Đây là cách ngắn nhất để có TLS hoạt động (bảo mật tầng truyền tải).

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

Thay toàn bộ nội dung của /etc/caddy/Caddyfile bằng ba dòng.

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

Cùng {"healthy":true} qua HTTPS xác nhận toàn bộ đường đi hoạt động. 502 từ Caddy cho biết ntfy không lắng nghe; hãy kiểm tra bằng sudo ss -lntp | grep 2586. Lỗi chứng chỉ thường có nghĩa là bản ghi DNS sai hoặc cổng 80 bị chặn. sudo journalctl -u caddy -n 50 cho biết nguyên nhân nào.

Nếu bạn đã chạy nginx, hãy sao chép các thiết lập proxy mà ntfy cung cấp trong tài liệu: proxy_http_version 1.1, proxy_buffering off, proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, đồng thời đặt thời gian chờ đọc và gửi ít nhất là ba phút. Một subscriber duy trì một kết nối HTTP mở trong suốt thời gian nó lắng nghe. Theo mặc định, nginx đóng kết nối upstream không hoạt động sau 60 giây. Vì vậy, các subscriber sẽ kết nối lại liên tục và bỏ lỡ những message được gửi trong khoảng gián đoạn.

Tạo user và khóa chặt topic

Authentication đã được bật và hiện chưa ai có quyền truy cập gì, đúng như mục tiêu. Tạo một tài khoản admin cho bạn và một machine account cho các script. Các lệnh này đọc /etc/ntfy/server.yml từ bên trong container, vì vậy file cấu hình được mount dưới dạng 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

Mỗi lệnh sẽ yêu cầu nhập password. Admin bỏ qua access list và có thể đọc, ghi mọi topic, nên chỉ dùng tài khoản này cho bạn và ứng dụng trên điện thoại. robot là user thông thường, mặc định không có quyền truy cập cho đến khi bạn cấp quyền.

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

Một mục ACL (access control list) gồm user, topic và permission. Topic có thể là tên cụ thể hoặc pattern, trong đó * khớp với bất kỳ chuỗi nào. Vì vậy, alerts_* bao phủ alerts_backupalerts_db mà không cần chạy một lệnh cho từng host. Permission write chỉ cho phép publish, nên token bị lấy cắp từ cron job không thể subscribe rồi đọc lại dữ liệu mà nó đã gửi. Username đặc biệt everyone quy định visitor chưa authentication được phép làm gì. Chỉ dùng username này khi bạn chủ động muốn mở một thứ công khai, chẳng hạn ntfy access everyone status read.

Script nên dùng token, không dùng password của bạn.

sudo docker compose exec ntfy ntfy token add robot

Lệnh này in ra token bắt đầu bằng tk_. Token kế thừa chính xác các quyền của user mà nó thuộc về, nên token này chỉ có thể publish vào các topic alerts và không thể làm gì khác. ntfy token list hiển thị những gì đang tồn tại, còn ntfy token remove thu hồi một token mà không thay đổi password của user.

Gửi tin nhắn đầu tiên và xác nhận cơ chế khóa hoạt động

Trước tiên, kiểm tra xem cửa đã đóng chưa.

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

Lệnh này in ra 403, còn 403 là kết quả đúng: auth-default-access: "deny-all" từ chối publish ẩn danh. Bây giờ gửi một tin nhắn thật.

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

Server trả về tin nhắn đã lưu dưới dạng JSON. Đây là cách xác nhận tin nhắn đã được chấp nhận thay vì bị bỏ qua. Title là dòng đầu tiên được in đậm. Priority nhận giá trị từ 1 đến 5, hoặc theo tên từ min đến urgent, và quyết định điện thoại có phát âm thanh hay không. Tags trở thành emoji trong notification khi tên khớp với một emoji short code đã biết, và giữ nguyên dưới dạng văn bản khi không khớp.

Để theo dõi một topic từ terminal, hãy stream topic đó:

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

curl sẽ yêu cầu password. Mỗi tin nhắn xuất hiện trên một dòng, còn các dòng trống thỉnh thoảng xuất hiện là keepalive. Mở https://ntfy.example.com trong browser và đăng nhập bằng cùng account sẽ cung cấp phiên bản web app của cùng stream này.

Thiết lập rate limit để một script không thể làm server quá tải

Theo mặc định, mỗi visitor có một bucket gồm 60 request, được nạp lại với tốc độ 1 request mỗi 5 giây. Mức này khá rộng rãi đối với server riêng, và một script bị kẹt trong vòng lặp retry sẽ dùng hết bucket. Thêm giới hạn vào server.yml.

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

Visitor vượt quá giới hạn sẽ nhận HTTP 429 thay vì message được gửi đi. Giới hạn được tính theo địa chỉ của visitor. Vì vậy behind-proxy: true rất quan trọng: nếu thiếu cấu hình này, ntfy chỉ thấy địa chỉ của Caddy, mọi client bị tính là cùng một visitor, và một script gây nhiều request sẽ làm cạn bucket mà điện thoại của bạn và các server khác đang dùng chung.

Cảnh báo từ cron job bị lỗi

Không đưa token vào command line. ps aux hiển thị toàn bộ command line của mọi process đang chạy cho mọi user trên máy, vì vậy token truyền bằng -H có thể bị bất kỳ local account nào đọc được trong thời gian curl chạy. Dùng file cấu hình của curl sẽ tránh được việc này.

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

Bây giờ hãy bọc job này lại. Lưu nội dung sau vào /usr/local/bin/backup-with-alert.sh rồi chạy chmod 750 để cấp quyền thực thi.

#!/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

$? được lấy ngay trên dòng sau command, vì command tiếp theo sẽ ghi đè giá trị này. Output được truyền qua tail -c 1000 vì ntfy giới hạn kích thước message và notification không phải là log viewer. exit "$code" ở cuối giữ nguyên status ban đầu, để mọi thành phần khác đang theo dõi job này vẫn nhận biết được job đã fail. Kiểm tra toàn bộ bằng cách trỏ script đến /bin/false trong một lần chạy.

Một nhánh xử lý lỗi không bao giờ được thực thi còn tệ hơn việc không có cảnh báo, vì nó khiến im lặng bị hiểu là thành công. Cron cung cấp cho job một environment gần như trống và PATH ngắn hơn nhiều so với login shell, vì vậy script chạy được khi bạn thực thi thủ công có thể dừng trước khi đến dòng curl. Hướng dẫn về lý do cron job không chạy trình bày các vấn đề về environment này. Luôn dùng absolute path, và đọc file log sau lần chạy theo lịch đầu tiên thay vì mặc định cho rằng mọi thứ đều đúng.

Cảnh báo khi một systemd unit bị lỗi

Cron phù hợp với các tác vụ chạy theo lịch. Các service chạy lâu dài cần OnFailure=, được systemd chạy mỗi khi một unit chuyển sang trạng thái failed. Tạo một template unit rồi dùng lại cho mọi service trên máy chủ. Lưu nội dung sau vào /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

Sau đó chạy /usr/local/bin/ntfy-unit-failed và đặt mode 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

Gắn nó vào một service bằng drop-in để lần nâng cấp package không ghi đè chỉnh sửa của bạn.

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

%n được thay bằng tên unit đầy đủ, nên instance sẽ trở thành ntfy-unit-failed@myapp.service. Còn %i bên trong template chuyển myapp.service cho script dưới dạng đối số đầu tiên. Nhờ đó, một template có thể dùng cho mọi unit. Kiểm tra bằng một unit được cố ý cho lỗi, lưu vào /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

Lệnh start thoát với mã khác 0 và in ra Job for ntfy-selftest.service failed because the control process exited with error code. Điện thoại sẽ rung sau khoảng một giây. Sau đó xóa test unit.

Có một bẫy cần lưu ý. OnFailure= chỉ chạy khi một unit chuyển sang trạng thái failed. Một service có Restart=always có thể không bao giờ đạt trạng thái đó, vì systemd sẽ tiếp tục restart service thay vì đánh dấu nó là failed. Unit chỉ bị lỗi sau khi vượt quá StartLimitBurst lần restart trong StartLimitIntervalSec. Hãy đặt hai giá trị này trên mọi service mà bạn muốn nhận cảnh báo. Nếu không, một crash loop có thể âm thầm lặp lại trong nhiều ngày. Timers là cách thay thế gọn hơn cho mô hình cron ở trên, vì service unit của timer được OnFailure= tự động. Hướng dẫn về systemd service và timer trên VPS trình bày cách chuyển đổi một tác vụ.

Kết nối trình theo dõi uptime vào cùng topic

Uptime Kuma, trình theo dõi trạng thái tự host, có sẵn loại thông báo ntfy. Mở Settings, rồi Notifications, rồi Setup Notification, chọn Ntfy, đặt server URL là https://ntfy.example.com và topic là alerts, chọn priority rồi dán access token robot. Hãy gửi thông báo kiểm tra trước khi lưu, vì tên topic sai sẽ không báo lỗi nếu quyền write không bao phủ topic đó.

Giới hạn thực tế của cách này là: monitor chạy trên cùng VPS không thể cho bạn biết VPS đã ngừng hoạt động, và ntfy không thể gửi thông báo rằng chính ntfy đã ngừng hoạt động. Hãy chạy monitor trên một máy khác và cấp cho nó một kênh thông báo thứ hai, chẳng hạn email, để monitor này theo dõi chính ntfy. Loại monitor Push của Uptime Kuma xử lý điểm mù còn lại: cron job của bạn gọi một push URL sau mỗi lần chạy thành công, còn Kuma sẽ cảnh báo khi các lần gọi đó ngừng xuất hiện. Nhánh xử lý lỗi chỉ chạy khi job được thực thi, nên không cho biết gì về job chưa từng khởi động.

Tự host ntfy có hoạt động trên Android và iPhone không?

Trên Android, có, không có điều kiện kèm theo. Cài app từ Google Play hoặc F-Droid, mở Settings, đặt server mặc định thành https://ntfy.example.com, thêm tài khoản trong màn hình quản lý user, rồi subscribe vào alerts. Instant delivery duy trì một foreground service để message vẫn đến ngay cả khi điện thoại đang ở chế độ doze. Notification cố định đi kèm là yêu cầu của Android đối với foreground service, không phải bug. Bản build F-Droid hoàn toàn không chứa code Firebase, vì vậy mọi subscription đều dùng instant delivery. ntfy cũng có thể hoạt động như một UnifiedPush distributor, là một phương án thay thế mở cho push service của Google. Vì vậy, các app khác hỗ trợ UnifiedPush cũng có thể gửi message qua server của bạn.

Trên iOS, ntfy hoạt động nhưng có một dependency không thể loại bỏ. Apple chỉ đánh thức app đang chạy nền thông qua APNs (Apple push notification service). Chỉ bên sở hữu signing credentials của app mới có thể gửi notification đến app. Vì vậy, server của bạn không thể kết nối trực tiếp đến app. ntfy giải quyết việc này bằng một relay: server của bạn gửi một poll_request chứa message ID đến ntfy.sh. ntfy.sh chuyển tiếp message đó qua Firebase và APNs để đánh thức app. Sau đó, app fetch message body từ server của bạn.

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

Cần hiểu rõ chi phí của cách này. Nội dung message vẫn nằm trên máy chủ của bạn, nhưng việc có message đến và ID của message đi qua hạ tầng mà bạn không vận hành. Nếu không bật thiết lập này, notification trên iPhone từ server tự host sẽ đến trễ hoặc không đến, vì không có cơ chế nào đánh thức app. Cách duy nhất để loại bỏ relay là tự build và phát hành app iOS bằng Apple developer account và APNs keys của riêng bạn. Cách này yêu cầu trả phí hằng năm và rebuild app cho mỗi bản cập nhật. Nếu relay không phù hợp với nhu cầu của bạn, hãy dùng Android hoặc desktop web app để nhận cảnh báo.

Sao lưu, nâng cấp và ghim image

Không thể tạo lại hai đường dẫn: /etc/ntfy/server.yml/var/lib/ntfy/user.db. Đường dẫn thứ hai chứa toàn bộ user, password hash, ACL entry và token, nên hãy xử lý nó như một private key.

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

Sao chép file đó ra khỏi server. cache.db chỉ chứa các message gần đây, trong 12 giờ cùng với cache-duration ở trên, nên mất file này không làm mất dữ liệu cần bảo vệ. Nâng cấp bằng cách sửa tag trong file compose rồi pull.

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

Đọc release notes trước. Các database SQLite được migrate khi khởi động, nên rollback về tag cũ sau khi schema thay đổi là không an toàn. Giữ bản sao lưu vừa tạo cho đến khi phiên bản mới đã chạy ổn định được 1 ngày.

Gotify và Apprise

Gotify là lựa chọn nhỏ gọn hơn: một binary có web UI và app Android, không hỗ trợ wildcard cho topic và không có client iOS chính thức. Nó phù hợp với một máy chủ riêng chỉ cần gửi thông báo đến Android. Apprise là thư viện Python và công cụ dòng lệnh, không phải server. Nó gửi cùng một message đến hơn một trăm dịch vụ, bao gồm ntfy. Apprise phù hợp với script cần gửi thông báo đến nhiều nơi cùng lúc. ntfy cung cấp server, HTTP API và app trên cả hai nền tảng di động. Vì vậy, đây thường là lựa chọn cho việc gửi cảnh báo từ server thuê.

FAQ

Vì sao publish lên ntfy server của tôi lại trả về 403?

Khi bật auth-default-access: "deny-all" trong server.yml, publish ẩn danh sẽ bị từ chối. Đây là hành vi được thiết kế. Gửi thông tin xác thực bằng -u user:pass hoặc -H "Authorization: Bearer tk_...". Nếu bạn đã gửi token nhưng vẫn nhận 403, user đứng sau token đó chưa có ACL entry phù hợp cho topic. Chạy ntfy access để in toàn bộ danh sách. Lưu ý rằng grant write không cho phép subscribe, vì vậy một account publish được vẫn sẽ bị từ chối khi cố đọc cùng topic.

Thông báo có hoạt động trên iPhone với ntfy server tự host không?

Có, nhưng phải đi qua một relay mà bạn không thể tránh. Apple chỉ đánh thức app thông qua APNs (Apple push notification service), và chỉ publisher của app mới có thể gửi đến đó. Vì vậy, ntfy chuyển tiếp một poll_request chứa message ID đến ntfy.sh, rồi ntfy.sh chuyển tiếp nó đến thiết bị. Đặt upstream-base-url: "https://ntfy.sh" trong server.yml và restart container. Nội dung message vẫn được fetch từ server của bạn. Nếu không đặt tùy chọn này, thông báo iOS sẽ bị trễ hoặc không xuất hiện.

Vì sao alert ntfy từ cron job của tôi không bao giờ đến?

Trước tiên, chạy riêng dòng curl để xác nhận token và topic chính xác. Nếu chạy thủ công được nhưng chạy từ cron không được, lỗi nằm trước bước gửi alert: cron chạy job với environment tối thiểu và PATH ngắn. Vì vậy, script gọi command bằng tên không đầy đủ có thể dừng trước khi đến dòng curl. Dùng absolute path, redirect output của job vào một file log, rồi đọc file đó sau lần chạy tiếp theo. Nếu nhận phản hồi 429 thay vì delivery, nghĩa là rate limit đang hoạt động và script của bạn retry quá nhanh.

Có nên expose ntfy trên public Internet không?

Các app trên điện thoại cần truy cập server từ mạng di động. Vì vậy, public HTTPS endpoint có auth-default-access: "deny-all" và ACL theo từng topic là cách triển khai thông thường. Cách này an toàn miễn là không có topic nào cho phép everyone đọc. Một instance chỉ truy cập qua VPN là lựa chọn hợp lý khi mọi subscriber đều là máy bạn kiểm soát. Cách này không phù hợp với điện thoại, vì app chỉ nhận được thông báo khi tunnel đang hoạt động. Do đó, alert sẽ được queue cho đến khi điện thoại kết nối lại.