Skip to content

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.

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-outages
beneco-power-outages-test
beneco-scheduled-outages
beneco-outages-alerts-*
obaki-system-*

Note: User outage alert topics use prefix beneco-outages-alerts-* (see ../architecture/unified-notification-pipeline-plan.md). Older docs referenced obaki-user-alerts-*; update ntfy ACL accordingly.

Recommended rule:

auth-default-access: deny-all

Then explicitly allow only what you need.


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-topic

If 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-k8f3p92mxq

Do not put addresses, real names, barangays, or personal descriptions in topic names. Treat public-read topic names as subscription secrets.


Use this:

Default access:
deny-all
Main outage topic:
anonymous read-only
api_publisher write-only
Test outage topic:
anonymous read-only
api_publisher write-only
User-defined topics:
anonymous read-only for obaki-user-alerts-*
api_publisher write-only for obaki-user-alerts-*
Everything else:
denied

This 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

Common files:

Terminal window
/etc/ntfy/server.yml
/var/lib/ntfy/user.db
/var/cache/ntfy/cache.db

Common service commands:

Terminal window
sudo systemctl status ntfy
sudo systemctl restart ntfy
sudo journalctl -u ntfy -f

Check containers:

Terminal window
docker ps

Common commands:

Terminal window
docker logs -f ntfy
docker exec -it ntfy sh
docker restart ntfy

If your container name is different, replace ntfy.


Do this before enabling ACLs.

Terminal window
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)
fi

Back up your compose file and mounted config directory.

Example:

Terminal window
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)

Open the config:

Terminal window
sudo nano /etc/ntfy/server.yml

Minimum 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: true

Adjust:

https://ntfy.yourdomain.com

to 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.


Terminal window
sudo systemctl restart ntfy
sudo systemctl status ntfy --no-pager

Watch logs:

Terminal window
sudo journalctl -u ntfy -f
Terminal window
docker restart ntfy
docker logs -f ntfy

If ntfy fails to start, restore the backup config and restart again.


You need two users:

admin
api_publisher

Create admin:

Terminal window
sudo ntfy user add --role=admin admin

Create API publisher:

Terminal window
sudo ntfy user add api_publisher

List users:

Terminal window
sudo ntfy user list

Expected idea:

admin role: admin
api_publisher role: user

Run the same commands inside the ntfy container:

Terminal window
docker exec -it ntfy ntfy user add --role=admin admin
docker exec -it ntfy ntfy user add api_publisher
docker exec -it ntfy ntfy user list

If your container does not automatically detect the config, use:

Terminal window
docker exec -it ntfy ntfy --config /etc/ntfy/server.yml user list

Then use the same --config /etc/ntfy/server.yml pattern for the other ntfy CLI commands.


Create a token for the API publisher user only.

Terminal window
sudo ntfy token add --label="obaki-api-publisher" api_publisher
Terminal window
docker exec -it ntfy ntfy token add --label="obaki-api-publisher" api_publisher

If needed:

Terminal window
docker exec -it ntfy ntfy --config /etc/ntfy/server.yml token add --label="obaki-api-publisher" api_publisher

Copy the generated token. It should look like:

tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Do 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.


Use everyone for anonymous access. It is clearer and avoids shell wildcard expansion problems.

Main outage topic:

Terminal window
sudo ntfy access api_publisher beneco-power-outages write
sudo ntfy access everyone beneco-power-outages read

Test topic:

Terminal window
sudo ntfy access api_publisher beneco-scheduled-outages-test write
sudo ntfy access everyone beneco-scheduled-outages-test read

Future user-defined alert topics:

Terminal window
sudo ntfy access api_publisher "obaki-user-alerts-*" write
sudo ntfy access everyone "obaki-user-alerts-*" read

Optional system topics:

Terminal window
sudo ntfy access api_publisher "obaki-system-*" write
sudo ntfy access admin "obaki-system-*" read-write

List ACL:

Terminal window
sudo ntfy access
Terminal window
docker exec -it ntfy ntfy access api_publisher beneco-power-outages write
docker exec -it ntfy ntfy access everyone beneco-power-outages read
docker exec -it ntfy ntfy access api_publisher beneco-power-outages-test write
docker exec -it ntfy ntfy access everyone beneco-power-outages-test read
docker exec -it ntfy ntfy access api_publisher "obaki-user-alerts-*" write
docker exec -it ntfy ntfy access everyone "obaki-user-alerts-*" read
docker exec -it ntfy ntfy access

If needed, add the explicit config path:

Terminal window
docker exec -it ntfy ntfy --config /etc/ntfy/server.yml access api_publisher beneco-power-outages write

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 topics

Set variables locally on the VPS:

Terminal window
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”
Terminal window
curl -i \
-d "anonymous spam test" \
"$NTFY_URL/beneco-power-outages"

Expected:

401 Unauthorized

or:

403 Forbidden

2. API token publish to main topic should succeed

Section titled “2. API token publish to main topic should succeed”
Terminal window
curl -i \
-H "Authorization: Bearer $NTFY_TOKEN" \
-d "authorized API publish test" \
"$NTFY_URL/beneco-power-outages"

Expected:

200 OK

3. Anonymous subscribe/read to main topic should work

Section titled “3. Anonymous subscribe/read to main topic should work”

Basic JSON poll test:

Terminal window
curl -i "$NTFY_URL/beneco-power-outages/json?poll=1"

Expected:

200 OK

