ntfy
Self-hosted push notification service using simple HTTP pub-sub. Publish notifications from any script, CI pipeline,
or monitoring system with a single curl command. Subscribe on Android, iOS, or the web. No signup, no cost, no
third-party service — all traffic stays on your infrastructure.
ntfy.authDefaultAccess defaults to read-write, meaning any unauthenticated user can publish and subscribe to any
topic. If you expose ntfy to the internet, set ntfy.authDefaultAccess: deny-all and configure users or OIDC to
prevent unauthorized access and notification spam.
Key Features
- HTTP pub-sub — publish via
PUT/POST, subscribe viaGETor WebSocket - Mobile and web clients — Android, iOS apps and progressive web app
- Access control — per-topic user and permission management
- Attachment support — configurable file attachment size and expiry limits
- Abuse ban-feed — opt-in weighted offender feed for fail2ban or equivalent enforcement
- Behind-proxy mode — correct client IP identification via
X-Forwarded-For - Prometheus metrics — optional
/metricsendpoint with ServiceMonitor support - Persistent storage — PVC-backed SQLite cache and authentication databases
- PostgreSQL — external database URL loaded from a Kubernetes Secret
- External Secrets — optional credential reconciliation from provider-backed stores
- Gateway API — optional HTTPRoute rendering for modern ingress controllers
- Secure runtime — non-root execution, read-only root filesystem, dropped capabilities, seccomp, and no API token
- Production controls — resource bounds, bounded temporary storage, optional NetworkPolicy and PodDisruptionBudget
- S3 backup — optional scheduled archive of the ntfy data volume
Architecture and Boundaries
The chart runs one ntfy Deployment behind a ClusterIP Service. ntfy listens on the configurable
containerPorts.http port (2586 by default), while service.port remains the stable client-facing port. The ConfigMap
is mounted read-only at /etc/ntfy; /tmp is a bounded emptyDir; and /var/cache/ntfy is backed by a PVC or a
bounded ephemeral volume.
SQLite is the default store. PostgreSQL can come from the HelmForge subchart or an external service, but it does not make this single-replica chart highly available and does not provide shared attachment storage. Ingress and Gateway API are optional edge routes. The chart does not install an edge controller, cert-manager, Prometheus Operator, External Secrets Operator, an SMTP server, an OIDC provider, or an S3 service.
Installation
HTTPS repository:
helm repo add helmforge https://repo.helmforge.dev
helm repo update
helm install ntfy helmforge/ntfy
OCI registry:
helm install ntfy oci://ghcr.io/helmforgedev/helm/ntfy
Upgrade guidance
The current chart defaults to ntfy 2.32.0. The chart remains single-replica because upstream clustering and local
attachment sharing are outside its production boundary. Back up persistent state and any PostgreSQL database, then
validate publishing, cache replay, authentication, email delivery, web push, and attachment workflows. Existing values
remain compatible. The
--reset-then-reuse-values flag retains an explicit image.tag from the current release; remove or update that override
if you want to adopt the chart’s v2.32.0 default. Review the
2.32.0 release:
helm upgrade ntfy helmforge/ntfy \
--reset-then-reuse-values
ntfy 2.31.1 migrated the message database schema from version 16 to 17. After upgrading an instance from ntfy 2.30.0
or earlier to 2.32.0, rolling back to 2.31.0 or earlier requires manually resetting the recorded message schema version
before starting the older server. For PostgreSQL, run UPDATE schema_version SET version = 16 WHERE store = 'message';.
For the SQLite cache file, run UPDATE schemaVersion SET version = 16 WHERE id = 1;. Rolling back to 2.31.1 or later
does not require this adjustment because those releases tolerate newer message schemas. Review the upstream
2.31.1 migration and rollback notes
before downgrading.
Deployment Examples
# values.yaml — ntfy with ingress, behind Traefik
ntfy:
baseUrl: 'https://ntfy.example.com'
behindProxy: true
persistence:
enabled: true
size: 2Gi
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: ntfy.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: ntfy-tls
hosts:
- ntfy.example.comAfter deploying, send your first notification:
curl -d "Hello from Kubernetes!" https://ntfy.example.com/my-topic# values.yaml — ntfy with authentication enabled (deny anonymous access)
ntfy:
baseUrl: 'https://ntfy.example.com'
behindProxy: true
authDefaultAccess: deny-all
extraConfig: |
auth-file: /var/cache/ntfy/user.db
persistence:
enabled: true
size: 2Gi
ingress:
enabled: true
ingressClassName: traefik
hosts:
- host: ntfy.example.com
paths:
- path: /
pathType: PrefixAfter deploying, create users via the ntfy CLI inside the pod:
# Create an admin user
kubectl exec -it deployment/ntfy -- ntfy user add --role=admin admin
# Publish with authentication
curl -u admin:password -d "Secure notification" https://ntfy.example.com/my-topic# values.yaml — ntfy with file attachment support
ntfy:
baseUrl: 'https://ntfy.example.com'
behindProxy: true
attachmentTotalSizeLimit: '200M'
attachmentFileSizeLimit: '20M'
attachmentExpiryDuration: '24h'
persistence:
enabled: true
# Increase PVC size to accommodate attachments
size: 10Gi
ingress:
enabled: true
ingressClassName: traefik
hosts:
- host: ntfy.example.com
paths:
- path: /
pathType: Prefix# values.yaml — emit abusive visitors for an external fail2ban consumer
ntfy:
banFeed:
enabled: true
file: /var/cache/ntfy/ban.log
window: 10m
threshold: 100
weights:
- '42909:10'The default file is stored on ntfy’s writable data volume. Configure an external consumer and rotate the file with copy-truncate semantics so it cannot grow without bound. The chart does not install fail2ban or modify node firewall rules.
# values.yaml — PostgreSQL URL from a provider-managed Secret
ntfy:
authDefaultAccess: deny-all
database:
enabled: true
existingSecret: ntfy-database
existingSecretKey: database-url
persistence:
enabled: false
externalSecrets:
enabled: true
items:
- fullnameOverride: ntfy-database
spec:
secretStoreRef:
name: production-secrets
kind: ClusterSecretStore
target:
name: ntfy-database
data:
- secretKey: database-url
remoteRef:
key: ntfy/database
property: urlThe provider value must be a complete PostgreSQL connection URL. PostgreSQL replaces the message cache, access control, and web push SQLite stores. Keep persistence enabled if attachments, the abuse ban-feed, or other local files must survive pod replacement.
# values.yaml — ntfy with Prometheus metrics
ntfy:
baseUrl: 'https://ntfy.example.com'
enableMetrics: true
# Expose metrics on a separate port to avoid routing /metrics via Ingress
metricsListenHttp: ':9090'
persistence:
enabled: true
size: 2Gi
metrics:
serviceMonitor:
enabled: true
interval: 30s
ingress:
enabled: true
ingressClassName: traefik
hosts:
- host: ntfy.example.com
paths:
- path: /
pathType: Prefix# values.yaml — ntfy exposed with Gateway API
ntfy:
baseUrl: 'https://ntfy.example.com'
behindProxy: true
persistence:
enabled: true
size: 2Gi
gateway:
enabled: true
parentRefs:
- name: public
namespace: gateway-system
hostnames:
- ntfy.example.comConfiguration Reference
Core
| Parameter | Type | Default | Description |
|---|---|---|---|
nameOverride |
string | "" |
Override the chart name. |
fullnameOverride |
string | "" |
Override the full release name. |
commonLabels |
object | {} |
Extra labels added to all resources. |
global |
object | {} |
Reserved global values contract. |
Image
| Parameter | Type | Default | Description |
|---|---|---|---|
image.repository |
string | docker.io/binwiederhier/ntfy |
ntfy container image. |
image.tag |
string | "v2.32.0" |
Image tag. |
image.pullPolicy |
string | IfNotPresent |
Image pull policy. |
imagePullSecrets |
array | [] |
Pull secrets for private registries. |
ntfy Configuration
The listener is controlled only by containerPorts.http; attempts to override listen-http through extraConfig or
NTFY_LISTEN_HTTP through extraEnv are rejected so the process, Service, and probes remain aligned.
| Parameter | Type | Default | Description |
|---|---|---|---|
ntfy.baseUrl |
string | "" |
Public base URL of the instance (e.g. https://ntfy.example.com). |
ntfy.authDefaultAccess |
string | read-write |
Default access for unauthenticated users: read-write, read-only, write-only, deny-all. |
ntfy.behindProxy |
boolean | true |
Trust X-Forwarded-For headers for correct client IP and rate limiting. |
ntfy.enableMetrics |
boolean | false |
Enable Prometheus metrics at /metrics. |
ntfy.metricsListenHttp |
string | "" |
Separate listen address for the metrics endpoint (e.g. :9090). |
ntfy.attachmentTotalSizeLimit |
string | "" |
Total attachment storage limit per visitor (e.g. 100M). |
ntfy.attachmentFileSizeLimit |
string | "" |
Maximum size per attachment file (e.g. 15M). |
ntfy.attachmentExpiryDuration |
string | "" |
How long attachments are retained (e.g. 3h). |
ntfy.banFeed.enabled |
boolean | false |
Enable the weighted abuse ban-feed. |
ntfy.banFeed.file |
string | /var/cache/ntfy/ban.log |
Writable file where confirmed offenders are appended. |
ntfy.banFeed.window |
string | 10m |
Rolling window used to count weighted strikes per visitor prefix. |
ntfy.banFeed.threshold |
integer | 100 |
Weighted strikes per window before a prefix is emitted. |
ntfy.banFeed.weights |
array | ["42909:10"] |
Per-code weights in KEY:WEIGHT format; longest match wins. |
ntfy.database.enabled |
boolean | false |
Use PostgreSQL instead of the SQLite-backed stores. |
ntfy.database.existingSecret |
string | "" |
Secret containing the PostgreSQL connection URL. |
ntfy.database.existingSecretKey |
string | database-url |
Secret key exposed to ntfy as NTFY_DATABASE_URL. |
ntfy.extraConfig |
string | "" |
Raw server.yml configuration appended to the generated ConfigMap. |
ntfy.extraEnv |
array | [] |
Extra environment variables injected into the container. |
containerPorts.http |
integer | 2586 |
Port on which the ntfy process listens. |
The ntfy.extraConfig field appends raw server.yml lines to the generated ConfigMap. Use it for any ntfy server
option not exposed as a dedicated value, such as authentication providers, Firebase credentials, or per-topic limits.
See the ntfy server configuration reference for all available options.
When ntfy.database.enabled is true, the chart omits cache-file, auth-file, and web-push-file and loads the
database URL from the selected Secret. PostgreSQL stores messages, users, access control, and web push subscriptions.
This chart still deploys one ntfy replica. Local filesystem attachments are not shared, so PostgreSQL alone is not a complete highly available architecture.
External Secrets
| Parameter | Type | Default | Description |
|---|---|---|---|
externalSecrets.enabled |
boolean | false |
Render ExternalSecret resources. |
externalSecrets.refreshInterval |
string | 1h |
Default provider synchronization interval. |
externalSecrets.items |
array | [] |
ExternalSecret definitions with complete specs. |
Each item supports name, fullnameOverride, labels, annotations, and a complete ExternalSecret spec. Configure
either spec.secretStoreRef or a per-source sourceRef. The target Secret name must match
ntfy.database.existingSecret. See the
External Secrets Operator documentation for provider configuration.
When running behind a Kubernetes Ingress controller, all requests arrive from the Ingress pod IP unless
X-Forwarded-For is trusted. Keep ntfy.behindProxy: true (the default) so rate limits and client IP logging work
correctly.
Enabling ntfy.banFeed only writes confirmed offenders to the configured file. Connect fail2ban or equivalent
enforcement separately, and rotate the file with copy-truncate semantics. The default file lives on the data PVC, so
it survives pod restarts when persistence is enabled.
Persistence
By default, ntfy stores its SQLite cache and authentication databases in /var/cache/ntfy. Persistence survives pod
restarts. With PostgreSQL, the PVC may be disabled only when attachments, the ban-feed, and custom file-backed options
do not require durable local storage.
| Parameter | Type | Default | Description |
|---|---|---|---|
persistence.enabled |
boolean | true |
Enable a PVC for /var/cache/ntfy. |
persistence.size |
string | 2Gi |
PVC size. Increase if using attachments. |
persistence.storageClass |
string | "" |
StorageClass for the PVC. |
persistence.accessModes |
array | ["ReadWriteOnce"] |
PVC access modes. |
persistence.existingClaim |
string | "" |
Use an existing PVC instead of creating one. |
persistence.emptyDirSizeLimit |
string | 1Gi |
Data-volume limit when persistence is disabled. |
Service
| Parameter | Type | Default | Description |
|---|---|---|---|
service.type |
string | ClusterIP |
Kubernetes service type. |
service.port |
integer | 80 |
Service port exposed to the cluster. |
service.annotations |
object | {} |
Annotations for the Service. |
Ingress
| Parameter | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
boolean | false |
Enable an Ingress resource. |
ingress.ingressClassName |
string | traefik |
Ingress class name. |
ingress.annotations |
object | {} |
Annotations for the Ingress (e.g. cert-manager). |
ingress.hosts |
array | [] |
Ingress host and path rules. |
ingress.tls |
array | [] |
TLS configuration (secret name and hosts). |
Gateway API
| Parameter | Type | Default | Description |
|---|---|---|---|
gateway.enabled |
boolean | false |
Render an HTTPRoute. Requires Gateway API CRDs and a compatible Gateway. |
gateway.annotations |
object | {} |
Annotations for the HTTPRoute. |
gateway.parentRefs |
array | [] |
Gateway parent references. Required when gateway.enabled=true. |
gateway.hostnames |
array | [] |
Hostnames matched by the HTTPRoute. |
gateway.path |
string | / |
Path value matched by the HTTPRoute. |
gateway.pathType |
string | PathPrefix |
Path match type. |
Probes
Probes use the /v1/health endpoint.
| Parameter | Type | Default | Description |
|---|---|---|---|
probes.startup.enabled |
boolean | true |
Enable startup probe. |
probes.startup.initialDelaySeconds |
integer | 5 |
Startup probe initial delay. |
probes.startup.periodSeconds |
integer | 5 |
Startup probe period. |
probes.startup.timeoutSeconds |
integer | 3 |
Startup probe timeout. |
probes.startup.failureThreshold |
integer | 30 |
Startup probe failure threshold. |
probes.liveness.enabled |
boolean | true |
Enable liveness probe. |
probes.liveness.initialDelaySeconds |
integer | 0 |
Liveness probe initial delay. |
probes.liveness.periodSeconds |
integer | 15 |
Liveness probe period. |
probes.liveness.timeoutSeconds |
integer | 5 |
Liveness probe timeout. |
probes.liveness.failureThreshold |
integer | 3 |
Liveness probe failure threshold. |
probes.readiness.enabled |
boolean | true |
Enable readiness probe. |
probes.readiness.initialDelaySeconds |
integer | 0 |
Readiness probe initial delay. |
probes.readiness.periodSeconds |
integer | 10 |
Readiness probe period. |
probes.readiness.timeoutSeconds |
integer | 5 |
Readiness probe timeout. |
probes.readiness.failureThreshold |
integer | 3 |
Readiness probe failure threshold. |
Metrics
| Parameter | Type | Default | Description |
|---|---|---|---|
metrics.serviceMonitor.enabled |
boolean | false |
Create a ServiceMonitor for Prometheus Operator. |
metrics.serviceMonitor.interval |
string | 30s |
Prometheus scrape interval. |
metrics.serviceMonitor.labels |
object | {} |
Extra labels applied to the ServiceMonitor. |
Resources and Security
| Parameter | Type | Default | Description |
|---|---|---|---|
resources |
object | requests 50m/64Mi, limits 500m/256Mi |
CPU and memory requests and limits. |
podSecurityContext |
object | non-root UID/GID 1000, RuntimeDefault |
Pod-level security context. |
securityContext |
object | read-only, no escalation, drop ALL |
Container-level security context. |
Service Account
| Parameter | Type | Default | Description |
|---|---|---|---|
serviceAccount.create |
boolean | false |
Create a dedicated ServiceAccount. |
serviceAccount.name |
string | "" |
Override the ServiceAccount name. |
serviceAccount.annotations |
object | {} |
Annotations for the ServiceAccount. |
serviceAccount.automountServiceAccountToken |
boolean | false |
Mount a Kubernetes API token into ntfy and backup pods. |
Runtime Security and Availability
The defaults run as UID/GID 1000 with RuntimeDefault seccomp, a read-only root filesystem, no privilege escalation,
and all capabilities dropped. CPU and memory requests and limits are set by default; tune them from observed usage.
| Parameter | Type | Default | Description |
|---|---|---|---|
resources.requests.cpu |
string | 50m |
Default CPU request. |
resources.requests.memory |
string | 64Mi |
Default memory request. |
resources.limits.cpu |
string | 500m |
Default CPU limit. |
resources.limits.memory |
string | 256Mi |
Default memory limit. |
podSecurityContext.runAsNonRoot |
boolean | true |
Require non-root execution. |
podSecurityContext.runAsUser |
integer | 1000 |
Pod user ID. |
podSecurityContext.runAsGroup |
integer | 1000 |
Pod group ID. |
podSecurityContext.fsGroup |
integer | 1000 |
Writable-volume group ID. |
podSecurityContext.fsGroupChangePolicy |
string | OnRootMismatch |
Avoid unnecessary recursive ownership changes. |
podSecurityContext.seccompProfile.type |
string | RuntimeDefault |
Container syscall profile. |
securityContext.allowPrivilegeEscalation |
boolean | false |
Deny privilege escalation. |
securityContext.readOnlyRootFilesystem |
boolean | true |
Keep the image filesystem read-only. |
securityContext.capabilities.drop |
array | ["ALL"] |
Drop Linux capabilities. |
runtimeStorage.tmpSizeLimit |
string | 128Mi |
Bound writable /tmp storage. |
podDisruptionBudget.enabled |
boolean | false |
Render a disruption budget for the singleton pod. |
podDisruptionBudget.minAvailable |
integer | 1 |
Keep one pod available; on a singleton, voluntary eviction and node drain wait until this value changes or the PDB is removed. |
podDisruptionBudget.maxUnavailable |
integer | null |
Alternative maximum unavailable pod count. |
Network Policy
Ingress isolation can be enabled independently. When egress isolation is enabled, list every external dependency.
External PostgreSQL requires an explicit database CIDR; SMTP, OIDC, web push, and S3 need suitable extraEgress rules.
| Parameter | Type | Default | Description |
|---|---|---|---|
networkPolicy.enabled |
boolean | false |
Render a NetworkPolicy. |
networkPolicy.ingressFrom |
array | [] |
Peers allowed to reach the ntfy and optional metrics ports. |
networkPolicy.egress.enabled |
boolean | false |
Apply egress isolation. |
networkPolicy.egress.allowDNS |
boolean | true |
Permit DNS when egress isolation is active. |
networkPolicy.egress.dnsPeers |
array | kube-system |
Peers allowed to serve DNS; override for custom DNS. |
networkPolicy.egress.externalDatabaseCIDR |
string | "" |
CIDR allowed for external PostgreSQL. |
networkPolicy.egress.extraEgress |
array | [] |
Complete additional egress rules for external integrations. |
Backup
The optional CronJob archives /var/cache/ntfy and uploads it to S3-compatible storage. It requires persistent storage
and does not dump PostgreSQL. Use an existing Secret containing access-key and secret-key in production.
| Parameter | Type | Default | Description |
|---|---|---|---|
backup.enabled |
boolean | false |
Enable scheduled data-volume backup. |
backup.schedule |
string | 0 3 * * * |
Cron schedule in UTC. |
backup.scratchSizeLimit |
string | 3Gi |
Bound temporary archive storage with headroom. |
backup.tmpSizeLimit |
string | 64Mi |
Bound backup container /tmp. |
backup.s3.endpoint |
string | "" |
S3-compatible endpoint. |
backup.s3.bucket |
string | "" |
Destination bucket. |
backup.s3.prefix |
string | ntfy |
Object-key prefix. |
backup.s3.existingSecret |
string | "" |
Existing S3 credential Secret. |
OpenID Connect and SMTP
OIDC client secrets and SMTP passwords are injected from Secrets through environment variables and never written to the ConfigMap. Restart the Deployment after rotating an existing Secret.
| Parameter | Type | Default | Description |
|---|---|---|---|
oidc.enabled |
boolean | false |
Enable OpenID Connect authentication. |
oidc.issuerUrl |
string | "" |
Identity-provider issuer URL. |
oidc.clientId |
string | "" |
Registered client ID. |
oidc.existingSecret |
string | "" |
Secret containing the client secret. |
oidc.existingSecretKey |
string | client-secret |
Key containing the client secret. |
smtp.enabled |
boolean | false |
Enable outbound email notifications. |
smtp.senderAddr |
string | "" |
SMTP server in host:port form. |
smtp.senderFrom |
string | "" |
Sender email address. |
smtp.existingSecret |
string | "" |
Secret containing the SMTP password. |
smtp.existingSecretPasswordKey |
string | smtp-password |
Key containing the SMTP password. |
Scheduling
| Parameter | Type | Default | Description |
|---|---|---|---|
nodeSelector |
object | {} |
Node selector for scheduling. |
tolerations |
array | [] |
Tolerations for scheduling. |
affinity |
object | {} |
Affinity rules. |
topologySpreadConstraints |
array | [] |
Topology spread constraints. |
priorityClassName |
string | "" |
PriorityClass for the pod. |
terminationGracePeriodSeconds |
integer | 30 |
Termination grace period. |
podLabels |
object | {} |
Extra labels for the pod. |
podAnnotations |
object | {} |
Extra annotations for the pod. |
Extra
| Parameter | Type | Default | Description |
|---|---|---|---|
extraVolumes |
array | [] |
Extra volumes to attach to the pod. |
extraVolumeMounts |
array | [] |
Extra volume mounts for the container. |
extraManifests |
array | [] |
Extra Kubernetes manifests deployed alongside the chart. |
Common Issues
If rate limiting is not working or logs show all requests from the same IP (the Ingress pod), verify that
ntfy.behindProxy: true is set (it is the default) and that your Ingress controller is forwarding the
X-Forwarded-For header.
After deploying, verify your setup with a quick curl command:
curl -d "Test notification from Kubernetes" https://ntfy.example.com/test-topicSubscribe to https://ntfy.example.com/test-topic in the ntfy app to receive it.