Self-Hosting ntfy: Push Notifications Under 50MB RAM

I wanted push notifications from my homelab — Jenkins failures, K8s alerts, custom scripts — without depending on Slack, Telegram bots, or any third-party service. ntfy is a single Go binary that idles at 12–18 MB RAM. Perfect for a 1GB Lightsail instance already running PocketBase, Woodpecker CI, and Nginx.

This is the exact setup I used to get ntfy.mrsubodh.xyz running in under 30 minutes.


What is ntfy?

ntfy (pronounced notify) is a pub-sub notification server. You POST a message to a topic URL, and any subscribed client (Android app, browser, curl) receives it instantly. No accounts, no SDKs — just HTTP.


Prerequisites

  • Debian/Ubuntu VPS (1GB RAM is fine)
  • Nginx already installed
  • Certbot installed
  • A subdomain pointed at your server — e.g. ntfy.yourdomain.xyz

Install

Download the latest binary directly from GitHub:

wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.tar.gz
tar zxvf ntfy_2.20.1_linux_amd64.tar.gz
sudo cp -a ntfy_2.20.1_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir -p /etc/ntfy
sudo cp ntfy_2.20.1_linux_amd64/{client,server}/*.yml /etc/ntfy

Verify:

ntfy serve --help

Note: use ntfy serve --help, not ntfy --version. The version is printed at the bottom of the help output.


Configuration

Create the cache directory and write a RAM-optimised config:

sudo mkdir -p /var/cache/ntfy
sudo nano /etc/ntfy/server.yml
base-url: "https://ntfy.yourdomain.xyz"
listen-http: "127.0.0.1:2586"

# SQLite file cache — avoids in-memory storage
cache-file: "/var/cache/ntfy/cache.db"
cache-duration: "12h"

# Auth
auth-file: "/var/cache/ntfy/auth.db"
auth-default-access: "deny-all"

# Disable attachments entirely to save RAM
attachment-cache-dir: ""
attachment-total-size-limit: "0"
attachment-file-size-limit: "0"
attachment-expiry-duration: "0"

behind-proxy: true
log-level: "warn"

The key RAM-saving decisions:

  • cache-file uses SQLite on disk instead of RAM
  • attachment-cache-dir: "" disables file attachments entirely
  • log-level: warn skips verbose INFO logs

systemd Service

sudo useradd -r -s /usr/sbin/nologin ntfy
sudo chown -R ntfy:ntfy /var/cache/ntfy
sudo nano /etc/systemd/system/ntfy.service
[Unit]
Description=ntfy notification server
After=network.target

[Service]
Type=simple
User=ntfy
Group=ntfy
ExecStart=/usr/local/bin/ntfy serve --config /etc/ntfy/server.yml
Restart=on-failure
RestartSec=5
MemoryMax=50M
MemorySwapMax=0
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/cache/ntfy

[Install]
WantedBy=multi-user.target

MemoryMax=50M is a hard kernel-enforced ceiling. If ntfy ever leaks past 50MB, the kernel kills and restarts it automatically.

sudo systemctl daemon-reload
sudo systemctl enable --now ntfy
sudo systemctl status ntfy

Nginx + TLS

The trick here is to write an HTTP-only config first, let Certbot issue the certificate, then Certbot auto-injects the SSL block. If you pre-write the ssl_certificate lines before the cert exists, nginx -t will fail and block Certbot.

sudo nano /etc/nginx/sites-available/ntfy
server {
    listen 80;
    server_name ntfy.yourdomain.xyz;

    location / {
        proxy_pass         http://127.0.0.1:2586;
        proxy_http_version 1.1;

        # WebSocket upgrade — required for ntfy Android app
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";

        proxy_buffering    off;
        proxy_read_timeout 3600s;
        proxy_set_header   Host $host;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }
}
sudo ln -s /etc/nginx/sites-available/ntfy /etc/nginx/sites-enabled/ntfy
sudo nginx -t && sudo systemctl reload nginx

# Issue cert — Certbot will auto-add HTTPS block
sudo certbot --nginx -d ntfy.yourdomain.xyz

Verify it's live:

curl https://ntfy.yourdomain.xyz/v1/health
# {"healthy":true}

WebSocket note

The ntfy Android app will prompt you to switch to WebSockets for better battery life. This requires the Upgrade + Connection "upgrade" headers shown above. Without them you'll get a WebSocketNotSupportedException in the app. With them, it just works.


User Setup

ntfy uses a simple user database. Add an admin user (admin gets read/write on all topics automatically):

sudo ntfy user add --role=admin yourname
# prompts for password

Send Your First Notification

From the server, your laptop, your K8s cluster — anywhere with internet:

curl -u yourname:yourpassword \
  -H "Title: Hello from ntfy" \
  -H "Priority: high" \
  -H "Tags: rocket" \
  -d "ntfy is live!" \
  https://ntfy.yourdomain.xyz/homelab

On your phone: install the ntfy Android app → tap + → topic homelab → server https://ntfy.yourdomain.xyz → enter credentials. The notification arrives in 1–2 seconds.

Priority levels: min · low · default · high · urgent


Reading Past Messages

# Pretty-print all cached messages
curl -s -u yourname:yourpassword \
  "https://ntfy.yourdomain.xyz/homelab/json?poll=1" | jq .

# Readable one-liner per message
curl -s -u yourname:yourpassword \
  "https://ntfy.yourdomain.xyz/homelab/json?poll=1" \
  | jq -r '"[\(.time | todate)] \(.title // "no title") — \(.message)"'

# Query SQLite directly
sudo sqlite3 /var/cache/ntfy/cache.db \
  "SELECT time, topic, message, title FROM messages ORDER BY time DESC LIMIT 10;"

You can also open https://ntfy.yourdomain.xyz in a browser for the built-in web UI.


Shell Helper

Drop this on any server you want to send alerts from:

cat << 'EOF' | sudo tee /usr/local/bin/notify
#!/bin/bash
# Usage: notify "message" "title (optional)" "priority (optional)"
curl -s -u yourname:yourpassword \
  -H "Title: ${2:-Server Alert}" \
  -H "Priority: ${3:-default}" \
  -d "$1" \
  https://ntfy.yourdomain.xyz/homelab
EOF
sudo chmod +x /usr/local/bin/notify
notify "Disk at 90% on /dev/sda1" "Storage Warning" high

Jenkins Integration

Add this to any Jenkinsfile to get build failure alerts on your phone:

pipeline {
    // ... your stages ...
    post {
        failure {
            sh """
                curl -s -u yourname:${NTFY_PASSWORD} \
                  -H "Title: Jenkins Failed" \
                  -H "Priority: high" \
                  -H "Tags: x" \
                  -d "Build #${BUILD_NUMBER} failed — ${JOB_NAME} (${BRANCH_NAME})" \
                  https://ntfy.yourdomain.xyz/homelab
            """
        }
        success {
            sh """
                curl -s -u yourname:${NTFY_PASSWORD} \
                  -H "Title: Jenkins Passed" \
                  -H "Priority: default" \
                  -H "Tags: white_check_mark" \
                  -d "Build #${BUILD_NUMBER} passed — ${JOB_NAME}" \
                  https://ntfy.yourdomain.xyz/homelab
            """
        }
    }
}

Store NTFY_PASSWORD as a Jenkins credential (Secret Text), inject it via withCredentials.


RAM Usage

systemctl status ntfy | grep Memory

On my instance after a day of use:

Memory: 14.2M (hard limit: 50M)

Well within the 50MB constraint with plenty of headroom for everything else running on the server.


Summary

ComponentChoiceReason
Binaryntfy v2.20.1Single Go binary, no runtime deps
CacheSQLite on diskAvoids in-memory message storage
Authdeny-all + user DBNo anonymous access
TransportWebSocket via NginxBetter battery on mobile
RAM capMemoryMax=50MKernel-enforced hard limit

+++