Files
nixos/docs/superpowers/specs/2026-07-05-ntfy-module-design.md
2026-07-05 12:28:25 +02:00

5.5 KiB

ntfy self-hosted notification module — design

Date: 2026-07-05 Target machine: jupiter

Purpose

Add a NixOS profile module that runs and configures a self-hosted ntfy notification server on jupiter, with authentication required, a homepage dashboard tile, and out-of-the-box plumbing so Home Assistant automations can publish push notifications.

Scope decisions (from brainstorming)

  • Service: ntfy (self-hosted server), not Apprise/Gotify/HA-only.
  • Access control: authentication required (auth-default-access = deny-all).
  • Home Assistant: the module wires an HA rest_command so automations can send notifications out of the box.
  • Credentials: fully manual. The module runs the server and wires the HA plumbing, but does not seed users or store any secret. The operator provisions ntfy users/passwords by hand, and the only secret lives in Home Assistant's secrets.yaml — never in the world-readable Nix store.

Module structure

New module at modules/environments/ntfy/default.nix following the repo's standard profile pattern (my.profiles.<name> with options + config = lib.mkIf cfg.enable { ... }).

Registration:

  1. Add ./ntfy to the imports list in modules/environments/default.nix.
  2. Enable via my.profiles.ntfy.enable = true; in machines/jupiter/environments.nix.

Options

options.my.profiles.ntfy = with lib; {
  enable = mkEnableOption "ntfy notification server";

  port = mkOption {
    type = types.port;
    default = 2586;
    description = "HTTP port ntfy listens on.";
  };

  topic = mkOption {
    type = types.str;
    default = "ha";
    description = "Topic Home Assistant publishes notifications to.";
  };

  haIntegration.enable = mkOption {
    type = types.bool;
    default = true;
    description = "Wire a Home Assistant rest_command that publishes to ntfy.";
  };
};

Server configuration

config = lib.mkIf cfg.enable {
  services.ntfy-sh = {
    enable = true;
    settings = {
      base-url            = "http://${hostName}:${toString cfg.port}";
      listen-http         = ":${toString cfg.port}";
      auth-file           = "/var/lib/ntfy-sh/user.db";
      auth-default-access = "deny-all";
    };
  };

  networking.firewall.allowedTCPPorts = [ cfg.port ];

  my.homepage.services = [
    {
      group = "Services";
      name = "ntfy";
      description = "Push notifications";
      href = "http://${hostName}:${toString cfg.port}";
      icon = "ntfy.svg";
    }
  ];
};

hostName is bound from config.networking.hostName, matching the pattern in the home-assistant and paperless modules.

Home Assistant wiring

Applied only when ntfy, the HA integration flag, and the home-assistant profile are all enabled:

lib.mkIf (cfg.enable && cfg.haIntegration.enable
          && config.my.profiles.home-assistant.enable) {
  services.home-assistant.config.rest_command.ntfy_send = {
    url = "http://${hostName}:${toString cfg.port}/${cfg.topic}";
    method = "POST";
    payload = "{{ message }}";
    content_type = "text/plain";
    username = "homeassistant";
    password = "!secret ntfy_password";
    headers = {
      Title = "{{ title | default('Home Assistant') }}";
      Priority = "{{ priority | default('default') }}";
    };
  };
};

Notes:

  • services.home-assistant.config is an attrset that NixOS merges, so adding rest_command.ntfy_send from this module composes with the config the home-assistant module already defines.
  • password = "!secret ntfy_password" is a whole-value !secret reference. The upstream home-assistant module unquotes such values when rendering the config (the same mechanism the repo already relies on for !include), so the secret resolves from /var/lib/hass/secrets.yaml at runtime and never enters the Nix store. See reference_nixos_ha_yaml_includes.
  • rest_command is chosen over the notify REST platform because ntfy's per-topic URL path plus header-based metadata map cleanly onto rest_command, whereas the notify platform's fixed JSON payload fights ntfy's format.

Manual provisioning (operator runbook)

Because credentials are fully manual, after the first nixos-rebuild switch run these once on jupiter:

# Dedicated publisher for Home Assistant, scoped to the ha topic
ntfy user add homeassistant            # prompts for a password
ntfy access homeassistant ha write-only

# Admin account for the app / web UI
ntfy user add --role=admin admin

Then add the homeassistant password to Home Assistant's secrets:

# /var/lib/hass/secrets.yaml
ntfy_password: <the homeassistant user's password>

Restart Home Assistant. Automations can then publish with:

service: rest_command.ntfy_send
data:
  message: "Garage door left open"
  title: "Alert"
  priority: high

Subscribers (phone/desktop ntfy app) log in as admin (or another user granted read access) to receive messages.

Testing / verification

  • Build check: nix build '.#nixosConfigurations.jupiter.config.system.build.toplevel' must succeed with the module enabled.
  • nixfmt-rfc-style clean on the new file.
  • Post-deploy manual verification (documented, not automated): create the users above, publish a test message from HA, confirm it reaches a subscribed client.

Out of scope

  • No automated user/token seeding (explicitly deferred to manual provisioning).
  • No sops-nix setup (repo has dangling config.sops.secrets references, but wiring up sops is a separate change and not required here).
  • No reverse-proxy / TLS termination (behind-proxy left default; LAN-only via firewall).