Secure ntfy Publishing Plan
Secure ntfy Publishing Plan for Obaki / Beneco Notifications
Section titled “Secure ntfy Publishing Plan for Obaki / Beneco Notifications”Protect the self-hosted ntfy server so random people cannot spam notification topics with a simple curl command.
The target setup:
- ntfy stays exposed publicly so phones can subscribe.
- Anonymous users can only read/subscribe to approved topics.
- Only the Obaki / Beneco API can publish notifications.
- User-defined notifications are created inside the API, not directly inside ntfy.
- Random arbitrary topics are denied by default.
Final Access Model
Section titled “Final Access Model”Anonymous public users:- Can subscribe to approved topics.- Cannot publish to any topic.- Cannot create/use random topics.
Obaki API:- Can publish to approved topic patterns.- Cannot use the ntfy admin account.- Uses a dedicated ntfy access token.
Admin:- Used only by you for server management.Recommended ntfy topic layout:
beneco-power-outagesbeneco-power-outages-testbeneco-scheduled-outagesbeneco-outages-alerts-*obaki-system-*Note: User outage alert topics use prefix
beneco-outages-alerts-*(see../architecture/unified-notification-pipeline-plan.md). Older docs referencedobaki-user-alerts-*; update ntfy ACL accordingly.
Recommended rule:
auth-default-access: deny-allThen explicitly allow only what you need.
Why This Design
Section titled “Why This Design”Bad design:
User creates any ntfy topic.User can publish directly to ntfy.API trusts the user topic name.Problem:
curl -d "spam" https://ntfy.yourdomain.com/some-topicIf ntfy is open by default, anyone can abuse your server.
Better design:
User creates notification rule in your API.API validates and stores the rule.API generates an opaque ntfy topic.API alone publishes to ntfy.User only subscribes.Example:
User-facing rule:"Alert me when outage text contains Bakakeng or Marcos Highway"
Generated topic:obaki-user-alerts-k8f3p92mxq
Subscribe URL:https://ntfy.yourdomain.com/obaki-user-alerts-k8f3p92mxqDo not put addresses, real names, barangays, or personal descriptions in topic names. Treat public-read topic names as subscription secrets.
Phase 1: Decide Your Policy
Section titled “Phase 1: Decide Your Policy”Use this:
Default access:deny-all
Main outage topic:anonymous read-onlyapi_publisher write-only
Test outage topic:anonymous read-onlyapi_publisher write-only
User-defined topics:anonymous read-only for obaki-user-alerts-*api_publisher write-only for obaki-user-alerts-*
Everything else:deniedThis means:
| Actor | Main Topic | User Alert Topic | Random Topic |
|---|---|---|---|
| Anonymous subscribe | Allowed | Allowed | Denied |
| Anonymous publish | Denied | Denied | Denied |
| API publish | Allowed | Allowed | Denied unless allowed |
| Admin | Allowed | Allowed | Allowed |
Phase 2: Locate Your ntfy Setup
Section titled “Phase 2: Locate Your ntfy Setup”If ntfy is installed directly on the VPS
Section titled “If ntfy is installed directly on the VPS”Common files:
/etc/ntfy/server.yml/var/lib/ntfy/user.db/var/cache/ntfy/cache.dbCommon service commands:
sudo systemctl status ntfysudo systemctl restart ntfysudo journalctl -u ntfy -fIf ntfy runs in Docker
Section titled “If ntfy runs in Docker”Check containers:
docker psCommon commands:
docker logs -f ntfydocker exec -it ntfy shdocker restart ntfyIf your container name is different, replace ntfy.
Phase 3: Backup Before Changing Auth
Section titled “Phase 3: Backup Before Changing Auth”Do this before enabling ACLs.
Direct VPS install
Section titled “Direct VPS install”sudo mkdir -p /root/backups/ntfy
sudo cp /etc/ntfy/server.yml /root/backups/ntfy/server.yml.$(date +%F-%H%M%S)
if [ -f /var/lib/ntfy/user.db ]; then sudo cp /var/lib/ntfy/user.db /root/backups/ntfy/user.db.$(date +%F-%H%M%S)fi
if [ -f /var/cache/ntfy/cache.db ]; then sudo cp /var/cache/ntfy/cache.db /root/backups/ntfy/cache.db.$(date +%F-%H%M%S)fiDocker install
Section titled “Docker install”Back up your compose file and mounted config directory.
Example:
mkdir -p ~/backups/ntfy
cp docker-compose.yml ~/backups/ntfy/docker-compose.yml.$(date +%F-%H%M%S)
# Adjust this path to your actual mounted ntfy config/data folder.cp -a ./ntfy ~/backups/ntfy/ntfy-data.$(date +%F-%H%M%S)Phase 4: Edit server.yml
Section titled “Phase 4: Edit server.yml”Open the config:
sudo nano /etc/ntfy/server.ymlMinimum recommended config:
base-url: "https://ntfy.yourdomain.com"listen-http: "127.0.0.1:8081"
cache-file: "/var/cache/ntfy/cache.db"cache-duration: "12h"
auth-file: "/var/lib/ntfy/user.db"auth-default-access: "deny-all"
behind-proxy: trueAdjust:
https://ntfy.yourdomain.comto your real ntfy domain.
Use listen-http: "127.0.0.1:8081" if ntfy is behind Nginx/Caddy on the same VPS. This prevents exposing the ntfy internal port directly to the internet.
Use behind-proxy: true when ntfy is behind a reverse proxy.
Do not use:
auth-default-access: "read-write"That keeps your server open.
Phase 5: Restart ntfy
Section titled “Phase 5: Restart ntfy”Direct VPS install
Section titled “Direct VPS install”sudo systemctl restart ntfysudo systemctl status ntfy --no-pagerWatch logs:
sudo journalctl -u ntfy -fDocker
Section titled “Docker”docker restart ntfydocker logs -f ntfyIf ntfy fails to start, restore the backup config and restart again.
Phase 6: Create ntfy Users
Section titled “Phase 6: Create ntfy Users”You need two users:
adminapi_publisherDirect VPS install
Section titled “Direct VPS install”Create admin:
sudo ntfy user add --role=admin adminCreate API publisher:
sudo ntfy user add api_publisherList users:
sudo ntfy user listExpected idea:
admin role: adminapi_publisher role: userDocker install
Section titled “Docker install”Run the same commands inside the ntfy container:
docker exec -it ntfy ntfy user add --role=admin admindocker exec -it ntfy ntfy user add api_publisherdocker exec -it ntfy ntfy user listIf your container does not automatically detect the config, use:
docker exec -it ntfy ntfy --config /etc/ntfy/server.yml user listThen use the same --config /etc/ntfy/server.yml pattern for the other ntfy CLI commands.
Phase 7: Create API Access Token
Section titled “Phase 7: Create API Access Token”Create a token for the API publisher user only.
Direct VPS install
Section titled “Direct VPS install”sudo ntfy token add --label="obaki-api-publisher" api_publisherDocker install
Section titled “Docker install”docker exec -it ntfy ntfy token add --label="obaki-api-publisher" api_publisherIf needed:
docker exec -it ntfy ntfy --config /etc/ntfy/server.yml token add --label="obaki-api-publisher" api_publisherCopy the generated token. It should look like:
tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxDo not create the API token under the admin user.
Important: ntfy access tokens inherit the permissions of the ntfy user account. This is why api_publisher must be a normal user with narrow ACL permissions.
Phase 8: Add ACL Rules
Section titled “Phase 8: Add ACL Rules”Use everyone for anonymous access. It is clearer and avoids shell wildcard expansion problems.
Direct VPS install
Section titled “Direct VPS install”Main outage topic:
sudo ntfy access api_publisher beneco-power-outages writesudo ntfy access everyone beneco-power-outages readTest topic:
sudo ntfy access api_publisher beneco-scheduled-outages-test writesudo ntfy access everyone beneco-scheduled-outages-test readFuture user-defined alert topics:
sudo ntfy access api_publisher "obaki-user-alerts-*" writesudo ntfy access everyone "obaki-user-alerts-*" readOptional system topics:
sudo ntfy access api_publisher "obaki-system-*" writesudo ntfy access admin "obaki-system-*" read-writeList ACL:
sudo ntfy accessDocker install
Section titled “Docker install”docker exec -it ntfy ntfy access api_publisher beneco-power-outages writedocker exec -it ntfy ntfy access everyone beneco-power-outages read
docker exec -it ntfy ntfy access api_publisher beneco-power-outages-test writedocker exec -it ntfy ntfy access everyone beneco-power-outages-test read
docker exec -it ntfy ntfy access api_publisher "obaki-user-alerts-*" writedocker exec -it ntfy ntfy access everyone "obaki-user-alerts-*" read
docker exec -it ntfy ntfy accessIf needed, add the explicit config path:
docker exec -it ntfy ntfy --config /etc/ntfy/server.yml access api_publisher beneco-power-outages writePhase 9: Expected ACL Result
Section titled “Phase 9: Expected ACL Result”Expected concept:
user admin (admin)- read-write access to all topics
user api_publisher (user)- write-only access to topic beneco-power-outages- write-only access to topic beneco-power-outages-test- write-only access to topics obaki-user-alerts-*
user * (anonymous)- read-only access to topic beneco-power-outages- read-only access to topic beneco-power-outages-test- read-only access to topics obaki-user-alerts-*- no access to any other topicsPhase 10: Test With Curl
Section titled “Phase 10: Test With Curl”Set variables locally on the VPS:
export NTFY_URL="https://ntfy.yourdomain.com"export NTFY_TOKEN="tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"1. Anonymous publish to main topic should fail
Section titled “1. Anonymous publish to main topic should fail”curl -i \ -d "anonymous spam test" \ "$NTFY_URL/beneco-power-outages"Expected:
401 Unauthorizedor:
403 Forbidden2. API token publish to main topic should succeed
Section titled “2. API token publish to main topic should succeed”curl -i \ -H "Authorization: Bearer $NTFY_TOKEN" \ -d "authorized API publish test" \ "$NTFY_URL/beneco-power-outages"Expected:
200 OK3. Anonymous subscribe/read to main topic should work
Section titled “3. Anonymous subscribe/read to main topic should work”Basic JSON poll test:
curl -i "$NTFY_URL/beneco-power-outages/json?poll=1"Expected:
200 OKThe ntfy mobile app should also be able to subscribe without login.
4. Anonymous publish to user alert topic should fail
Section titled “4. Anonymous publish to user alert topic should fail”curl -i \ -d "anonymous user-alert spam" \ "$NTFY_URL/obaki-user-alerts-test123"Expected:
401 Unauthorizedor:
403 Forbidden5. API publish to user alert topic should work
Section titled “5. API publish to user alert topic should work”curl -i \ -H "Authorization: Bearer $NTFY_TOKEN" \ -d "authorized user alert test" \ "$NTFY_URL/obaki-user-alerts-test123"Expected:
200 OK6. Anonymous random topic should fail
Section titled “6. Anonymous random topic should fail”curl -i "$NTFY_URL/random-topic-that-should-not-exist/json?poll=1"Expected:
401 Unauthorizedor:
403 Forbidden7. API publish to random topic should fail
Section titled “7. API publish to random topic should fail”curl -i \ -H "Authorization: Bearer $NTFY_TOKEN" \ -d "should fail" \ "$NTFY_URL/random-topic-that-should-not-exist"Expected:
401 Unauthorizedor:
403 ForbiddenIf this succeeds, your ACL is too open.
Phase 11: Store the API Token in the .NET API
Section titled “Phase 11: Store the API Token in the .NET API”Do not put the token in source control.
Use an environment variable on the VPS.
Example systemd environment file:
sudo nano /etc/obaki/obaki.envAdd:
Ntfy__BaseUrl=https://ntfy.yourdomain.comNtfy__PublishToken=tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxNtfy__MainTopic=beneco-power-outagesNtfy__TestTopic=beneco-power-outages-testNtfy__UserTopicPrefix=obaki-user-alerts-Lock down the file:
sudo chown root:root /etc/obaki/obaki.envsudo chmod 600 /etc/obaki/obaki.envIn your API systemd service:
[Service]EnvironmentFile=/etc/obaki/obaki.envThen reload and restart:
sudo systemctl daemon-reloadsudo systemctl restart obaki.servicesudo journalctl -u obaki.service -fPhase 12: .NET Publishing Shape
Section titled “Phase 12: .NET Publishing Shape”Your publishing code should add the Bearer token.
Example shape:
public sealed class NtfyOptions{ public required string BaseUrl { get; init; } public required string PublishToken { get; init; } public required string MainTopic { get; init; } public required string UserTopicPrefix { get; init; }}Publishing request:
using System.Net.Http.Headers;using System.Text;
public sealed class NtfyNotificationService{ private readonly HttpClient _httpClient; private readonly NtfyOptions _options;
public NtfyNotificationService(HttpClient httpClient, IOptions<NtfyOptions> options) { _httpClient = httpClient; _options = options.Value; }
public async Task PublishAsync(string topic, string message, CancellationToken cancellationToken) { ValidateTopic(topic);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{_options.BaseUrl.TrimEnd('/')}/{topic}") { Content = new StringContent(message, Encoding.UTF8, "text/plain") };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.PublishToken);
using var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode(); }
private void ValidateTopic(string topic) { if (string.Equals(topic, _options.MainTopic, StringComparison.Ordinal)) return;
if (topic.StartsWith(_options.UserTopicPrefix, StringComparison.Ordinal)) return;
throw new InvalidOperationException($"Publishing to topic '{topic}' is not allowed."); }}Do not accept arbitrary topic names directly from the frontend.
Phase 13: User-Defined Notification Design
Section titled “Phase 13: User-Defined Notification Design”Store user rules in your API
Section titled “Store user rules in your API”Example table:
NotificationRule- Id- PublicId- DisplayName- TopicName- KeywordsJson- IsActive- CooldownMinutes- LastSentAt- CreatedAt- CreatedByIpHash- CreatedByUserId nullableRule creation flow
Section titled “Rule creation flow”1. User submits display name and keywords.2. API validates keywords.3. API rate-limits rule creation.4. API generates topic name.5. API stores the rule.6. API returns subscription URL.Example generated topic:
obaki-user-alerts-k8f3p92mxqGenerate with enough randomness.
Example C# shape:
public static string GenerateUserAlertTopic(){ var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant(); return $"obaki-user-alerts-{id}";}Never allow this
Section titled “Never allow this”User chooses raw ntfy topic.User publishes directly to ntfy.Frontend receives ntfy publish token.API publishes to any topic string the frontend sends.Phase 14: API Abuse Limits
Section titled “Phase 14: API Abuse Limits”Because your VPS is small, add limits before making user-defined notifications public.
Recommended starting limits:
Max rules per IP/device/user: 3Max keywords per rule: 5Min keyword length: 3Max keyword length: 50Cooldown per rule: 30 to 60 minutesMax notifications per rule per day: 10Max custom topics generated per IP per day: 3Reject bad keyword input:
athebaguio.*!!!very long pasted paragraphNormalize keywords:
trimlowercasecollapse spacesremove duplicatesreject symbols-only keywordsTrigger protection:
If a rule has fired recently, skip it.If a notification text matches too many user rules, batch/queue publishing.If ntfy returns 429, back off.If ntfy returns 401/403, disable publisher and alert yourself.Phase 15: Reverse Proxy Hardening
Section titled “Phase 15: Reverse Proxy Hardening”If using Nginx, ntfy should listen locally:
listen-http: "127.0.0.1:8081"Nginx should expose HTTPS publicly.
Example Nginx shape:
server { listen 443 ssl http2; server_name ntfy.yourdomain.com;
client_max_body_size 256k;
location / { proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1; proxy_buffering off; proxy_request_buffering off;
proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3h; proxy_send_timeout 3h; }}Basic rate limit concept:
http { limit_req_zone $binary_remote_addr zone=ntfy_limit:10m rate=30r/m;}Then inside the ntfy server block:
location / { limit_req zone=ntfy_limit burst=60 nodelay;
proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_buffering off; proxy_request_buffering off;
proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3h; proxy_send_timeout 3h;}Do not make the rate limit too aggressive. ntfy subscribers may keep long-lived connections open.
Phase 16: VPS Firewall Check
Section titled “Phase 16: VPS Firewall Check”If using Nginx/Caddy as reverse proxy, expose only:
8044322Do not expose ntfy internal port directly.
Check listening ports:
sudo ss -tulpnIf ntfy listens on 0.0.0.0:8081, change it to:
listen-http: "127.0.0.1:8081"Then restart ntfy.
UFW example:
sudo ufw allow OpenSSHsudo ufw allow 80/tcpsudo ufw allow 443/tcpsudo ufw deny 8081/tcpsudo ufw status verbosePhase 17: Logs to Watch
Section titled “Phase 17: Logs to Watch”ntfy logs:
sudo journalctl -u ntfy -fAPI logs:
sudo journalctl -u obaki.service -fNginx logs:
sudo tail -f /var/log/nginx/access.logsudo tail -f /var/log/nginx/error.logDocker ntfy logs:
docker logs -f ntfyWatch for:
many 401/403 responsesmany 429 responsesrandom topic guessingrepeated POST requests from one IPAPI publish failuresntfy memory growthhigh open connectionsPhase 18: Token Rotation
Section titled “Phase 18: Token Rotation”When you rotate the token:
1. Create a new token for api_publisher.2. Put the new token in the API environment file.3. Restart the API.4. Test publishing.5. Remove the old token.6. Confirm the old token fails.Commands:
sudo ntfy token add --label="obaki-api-publisher-rotated" api_publishersudo ntfy token list api_publisherAfter updating the API:
sudo systemctl restart obaki.serviceRemove old token:
sudo ntfy token remove api_publisher tk_oldtokenhereTest old token:
curl -i \ -H "Authorization: Bearer tk_oldtokenhere" \ -d "old token should fail" \ "$NTFY_URL/beneco-power-outages"Expected:
401 Unauthorizedor:
403 ForbiddenPhase 19: Rollback Plan
Section titled “Phase 19: Rollback Plan”If users can no longer subscribe after enabling auth:
sudo ntfy access everyone beneco-power-outages readsudo ntfy access everyone beneco-power-outages-test readsudo ntfy access everyone "obaki-user-alerts-*" readsudo systemctl restart ntfyIf the API can no longer publish:
sudo ntfy access api_publisher beneco-power-outages writesudo ntfy access api_publisher beneco-power-outages-test writesudo ntfy access api_publisher "obaki-user-alerts-*" writesudo systemctl restart ntfyIf ntfy is broken after config changes:
sudo cp /root/backups/ntfy/server.yml.YYYY-MM-DD-HHMMSS /etc/ntfy/server.ymlsudo systemctl restart ntfyReplace the backup filename with your real backup.
Phase 20: Final Checklist
Section titled “Phase 20: Final Checklist”Before considering this done:
[ ] ntfy has auth-file configured.[ ] ntfy has auth-default-access set to deny-all.[ ] admin user exists.[ ] api_publisher user exists and is not admin.[ ] API token belongs to api_publisher.[ ] anonymous users can read beneco-power-outages.[ ] anonymous users cannot publish to beneco-power-outages.[ ] api_publisher can publish to beneco-power-outages.[ ] anonymous users can read obaki-user-alerts-*.[ ] anonymous users cannot publish to obaki-user-alerts-*.[ ] api_publisher can publish to obaki-user-alerts-*.[ ] random topics are blocked.[ ] API token is stored only on VPS.[ ] frontend never sees the token.[ ] API validates allowed topic names.[ ] reverse proxy only exposes HTTPS.[ ] internal ntfy port is not publicly exposed.[ ] logs are checked after rollout.Recommended Final State
Section titled “Recommended Final State”auth-file: "/var/lib/ntfy/user.db"auth-default-access: "deny-all"ntfy access api_publisher beneco-power-outages writentfy access everyone beneco-power-outages read
ntfy access api_publisher beneco-power-outages-test writentfy access everyone beneco-power-outages-test read
ntfy access api_publisher "obaki-user-alerts-*" writentfy access everyone "obaki-user-alerts-*" readThe API owns publishing. Users only subscribe.
That is the correct boundary for your VPS.
