Push Notifications
    Auth & TLS

    Deploy ntfy on a VPS

    Self-host ntfy, a pub/sub push notification service driven by curl, on a RamNode VPS — TLS, authentication, Web Push, iOS support, and rate limiting behind a reverse proxy.

    ntfy is a pub/sub notification service you can drive with a single curl command. No SDK, no API key ceremony, no client library. You POST a string to a URL and a push notification lands on your phone.

    shell
    curl -d "Backup finished" https://ntfy.example.com/alerts

    That is the entire integration surface for the simple case. It is also why ntfy has quietly become the default notification layer for people who run their own infrastructure. Cron jobs, monitoring hooks, backup scripts, CI pipelines, and Home Assistant all speak curl.

    The public instance at ntfy.sh works fine, but it is a public message bus. Anyone who guesses your topic name reads your notifications. Self-hosting on a RamNode VPS gets you access control, unlimited message retention, and the confidence that your server alerts are not passing through someone else's infrastructure.

    This guide covers a production ntfy deployment: the binary install, TLS, authentication, Web Push for browsers, iOS support, rate limiting, and the reverse proxy settings that ntfy specifically needs.


    Before you start

    Pick the right RamNode plan

    ntfy is a single Go binary. It is genuinely tiny.

    DeploymentRecommended specs
    Personal, a handful of scripts and devices1 GB RAM, 1 vCPU, 20 GB SSD
    Small team, dozens of topics, attachments enabled2 GB RAM, 1 vCPU, 40 GB SSD
    Heavy use, thousands of subscribers, long retention4 GB RAM, 2 vCPU, 80 GB NVMe

    The smallest RamNode plan handles a personal deployment without breaking a sweat. Idle memory usage sits in the tens of megabytes.

    The one thing that scales is connections. Every subscribed client holds an open HTTP stream or WebSocket. A thousand idle subscribers costs you file descriptors and a little memory, not CPU.

    Attachments are the storage variable. If you plan to push screenshots or log files through ntfy, size the disk for it and set an expiry.

    DNS

    Create an A record:

    shell
    ntfy.example.com.  A  198.51.100.42

    And AAAA if you have IPv6 from RamNode:

    shell
    ntfy.example.com.  AAAA  2001:db8::1

    Verify:

    shell
    dig +short ntfy.example.com A

    Set reverse DNS in the RamNode control panel to match.

    A word about email

    ntfy can send notifications to email addresses and receive them via inbound SMTP. RamNode does not permit mail services on their VPS and blocks outbound port 25, so:

    • Email publishing (ntfy sending notifications out as email) works if you point it at an external SMTP relay on port 587. Postmark, Mailgun, SES all work.
    • Inbound email publishing (emailing a topic to publish to it) requires ntfy to listen on port 25. Do not attempt this on RamNode. Skip that feature.

    This guide configures outbound email through a relay and leaves the inbound listener off.


    Step 1: Base system preparation

    Start from a clean Ubuntu 24.04 LTS install via the RamNode control panel.

    shell
    apt update && apt upgrade -y
    apt install -y curl ca-certificates gnupg ufw fail2ban unattended-upgrades apache2-utils jq

    Hostname

    shell
    hostnamectl set-hostname ntfy.example.com
    echo "127.0.1.1 ntfy.example.com ntfy" >> /etc/hosts

    Admin user and SSH hardening

    shell
    adduser deploy
    usermod -aG sudo deploy
    rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

    In /etc/ssh/sshd_config:

    shell
    PermitRootLogin no
    PasswordAuthentication no
    PubkeyAuthentication yes
    shell
    systemctl reload ssh

    Firewall

    shell
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw enable

    Raise the file descriptor limit

    This is the one system tuning that matters for ntfy. Every subscriber holds an open connection. The default 1024 limit will bite you sooner than you expect.

    Add to /etc/security/limits.conf:

    shell
    *  soft  nofile  65535
    *  hard  nofile  65535

    And to /etc/sysctl.d/99-ntfy.conf:

    shell
    fs.file-max = 200000
    net.core.somaxconn = 4096
    net.ipv4.tcp_max_syn_backlog = 4096
    shell
    sysctl --system

    The systemd unit gets its own limit later.

    Automatic security updates

    shell
    dpkg-reconfigure --priority=low unattended-upgrades

    Step 2: Install ntfy

    ntfy ships an apt repository. Use it rather than Docker for this one. It is a single static binary with a systemd unit, and the packaged install gives you a cleaner upgrade path and a proper service user.

    shell
    curl -fsSL https://archive.heckel.io/apt/pubkey.txt \
      | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
    
    sudo install -m 0755 -d /etc/apt/keyrings
    
    echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] \
    https://archive.heckel.io/apt debian main" \
      | sudo tee /etc/apt/sources.list.d/archive.heckel.io.list
    
    sudo apt update
    sudo apt install -y ntfy

    Enable but do not start it yet. You want to configure first.

    shell
    sudo systemctl enable ntfy

    Verify the binary:

    shell
    ntfy --version

    Step 3: Generate Web Push keys

    If you want browser notifications to work when the ntfy tab is closed, you need VAPID keys for Web Push. Generate them now, before writing the config.

    shell
    sudo ntfy webpush keys

    That prints a public key, a private key, and a suggested config block. Copy all of it. You cannot regenerate these later without invalidating every existing browser subscription, so store them with your other secrets.


    Step 4: Configure ntfy

    Replace /etc/ntfy/server.yml. The stock file is entirely comments, which is useful reading but not useful as a running config.

    shell
    # ---------------------------------------------------------------
    # Core
    # ---------------------------------------------------------------
    
    # The public URL. This must match exactly, including scheme.
    # ntfy embeds it in links, in the web app config, and in
    # Web Push payloads. A mismatch produces subtle breakage.
    base-url: "https://ntfy.example.com"
    
    # Bind to localhost. Caddy on the host faces the internet.
    listen-http: "127.0.0.1:2586"
    
    # Tells ntfy to trust X-Forwarded-For for rate limiting.
    # Without this, every request appears to come from 127.0.0.1
    # and your per-visitor rate limits apply to the whole world
    # as a single bucket.
    behind-proxy: true
    
    # ---------------------------------------------------------------
    # Storage
    # ---------------------------------------------------------------
    
    # SQLite message cache. Without this, messages exist only in
    # memory and vanish on restart.
    cache-file: "/var/lib/ntfy/cache.db"
    
    # How long messages stay retrievable for clients that were
    # offline. "12h" is the default. Bump it if your devices are
    # frequently offline. Set to "0" to disable caching entirely.
    cache-duration: "48h"
    
    # Attachments.
    attachment-cache-dir: "/var/lib/ntfy/attachments"
    attachment-total-size-limit: "5G"
    attachment-file-size-limit: "15M"
    attachment-expiry-duration: "6h"
    
    # ---------------------------------------------------------------
    # Authentication
    # ---------------------------------------------------------------
    
    auth-file: "/var/lib/ntfy/user.db"
    
    # The critical setting. "read-write" makes your server a public
    # message bus that anyone can publish to and read from.
    # "deny-all" means nothing works until you explicitly grant
    # access. Start here.
    auth-default-access: "deny-all"
    
    # ---------------------------------------------------------------
    # Web Push (browser notifications when the tab is closed)
    # ---------------------------------------------------------------
    
    web-push-public-key: "YOUR_PUBLIC_KEY"
    web-push-private-key: "YOUR_PRIVATE_KEY"
    web-push-file: "/var/lib/ntfy/webpush.db"
    web-push-email-address: "you@example.com"
    
    # ---------------------------------------------------------------
    # iOS support
    # ---------------------------------------------------------------
    
    # The iOS app cannot receive push from an arbitrary server
    # because APNs requires an Apple developer certificate that
    # only the ntfy.sh project holds. This setting makes your
    # server poke ntfy.sh, which pokes APNs, which wakes the app,
    # which then fetches the actual message from YOUR server.
    #
    # Only the topic name is sent upstream. The message body is not.
    # Comment this out if you have no iOS clients.
    upstream-base-url: "https://ntfy.sh"
    
    # ---------------------------------------------------------------
    # Rate limiting
    # ---------------------------------------------------------------
    
    # Burst allowance per visitor, then refill at the replenish rate.
    visitor-request-limit-burst: 60
    visitor-request-limit-replenish: "5s"
    
    # Subscription connections per visitor.
    visitor-subscription-limit: 30
    
    # Attachment quota per visitor.
    visitor-attachment-total-size-limit: "100M"
    visitor-attachment-daily-bandwidth-limit: "500M"
    
    # Skip rate limiting for your own infrastructure. Useful when
    # a monitoring host bursts a lot of alerts at once.
    # visitor-request-limit-exempt-hosts: "10.0.0.0/8,monitoring.example.com"
    
    # ---------------------------------------------------------------
    # Outbound email via external relay
    # Port 25 is blocked on RamNode. Use a relay on 587.
    # Remove this block entirely if you do not need email delivery.
    # ---------------------------------------------------------------
    
    smtp-sender-addr: "smtp.postmarkapp.com:587"
    smtp-sender-user: "YOUR_SMTP_USERNAME"
    smtp-sender-pass: "YOUR_SMTP_PASSWORD"
    smtp-sender-from: "ntfy@example.com"
    
    # ---------------------------------------------------------------
    # Behavior
    # ---------------------------------------------------------------
    
    # Turn this on if you want ntfy to fetch and cache the web app
    # assets rather than serving from disk. Leave default.
    enable-signup: false
    enable-login: true
    enable-reservations: true
    
    # Message size cap. Default 4096 bytes. Raising it above
    # 4096 breaks compatibility with some clients.
    message-size-limit: "4k"
    
    log-level: "info"
    log-format: "text"

    Lock the file down. It holds your Web Push private key and SMTP credentials:

    shell
    sudo chmod 600 /etc/ntfy/server.yml
    sudo chown ntfy:ntfy /etc/ntfy/server.yml

    Create the data directory:

    shell
    sudo mkdir -p /var/lib/ntfy/attachments
    sudo chown -R ntfy:ntfy /var/lib/ntfy

    The settings that actually matter

    Three of these deserve emphasis because getting them wrong produces confusing behavior rather than clean failure.

    auth-default-access: "deny-all". The ntfy default is read-write. If you leave it, your server is an open relay for notifications. Anyone who finds it can publish to any topic and subscribe to any topic. Since topic names are the only namespace, and people pick predictable ones like alerts and backups, this is not theoretical.

    behind-proxy: true. Without it, ntfy sees every request as originating from your reverse proxy's IP. Rate limits then apply globally instead of per-visitor, so one aggressive client rate-limits everyone.

    base-url. Must be the exact public URL including https://. ntfy hands this to the web app and embeds it in Web Push subscriptions. Get it wrong and the web interface will load but fail to subscribe, with an error message that does not point at the cause.


    Step 5: Harden the systemd unit

    Use a drop-in rather than editing the packaged unit, so package upgrades do not clobber your changes.

    shell
    sudo systemctl edit ntfy
    shell
    [Service]
    # Every subscriber holds an open connection.
    LimitNOFILE=65535
    
    # Sandboxing. ntfy needs very little.
    ProtectSystem=strict
    ProtectHome=true
    PrivateTmp=true
    PrivateDevices=true
    NoNewPrivileges=true
    ProtectKernelTunables=true
    ProtectKernelModules=true
    ProtectControlGroups=true
    RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
    ReadWritePaths=/var/lib/ntfy
    
    # Restart on failure.
    Restart=on-failure
    RestartSec=5
    shell
    sudo systemctl daemon-reload
    sudo systemctl start ntfy
    sudo systemctl status ntfy

    Confirm it is listening and the limit took:

    shell
    sudo ss -tlnp | grep 2586
    grep 'Max open files' /proc/$(pgrep -f 'ntfy serve')/limits

    Step 6: Reverse proxy and TLS

    ntfy has specific proxy requirements that generic configs get wrong. Subscribers hold long-lived streaming connections. If your proxy buffers responses or times out idle connections, notifications arrive in batches or not at all.

    Install Caddy:

    shell
    sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
      | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
      | sudo tee /etc/apt/sources.list.d/caddy-stable.list
    sudo apt update
    sudo apt install -y caddy

    Replace /etc/caddy/Caddyfile:

    shell
    ntfy.example.com {
        reverse_proxy 127.0.0.1:2586 {
            # ntfy streams. Buffering breaks it.
            flush_interval -1
        }
    
        # Attachments.
        request_body {
            max_size 15MB
        }
    
        header {
            Strict-Transport-Security "max-age=31536000; includeSubDomains"
            X-Content-Type-Options "nosniff"
            -Server
        }
    
        log {
            output file /var/log/caddy/ntfy.log {
                roll_size 50mb
                roll_keep 5
            }
        }
    }

    flush_interval -1 disables response buffering. This is the single most important line in the file. Without it, Caddy holds streamed notifications in a buffer and your clients see nothing until the buffer fills or the connection closes.

    shell
    sudo caddy validate --config /etc/caddy/Caddyfile
    sudo systemctl reload caddy

    If you prefer nginx

    The equivalent settings, since these are easy to miss:

    shell
    server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;
        server_name ntfy.example.com;
    
        ssl_certificate     /etc/letsencrypt/live/ntfy.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/ntfy.example.com/privkey.pem;
    
        client_max_body_size 15M;
    
        location / {
            proxy_pass http://127.0.0.1:2586;
            proxy_http_version 1.1;
    
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
    
            # WebSocket upgrade for the web app.
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
    
            # Do not buffer. This is the one that gets missed.
            proxy_buffering off;
            proxy_request_buffering off;
    
            # Streaming connections are long-lived by design.
            # The default 60s kills them.
            proxy_connect_timeout 3m;
            proxy_send_timeout 3m;
            proxy_read_timeout 3m;
    
            proxy_redirect off;
        }
    }

    Verify:

    shell
    curl -sI https://ntfy.example.com/v1/health
    curl -s https://ntfy.example.com/v1/health | jq

    You want {"healthy":true}.


    Step 7: Set up users and access control

    You set auth-default-access: deny-all, so right now nothing works. That is correct. Now grant access deliberately.

    Create an admin

    shell
    sudo ntfy user add --role=admin admin

    Admins bypass all ACLs. Create one, use it for management, and do not use it in scripts.

    Create service accounts

    The pattern that works: one user per publishing system, one user per consuming device class.

    shell
    # A user for servers that publish alerts.
    sudo ntfy user add publisher
    
    # A user for your phone, which reads.
    sudo ntfy user add phone

    Grant topic access

    shell
    # publisher can write to anything under alerts.
    sudo ntfy access publisher 'alerts*' write-only
    
    # phone can read anything under alerts.
    sudo ntfy access phone 'alerts*' read-only
    
    # A specific topic both can use.
    sudo ntfy access publisher backups write-only
    sudo ntfy access phone backups read-only

    Wildcards work with * at the end. alerts* matches alerts, alerts-prod, alerts_db.

    Review what you have:

    shell
    sudo ntfy access

    Anonymous read on a public topic

    If you want one topic readable without credentials, for a status page or similar:

    shell
    sudo ntfy access everyone status read-only

    everyone is the reserved name for unauthenticated visitors.

    Tokens instead of passwords

    For scripts, tokens are better than passwords. They are revocable individually and do not grant web login.

    shell
    sudo ntfy token add --label "backup-server" publisher

    That prints a token starting with tk_. Use it as a bearer token:

    shell
    curl -H "Authorization: Bearer tk_yourtokenhere" \
         -d "Backup complete" \
         https://ntfy.example.com/backups

    List and revoke:

    shell
    sudo ntfy token list publisher
    sudo ntfy token remove publisher tk_yourtokenhere

    Step 8: Verify it works

    Publish

    shell
    curl -u publisher:yourpassword \
         -d "Hello from RamNode" \
         https://ntfy.example.com/alerts

    Subscribe from another shell

    shell
    curl -u phone:yourpassword -s \
         https://ntfy.example.com/alerts/json

    Leave that running, publish from the first shell, and the message should appear immediately. If it appears with a delay, or only when you kill the connection, your proxy is buffering. Go back to Step 6.

    Confirm the ACL actually denies

    shell
    curl -d "should fail" https://ntfy.example.com/alerts

    Expect a 403. If you get a 200, auth-default-access is not set to deny-all or ntfy did not reload the config.

    Web app

    Open https://ntfy.example.com in a browser, log in as phone, subscribe to alerts, and publish from curl. Grant notification permission when prompted. Close the tab and publish again. If Web Push is configured correctly, the notification still arrives.


    Step 9: Actually using it

    The point of ntfy is that integration is trivial. A few patterns worth having.

    Message with priority, tags, and a title

    shell
    curl -H "Authorization: Bearer tk_yourtoken" \
         -H "Title: Disk space critical" \
         -H "Priority: urgent" \
         -H "Tags: warning,skull" \
         -d "/ is at 94% on web01" \
         https://ntfy.example.com/alerts

    Priority urgent bypasses Do Not Disturb on Android. Use it sparingly or your users will mute the topic and you will have achieved nothing.

    JSON publishing

    Sometimes cleaner than header stacking:

    shell
    curl -H "Authorization: Bearer tk_yourtoken" \
         -H "Content-Type: application/json" \
         -d '{
           "topic": "alerts",
           "title": "Deploy finished",
           "message": "v2.4.1 is live",
           "priority": 3,
           "tags": ["rocket"],
           "click": "https://example.com/deploys/2841"
         }' \
         https://ntfy.example.com

    Wrap a command and report its outcome

    The single most useful pattern. Drop this in /usr/local/bin/notify-run:

    shell
    #!/usr/bin/env bash
    # Usage: notify-run <topic> <command...>
    
    TOPIC="$1"; shift
    NTFY_URL="https://ntfy.example.com"
    TOKEN="tk_yourtoken"
    HOST=$(hostname -s)
    
    START=$(date +%s)
    OUTPUT=$("$@" 2>&1)
    RC=$?
    DURATION=$(( $(date +%s) - START ))
    
    if [ $RC -eq 0 ]; then
        PRIORITY="default"
        TAGS="white_check_mark"
        TITLE="OK on $HOST (${DURATION}s)"
    else
        PRIORITY="high"
        TAGS="x"
        TITLE="FAILED rc=$RC on $HOST (${DURATION}s)"
    fi
    
    curl -s -H "Authorization: Bearer $TOKEN" \
         -H "Title: $TITLE" \
         -H "Priority: $PRIORITY" \
         -H "Tags: $TAGS" \
         -d "$* 
    
    ${OUTPUT:0:3000}" \
         "$NTFY_URL/$TOPIC" > /dev/null
    
    exit $RC
    shell
    chmod +x /usr/local/bin/notify-run

    Then in cron:

    shell
    30 2 * * * /usr/local/bin/notify-run backups /home/deploy/backup.sh

    You now get a notification with the command output whether it succeeds or fails, and the exit code propagates so cron still behaves normally.

    Systemd failure notifications

    Create /etc/systemd/system/notify-failure@.service:

    shell
    [Unit]
    Description=Notify ntfy on failure of %i
    
    [Service]
    Type=oneshot
    ExecStart=/usr/bin/curl -s \
      -H "Authorization: Bearer tk_yourtoken" \
      -H "Title: Service failed: %i" \
      -H "Priority: high" \
      -H "Tags: rotating_light" \
      -d "%i failed on %H" \
      https://ntfy.example.com/alerts

    Attach it to any unit:

    shell
    sudo systemctl edit some-service
    shell
    [Unit]
    OnFailure=notify-failure@%n.service

    Attachments

    shell
    curl -H "Authorization: Bearer tk_yourtoken" \
         -H "Filename: nginx-error.log" \
         -T /var/log/nginx/error.log \
         https://ntfy.example.com/alerts

    Respects attachment-file-size-limit and expires per attachment-expiry-duration.


    Step 10: Backups

    Everything lives in /var/lib/ntfy and /etc/ntfy. The auth database is the part you cannot reconstruct.

    Create /home/deploy/ntfy-backup.sh:

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    
    BACKUP_DIR="/home/deploy/backups"
    STAMP=$(date +%Y%m%d-%H%M%S)
    RETAIN_DAYS=30
    
    mkdir -p "$BACKUP_DIR"
    
    # SQLite databases. Use the sqlite3 .backup command rather than
    # copying the file, so you get a consistent snapshot of a live db.
    for db in cache user webpush; do
      if [ -f "/var/lib/ntfy/$db.db" ]; then
        sqlite3 "/var/lib/ntfy/$db.db" ".backup '$BACKUP_DIR/$db-$STAMP.db'"
      fi
    done
    
    # Config. Contains your Web Push private key.
    tar czf "$BACKUP_DIR/ntfy-config-$STAMP.tar.gz" /etc/ntfy
    
    find "$BACKUP_DIR" -name '*-20*' -mtime +$RETAIN_DAYS -delete
    shell
    sudo apt install -y sqlite3
    chmod +x /home/deploy/ntfy-backup.sh
    shell
    crontab -e
    shell
    0 4 * * * /home/deploy/ntfy-backup.sh >> /home/deploy/ntfy-backup.log 2>&1

    Copy the results off the VPS. The cache database is disposable, since it holds undelivered messages. The user database and the config are not.

    Attachments are excluded on purpose. They expire in hours by design. If you are backing up attachments you are using ntfy as a file store, which it is not.


    Step 11: Maintenance

    Upgrading

    shell
    sudo apt update && sudo apt upgrade ntfy
    sudo systemctl restart ntfy

    Read the release notes for anything touching the auth or Web Push schema. ntfy migrates SQLite automatically on start, so back up first.

    Watch the cache database

    shell
    ls -lh /var/lib/ntfy/cache.db

    It grows with cache-duration. At 48 hours with moderate traffic it stays trivially small. If it is large, either your retention is long or something is publishing far more than you think.

    Find out what:

    shell
    sqlite3 /var/lib/ntfy/cache.db \
      "SELECT topic, COUNT(*) FROM messages GROUP BY topic ORDER BY 2 DESC LIMIT 10;"

    Watch connections

    shell
    ss -tn state established '( sport = :2586 )' | wc -l

    If that number approaches your LimitNOFILE, raise it or look for a client reconnecting in a loop.

    Logs

    shell
    journalctl -u ntfy -f

    Rate limit rejections show up here. If you see them constantly from your own infrastructure, add those hosts to visitor-request-limit-exempt-hosts rather than raising the global limit.


    Troubleshooting

    Notifications arrive in batches or with long delays. Proxy buffering. flush_interval -1 in Caddy, proxy_buffering off in nginx. This is far and away the most common ntfy deployment problem.

    Connections drop every 60 seconds. nginx proxy_read_timeout default. Raise it to 3m as shown.

    Everything returns 403. Expected with deny-all until you grant access. Check ntfy access and confirm your ACL entries and that you are authenticating.

    Rate limits hitting everyone at once. behind-proxy: true is missing. ntfy is bucketing all traffic under your proxy's IP.

    Web app loads but subscribe fails. base-url mismatch. It must be the exact public URL with scheme. Check the browser console, which will usually show a request going somewhere unexpected.

    Web Push works, then stops after a config change. You regenerated the VAPID keys. Every existing browser subscription is now invalid and users must resubscribe. Do not regenerate keys.

    iOS app gets no notifications. Confirm upstream-base-url: "https://ntfy.sh" is set and your server can reach ntfy.sh outbound. Also confirm the topic is not so long or unusual that the iOS app cannot register it. Note that iOS notification delivery relies on the ntfy.sh relay, so if that service is down, iOS push stops even though your server is fine.

    Email notifications do not send. You are on port 587 with a relay, not port 25, right? Test the relay independently with swaks before blaming ntfy.

    Cannot bind port 2586. Something else has it, or the systemd sandboxing is too tight. ss -tlnp | grep 2586 and journalctl -u ntfy -n 50.


    Wrapping up

    You have a self-hosted ntfy on a RamNode VPS with TLS, deny-by-default access control, Web Push, token authentication, and backups.

    The value here compounds quickly. Once the endpoint exists, every cron job, deploy script, and monitoring hook you write gets notifications for the cost of one curl line. The notify-run wrapper in Step 9 is the highest-leverage thing in this guide: wrap anything you currently run blind and you will find out about failures the moment they happen instead of the next time you happen to look.

    Two things to get right and then forget about: keep auth-default-access on deny-all, and keep the proxy from buffering. Almost every ntfy problem anyone has is one of those two.