SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-27

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

Tự host ntfy trên VPS với Docker Compose và TLS. Khóa topic bằng user, ACL, rồi gửi cảnh báo từ cron hoặc systemd OnFailure khi service lỗi.

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 khác có thể duy trì 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, như https://ntfy.example.com/alerts, và tồn tại ngay khi có người publish vào đó. Với cấu hình 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 chính thức của 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. Nó không phù hợp với server nhận các cảnh báo backup failed của bạn. Vì thế, 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ì

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

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

dig phải in ra IP của server. Việc cấp certificate sẽ thất bại nếu lệnh không in ra gì, vì certificate authority kiểm tra hostname từ bên ngoài. Cổng 80 phải được mở vì ACME (automatic certificate management environment), giao thức được Let's Encrypt sử dụng, cần 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ừ đây. Trước tiên, tìm user ID và group ID mà container sẽ chạy bằng.

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

Bốn dòng trong đó có vai trò chí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 có thể tải được nhưng mọi thao tác sau đó đều fail. listen-http: ":2586" bind vào tất cả interface bên trong container. Cách này trông có vẻ quá rộng nhưng là đúng: container có network namespace riêng, nên nếu bind vào 127.0.0.1 bên trong container thì port sẽ không truy cập được từ host và published port của Docker sẽ không kết nối được. auth-default-access: "deny-all" xác định 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 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 người truy cập 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 thêm một điểm truy cập 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 -u và id -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

Một server khỏe mạnh trả lời {"healthy":true}. Hai chi tiết trong file compose đó là có chủ ý. Image được pin 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, lần docker compose pull tiếp theo sẽ thay đổi version của server và bạn chỉ biết việc đó qua changelog sau khi đã xảy ra. Port đượ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 rule firewall của nó trước các rule của bạn. Vì vậy, port vẫn trả lời từ Internet dù ufw status cho biết port đã đóng. Cả hai thói quen này đều áp dụng cho container tiếp theo bạn thêm vào: relay RustDesk tự host cũng pin image tag theo cách tương tự, nhưng không thể ẩn sau loopback vì các port signal và relay của nó phải trả lời từ Internet.

Nếu curl in ra Connection refused, hãy đọc log của container. Lỗi permission 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à sẽ 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à restart policy.

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

Caddy tự yêu cầu và gia hạn certificate. Đây là cách ngắn gọn nhất để bật 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

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

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

Kết quả {"healthy":true} qua HTTPS cho biết toàn bộ đường đi đã hoạt động. Lỗi 502 từ Caddy cho biết ntfy không listening: kiểm tra bằng sudo ss -lntp | grep 2586. Lỗi certificate thường có nghĩa là DNS record sai hoặc cổng 80 bị chặn. sudo journalctl -u caddy -n 50 cho biết nguyên nhân nào trong hai nguyên nhân này. Sau này, để thêm service khác, chỉ cần thêm một hostname block vào cùng Caddyfile. Nhờ đó, một ứng dụng như Halcyon, frontend cửa hàng video thập niên 90 cho thư viện Jellyfin của bạn có thể chạy trên subdomain thứ hai của cùng server.

Nếu bạn đã chạy nginx, hãy sao chép các proxy setting mà ntfy ghi 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 read timeout và send timeout tối thiểu là 3 phút. Subscriber giữ một HTTP connection mở trong suốt thời gian listening. Theo mặc định, nginx đóng upstream connection idle sau 60 giây. Vì vậy, subscriber sẽ reconnect liên tục và bỏ lỡ các message được gửi trong khoảng ngắt đó.

Tạo user và khóa chặt quyền truy cập topic

Authentication đã được bật và hiện chưa có ai truy cập được bất kỳ thứ gì. Đây chính là mục đích. Tạo một account admin cho bạn và một machine account cho các script. Các command này đọc /etc/ntfy/server.yml bên trong container, nên 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 command đều yêu cầu nhập password. Admin bỏ qua access list và có thể đọc, ghi mọi topic, vì vậy chỉ dùng account này cho bạn và ứng dụng trên điện thoại. robot là user thông thường và hoàn toàn 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 mọi chuỗi. Vì vậy, alerts_* bao phủ alerts_backup và alerts_db mà không cần một command riêng cho từng host. Permission write chỉ cho phép publish, nên token bị lấy cắp từ một cron job không thể subscribe và đọc lại dữ liệu nó đã gửi. Username đặc biệt everyone xác định những gì visitor chưa authentication được phép làm. Chỉ dùng username này khi bạn chủ động muốn mở một thứ công khai, chẳng hạn như 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

Command này in ra một token bắt đầu bằng tk_. Token kế thừa chính xác quyền của user tương ứng, 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ị các mục hiện có, còn ntfy token remove thu hồi một mục mà không ảnh hưởng đến password của user.

Gửi message đầ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 đã được đó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, và 403 là kết quả đúng: auth-default-access: "deny-all" từ chối publish ẩn danh. Bây giờ, hãy gửi một message 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ề message đã lưu dưới dạng JSON. Đây là cách xác nhận message đã đượ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 được chuyển thành emoji trong notification khi tên khớp với một emoji short code đã biết, và giữ nguyên dạng plain text 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ẽ hỏi password. Mỗi message xuất hiện trên một dòng. 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ẽ cho bạn phiên bản web app của cùng stream đó.

Đặt giới hạn tốc độ để một script không thể làm ngập server

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 một server riêng, và một script bị kẹt trong vòng lặp retry sẽ dùng hết bucket. Thêm các 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ì nhận được message. 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 không có nó, ntfy chỉ thấy địa chỉ của Caddy, mọi client đều 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 cùng sử dụng.

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

