How it works
Bind a channel to a provider
Declare nixpush.channels.alerts.provider = "ntfy" once, with whatever settings that provider needs. Add a secretFile if the backend needs a token — read fresh per invocation, never written to the Nix store.
Call nixpush send from anywhere
A script, a systemd ExecStopPost=, another module's mkSendCommand helper — every caller names a channel, not a backend, and reads the exit code.
Repoint the channel later
Swap alerts.provider from ntfy to a self-hosted Gotify, or anything speaking the provider contract. Every caller's behavior changes with it — nothing that calls send has to change at all.
What you get
One call site, any backend
Modules and scripts depend on a channel name, never on ntfy, Gotify, or whatever comes next. The provider is a config value, not a rewrite.
A real first-party provider
The ntfy provider is a working curl+jq implementation — tags, actions, click URLs, attachments, priorities — not a stub standing in for a future one.
Three exit codes, wired for real use
0 delivered, 3 permanently rejected, anything else transient — directly usable in shell and directly wireable into ExecStartPost=, OnFailure=, or Restart=on-failure without glue code.
Secrets stay out of the store
channels.<name>.secretFile points at a runtime-readable file — sops-nix, agenix, anything — sourced into the provider's environment for that one send and never rendered into channels.json.
Per-channel defaults
defaultPriority and defaultTags live on the channel, once. A paging channel is urgent by default; a digest channel is low — callers that don't care never have to say so.
doctor, before anything fires
Validates every channel — provider resolves, settings pass the provider's own check-settings — without sending. Wire it into ExecStartPre= or CI to catch a broken channel before it matters.
A provider is one contract, any language
stdin JSON envelope in, an exit code out. A community provider for Gotify, Pushover, or Matrix needs zero dependency on nixpush internals — see CONTRIBUTING.md.
Honest about v1's scope
No retry/backoff, no delivery-status querying, no fan-out to multiple channels in one call — the caller's job, by design. A durable-spool nixpush-daemon is a natural v2, under the same provider contract.
Architecture
caller (module / script / systemd ExecStopPost=)
│
│ nixpush send --channel alerts "..."
▼
┌─────────────────────────┐ /etc/nixpush/channels.json
│ nixpush (core CLI) │◄──── channel → provider + settings
│ resolves the channel │ (rendered at build time, read-only)
└────────────┬─────────────┘
│ stdin: JSON envelope (message, title, priority, tags, ...)
│ env: provider settings + secretFile contents (if any)
▼
┌─────────────────────────┐
│ nixpush-provider-ntfy │ curl + jq → ntfy.sh or self-hosted
│ (or any provider that │ exit 0 delivered / 3 rejected / other transient
│ speaks the contract) │
└─────────────────────────┘
The core never speaks HTTP
Core resolves config and execs the provider — it has no notion of a request, a token format, or a wire protocol. Providers own the transport entirely, so adding a new backend never touches core.
One contract, either side of it
First-party or third-party, a provider is a small executable: stdin JSON in, one HTTP-ish call, an exit code in {0, 3, other} out. That's the whole surface nixpush depends on.
Install
1. Add the flake input
inputs.nixpush.url = "github:julian-corbet/nixpush-corbet-ch";
2. Import and declare a channel
imports = [ inputs.nixpush.nixosModules.default ];
nixpush = {
enable = true;
defaultChannel = "alerts";
ntfy.enable = true;
channels.alerts.provider = "ntfy";
channels.alerts.settings.topic = "REPLACE_ME_UNGUESSABLE_TOPIC";
};
Build from source
git clone https://github.com/julian-corbet/nixpush-corbet-ch
cd nixpush-corbet-ch
nix build .#nixpush .#nixpush-provider-ntfy
Needs only Nix with flakes enabled. nix flake check runs the same build + shellcheck CI does.
CLI reference
nixpush send [--channel NAME] MESSAGE | Send once. Also takes --title, --priority, --tag, --click, --attach, --action, --json. --channel is optional if defaultChannel is set. |
nixpush channels [--names-only] | List configured channel names and their bound provider. |
nixpush doctor [--channel NAME] | Dry-run config validation — no send. Exits nonzero if any checked channel is misconfigured. |