ntfy on Kubernetes: push notifications over HTTP
ntfy 2.37.2, a server that sends push notifications to phones, browsers and scripts subscribed to a topic, as a private instance with its message cache, users and attachments on a claim, designed in kadet-hub on ntfy’s image and rendered by the kadet-hub kubernetes generator 1.14.0.
- Latest version:
1.0.0, published - Source: container image
docker.io/binwiederhier/ntfy:v2.37.2@sha256:1875ddcf815c8a3c981d6d78c67a93452d0abba79b1ab9520b1f5f5238902bd7, recipe designed in kadet-hub - Proof level:
deploys, deployed on kind and smoke tested by the kadet-hub/recipes CI renderspassed in the release run of with check 0.20.2, kind v0.33.0- Output capabilities: none
- Inventory backends:
omegaconf - Generators: kubernetes 1.14.0
sha256:787129e1eaed6b9bc868fe4c4aec58d40de6a89fc3cc08424a4cb5822b855080 - Image security: grade D 5 findings, one per advisory and package, in the 1 image of version 1.0.0: CRITICAL 0, HIGH 1, MEDIUM 2, LOW 0, UNKNOWN 2. Scanned 2026-11-14 12:09 UTC. Full report
The grade rates the upstream images the recipe pins, not the recipe:docker.io/binwiederhier/ntfy.
What it deploys
recipes.ntfy(init.yml)recipes.ntfy.ntfy(ntfy.yml):ntfy
Required parameters
applications.ntfy.base_url is NTFY_BASE_URL, the URL that phones and
browsers reach ntfy at, such as https://ntfy.example.com behind an
Ingress (DEC-338 in the spec). ntfy builds the links of attachments from
it, and refuses to start with a path in the URL.
applications.ntfy.auth_users is NTFY_AUTH_USERS, the users of the
private instance as <name>:<bcrypt hash>:<role>, several separated by
commas, with the role admin or user (DEC-337). Set it as a ref.
ntfy user hash of the image asks for a password and prints its hash.
Without either parameter the compile stops with Interpolation key and
its name.
Include
A target includes the recipe and its options as classes and can override anything the recipe sets:
classes:
- recipes.ntfy
parameters:
namespace: ntfy
kapitan:
vars:
target: ntfy
applications:
ntfy:
base_url: https://ntfy.example.com
auth_users: <ref>
Fetching it
The quickstart goes through these steps on a kind cluster with the uptime-kuma recipe, from installing Kapitan to the running app.
A separate fetch inventory pins the recipe by version and digest. This is fetch/targets/recipes.yml for version 1.0.0:
parameters:
kapitan:
dependencies:
- type: oci
source: ghcr.io/kadet-hub/recipes/ntfy:1.0.0@sha256:e2af8ed9c7b9c049002b41874a95e9c5a4a860b934f9dfd475c8859f852a2bc8
output_path: inventory/classes/recipes/ntfy
The first command fetches the recipe into inventory/classes/recipes/ntfy, the second the generators and compiles:
kapitan compile --inventory-path fetch --fetch --output-path .
kapitan compile --fetch
The first command exits with status 1 because the fetch target compiles nothing. Kapitan's --fetch checks neither the digest nor the attestation. This verifies the attestation of the recipe:
gh attestation verify \
oci://ghcr.io/kadet-hub/recipes/ntfy@sha256:e2af8ed9c7b9c049002b41874a95e9c5a4a860b934f9dfd475c8859f852a2bc8 \
--owner kadet-hub \
--cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main
The attested index must also list each digest under its recipe with the pinned version, and not as yanked. Verifying by hand shows how to read it.
Settings
| Setting | Inventory |
|---|---|
| ntfy version | components.ntfy.image, tag and digest |
access entries, tokens and other settings, such as NTFY_AUTH_ACCESS or NTFY_CACHE_DURATION |
components.ntfy.env.<variable>, credentials through components.ntfy.secrets with a ref |
size of the claim data, 1Gi |
components.ntfy.volume_claims.data.spec.resources.requests.storage |
| exposure | a route or Ingress of the target to the Service ntfy on port 80 |
| resources | components.ntfy.resources |
The claim data is mounted at /var/lib/ntfy. It holds the message cache
cache.db, the user database auth.db and the directory attachments.
ntfy keeps messages for 12 hours and attachments for 3 hours by default.
SQLite allows one replica.
NTFY_BEHIND_PROXY is true, so ntfy counts each visitor by the last
address in X-Forwarded-For, which an Ingress or a Gateway adds. Without
it every visitor behind the proxy would share one rate limit. A client
that reaches the Service directly can set the header itself.
ntfy listens on its default port 80. The pod sets the sysctl
net.ipv4.ip_unprivileged_port_start to 0, so UID 1000 binds the port
without a capability (DEC-336 in the spec). Pod Security baseline and
restricted allow this sysctl.
The readiness probe gets /v1/ready, the endpoint ntfy’s documentation
names for load balancers. There is no liveness probe.
Proof
check ntfy --level deploys with tests/target.yml, on kind for each
Kubernetes minor CI tests. The image has no Kubernetes objects to compare
with, so the recipe is proven by deploying it (RCP-44).
tests/smoke checks that the pod passes Pod Security restricted and
starts a pod of ntfy’s image, which carries the ntfy client and busybox
wget. From there, through the Service ntfy on port 80, it
- gets
/v1/health - gets 403 for a message without credentials and 401 for one with a wrong password
- publishes a message and a file of 4096 random bytes as the admin of
tests/target.yml, finds both withpoll=1, requires that the file’s attachment URL starts with the base URL oftests/target.yml, and downloads the file byte for byte from/file/ - deletes the pod
ntfy-0and, once its successor is ready, finds both messages again as the admin and downloads the file again.
Versions
1.0.0, :sha256:e2af8ed9c7b9c049002b41874a95e9c5a4a860b934f9dfd475c8859f852a2bc8
The recipe's files are in kadet-hub/recipes. All recipes