The 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”
Terminal window
curl -i \
-d "anonymous user-alert spam" \
"$NTFY_URL/obaki-user-alerts-test123"

Expected:

401 Unauthorized

or:

403 Forbidden

5. API publish to user alert topic should work

Section titled “5. API publish to user alert topic should work”
Terminal window
curl -i \
-H "Authorization: Bearer $NTFY_TOKEN" \
-d "authorized user alert test" \
"$NTFY_URL/obaki-user-alerts-test123"

Expected:

200 OK
Terminal window
curl -i "$NTFY_URL/random-topic-that-should-not-exist/json?poll=1"

Expected:

401 Unauthorized

or:

403 Forbidden

7. API publish to random topic should fail

Section titled “7. API publish to random topic should fail”
Terminal window
curl -i \
-H "Authorization: Bearer $NTFY_TOKEN" \
-d "should fail" \
"$NTFY_URL/random-topic-that-should-not-exist"

Expected:

401 Unauthorized

or:

403 Forbidden

If 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:

Terminal window
sudo nano /etc/obaki/obaki.env

Add:

Ntfy__BaseUrl=https://ntfy.yourdomain.com
Ntfy__PublishToken=tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Ntfy__MainTopic=beneco-power-outages
Ntfy__TestTopic=beneco-power-outages-test
Ntfy__UserTopicPrefix=obaki-user-alerts-

Lock down the file:

Terminal window
sudo chown root:root /etc/obaki/obaki.env
sudo chmod 600 /etc/obaki/obaki.env

In your API systemd service:

[Service]
EnvironmentFile=/etc/obaki/obaki.env

Then reload and restart:

Terminal window
sudo systemctl daemon-reload
sudo systemctl restart obaki.service
sudo journalctl -u obaki.service -f

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”

Example table:

NotificationRule
- Id
- PublicId
- DisplayName
- TopicName
- KeywordsJson
- IsActive
- CooldownMinutes
- LastSentAt
- CreatedAt
- CreatedByIpHash
- CreatedByUserId nullable
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-k8f3p92mxq

Generate with enough randomness.

Example C# shape:

public static string GenerateUserAlertTopic()
{
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
return $"obaki-user-alerts-{id}";
}
User chooses raw ntfy topic.
User publishes directly to ntfy.
Frontend receives ntfy publish token.
API publishes to any topic string the frontend sends.

Because your VPS is small, add limits before making user-defined notifications public.

Recommended starting limits:

Max rules per IP/device/user: 3
Max keywords per rule: 5
Min keyword length: 3
Max keyword length: 50
Cooldown per rule: 30 to 60 minutes
Max notifications per rule per day: 10
Max custom topics generated per IP per day: 3

Reject bad keyword input:

a
the
baguio
.
*
!!!
very long pasted paragraph

Normalize keywords:

trim
lowercase
collapse spaces
remove duplicates
reject symbols-only keywords

Trigger 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.

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.


If using Nginx/Caddy as reverse proxy, expose only:

80
443
22

Do not expose ntfy internal port directly.

Check listening ports:

Terminal window
sudo ss -tulpn

If ntfy listens on 0.0.0.0:8081, change it to:

listen-http: "127.0.0.1:8081"

Then restart ntfy.

UFW example:

Terminal window
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw deny 8081/tcp
sudo ufw status verbose

ntfy logs:

Terminal window
sudo journalctl -u ntfy -f

API logs:

Terminal window
sudo journalctl -u obaki.service -f

Nginx logs:

Terminal window
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/nginx/error.log

Docker ntfy logs:

Terminal window
docker logs -f ntfy

Watch for:

many 401/403 responses
many 429 responses
random topic guessing
repeated POST requests from one IP
API publish failures
ntfy memory growth
high open connections

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:

Terminal window
sudo ntfy token add --label="obaki-api-publisher-rotated" api_publisher
sudo ntfy token list api_publisher

After updating the API:

Terminal window
sudo systemctl restart obaki.service

Remove old token:

Terminal window
sudo ntfy token remove api_publisher tk_oldtokenhere

Test old token:

Terminal window
curl -i \
-H "Authorization: Bearer tk_oldtokenhere" \
-d "old token should fail" \
"$NTFY_URL/beneco-power-outages"

Expected:

401 Unauthorized

or:

403 Forbidden

If users can no longer subscribe after enabling auth:

Terminal window
sudo ntfy access everyone beneco-power-outages read
sudo ntfy access everyone beneco-power-outages-test read
sudo ntfy access everyone "obaki-user-alerts-*" read
sudo systemctl restart ntfy

If the API can no longer publish:

Terminal window
sudo ntfy access api_publisher beneco-power-outages write
sudo ntfy access api_publisher beneco-power-outages-test write
sudo ntfy access api_publisher "obaki-user-alerts-*" write
sudo systemctl restart ntfy

If ntfy is broken after config changes:

Terminal window
sudo cp /root/backups/ntfy/server.yml.YYYY-MM-DD-HHMMSS /etc/ntfy/server.yml
sudo systemctl restart ntfy

Replace the backup filename with your real backup.


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.

auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
Terminal window
ntfy access api_publisher beneco-power-outages write
ntfy access everyone beneco-power-outages read
ntfy access api_publisher beneco-power-outages-test write
ntfy access everyone beneco-power-outages-test read
ntfy access api_publisher "obaki-user-alerts-*" write
ntfy access everyone "obaki-user-alerts-*" read

The API owns publishing. Users only subscribe.

That is the correct boundary for your VPS.