nixpush

One send call. Repoint the backend, not the callers.

A stateless NixOS module and CLI that binds named channels to pluggable providers. Every script, systemd unit, and module that wants to alert someone calls nixpush send --channel alerts "..." and never knows or cares what actually receives it. No daemon, no queue — one delivery attempt, reported through a 3-class exit code.

nixpush — three configured channels, one CLI
$ nixpush channels
alerts   -> ntfy
digest   -> ntfy
paging   -> ntfy

$ nixpush doctor
alerts:  ok
digest:  ok
paging:  ok

$ nixpush send --tag disk_full "root filesystem above 90% on example-host"
delivered (http 200)

$ nixpush send --channel digest --priority low --json "nightly backup completed"
{"status":"delivered","channel":"digest","provider":"ntfy","http_status":200}

How it works

1

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.

2

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.

3

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] MESSAGESend 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.