Không đặt token trên 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 trong suốt thời gian curl chạy. Dùng curl config file 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ưu nội dung dưới đây thành /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 tối đa 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 job đã fail. Kiểm tra toàn bộ quy trình bằng cách trỏ script đến /bin/false trong một lần chạy.

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

Cảnh báo khi một systemd unit bị fail

Cron phù hợp với các tác vụ 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. 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 để package upgrade 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 đầy đủ của unit, nên instance sẽ trở thành ntfy-unit-failed@myapp.service. Còn %i trong template truyề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 hoạt động bằng một unit cố ý fail, 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 sẽ thoát với mã khác 0 và in Job for ntfy-selftest.service failed because the control process exited with error code. Điện thoại sẽ báo khoảng 1 giây sau. Xóa test unit sau khi kiểm tra xong.

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ờ chuyển sang trạng thái này, vì systemd cứ restart service đó. Unit chỉ fail sau khi vượt quá StartLimitBurst lần restart trong StartLimitIntervalSec. Đặt 2 giá trị này cho mọi service mà bạn muốn nhận cảnh báo. Nếu không, crash loop có thể âm thầm chạy trong nhiều ngày. Timers là cách thay thế phù hợp hơn cho mẫu cron ở trên, vì service unit của timer tự động có OnFailure=. Hướng dẫn về systemd service và timer trên VPS trình bày cách chuyển đổi một service.

Kết nối uptime monitor vào cùng topic

Uptime Kuma, status monitor tự host, có sẵn loại notification ntfy. Mở Settings, sau đó mở Notifications, rồi chọn Setup Notification. Chọn Ntfy, đặt server URL thành https://ntfy.example.com và topic thành alerts, chọn priority rồi dán access token robot. Hãy gửi test notification trước khi lưu, vì tên topic sai sẽ fail silently nếu grant write không bao gồm topic đó.

Giới hạn thực tế của mô hình này là: monitor chạy trên cùng VPS không thể cho bạn biết VPS đã down, và ntfy không thể gửi thông báo rằng chính ntfy đã down. Hãy chạy monitor trên một máy khác và cấp cho nó một notification channel 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ý blind spot còn lại: cron job gọi 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 đến. Nhánh failure chỉ chạy khi job được thực thi, nên không cho biết job chưa từng khởi động.

Self-hosted ntfy có hoạt động trên Android và iPhone không?

Trên Android thì có, không có giới hạn nào đáng kể. 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ý người dùng, rồi subscribe vào alerts. Instant delivery duy trì một foreground service để tin nhắn 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 lỗi. Bản build trên F-Droid hoàn toàn không chứa mã Firebase, nên mọi subscription đều dùng instant delivery. ntfy cũng có thể hoạt động như một UnifiedPush distributor, là một lựa chọn mở thay cho push service của Google. Vì vậy, các app khác hỗ trợ UnifiedPush cũng có thể gửi thông báo 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 giữ signing credentials của app mới có thể gửi thông báo đến app. Vì vậy, server của bạn không thể tiếp cận app trực tiếp. 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 nó 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õ cái giá phải trả. Nội dung tin nhắn vẫn nằm trên máy chủ của bạn, nhưng việc một tin nhắn đã đến và ID của nó sẽ đ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 từ server self-hosted trên iPhone sẽ đến muộn hoặc không đến, vì không có gì đá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. Việc 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à pin image

Không thể tạo lại 2 đường dẫn: /etc/ntfy/server.yml và /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 ngoà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 nghĩa là sửa tag trong file compose rồi pull image.

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 backup vừa tạo cho đến khi phiên bản mới đã chạy ổn định trong 1 ngày. Mỗi service trên máy có một danh sách ngắn các đường dẫn không thể tạo lại. Bài so sánh PhotoPrism và Immich xác định danh sách đó cùng các lệnh backup tương ứng cho một thư viện ảnh trên loại VPS tương tự.

Gotify và Apprise

Gotify là lựa chọn 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áy chủ riêng chỉ cần gửi thông báo đến Android. Apprise là một thư viện Python và công cụ dòng lệnh, không phải server. Nó phân phối 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, ntfy thường được chọn để gửi cảnh báo từ server thuê ngoài.

FAQ

Tại sao gửi thông báo đến ntfy server của tôi lại trả về 403?

Khi bật auth-default-access: "deny-all" trong server.yml, việc publish ẩn danh sẽ bị từ chối. Đây là hành vi được thiết kế như vậy. Gửi credential bằng -u user:pass hoặc -H "Authorization: Bearer tk_...". Nếu bạn đã gửi token mà vẫn nhận 403, user đứng sau token đó không 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, nên 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 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 dịch vụ này chuyển tiếp tiếp đến thiết bị. Đặt upstream-base-url: "https://ntfy.sh" trong server.yml rồi restart container. Nội dung message vẫn được fetch từ server của bạn. Nếu không bật setting này, notification trên iOS sẽ bị trễ hoặc không xuất hiện.

Tại sao cảnh báo 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 là chính xác. Nếu chạy thủ công được nhưng chạy từ cron thì không, lỗi nằm ở phía trước bước gửi cảnh báo: 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 chạy đến dòng curl. Dùng absolute path, redirect output của job vào một log file, rồi đọc file đó sau lần chạy tiếp theo. Nếu nhận response 429 thay vì thông báo được gửi đi, rate limit đang hoạt động và script của bạn đang 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 đến server từ mạng di động. Vì vậy, public HTTPS endpoint có auth-default-access: "deny-all" và ACL riêng cho từng topic là cấu hình thông thường. Cấu hình này an toàn miễn là không có topic nào cho phép everyone đọc. Instance chỉ dùng qua VPN là lựa chọn hợp lý khi mọi subscriber đều là machine do 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. Các alert sẽ được queue cho đến khi điện thoại kết nối lại.