try.directtry.direct

apprise → ntfy: A Real Webhook Pipe with Stacker PIPE

What You'll Build

A working webhook pipeline between two tiny self-hosted apps — apprise (the source) and ntfy (the target) — using nothing but stacker pipe. You fire a payload, the pipe delivers it to ntfy's publish webhook, and you watch it land. Every command and every response below is from a real deployment.

  • Co-locate two apps on one server so the agent can wire them together
  • Create a pipe with manual endpoints — apprise has no auto-discoverable API
  • Activate, trigger with a JSON payload, and confirm delivered: true
  • Verify the delivery independently with curl

Quick Answer

stacker pipe create app ntfy \
  --source-endpoint "GET /status" \
  --target-endpoint "POST /pipetest" \
  --source-fields message \
  --target-fields message \
  --name apprise-to-ntfy

stacker pipe activate <pipe-id> --trigger manual
stacker pipe trigger  <pipe-id> --data '{"message":"hello from the pipe"}'
The payload lands on ntfy's pipetest topic. No discovery, no HTTPS, no scripts.

Why apprise → ntfy Needs Manual Endpoints

Stacker's pipe discovery scans running containers for OpenAPI specs, HTML forms, and REST APIs at standard paths. apprise exposes none of those, so it can't be a pipe source through discovery. That's exactly what manual endpoints are for: you name the source and target endpoints yourself, and discovery is skipped entirely. Any app — or any HTTP URL — becomes pipeable.

Step 1: Deploy Both Apps on One Server

A pipe connects apps on the same server (the agent resolves each container by its my.stacker.service label). The simplest way is one project with two services — apprise as the main app, ntfy as a service:

name: pipe-webhook-demo
project:
  identity: pipe-webhook-demo

# SOURCE — apprise notification gateway
app:
  type: custom
  image: caronc/apprise:latest
  ports: ["8000:8000"]
  environment:
    APPRISE_STATEFUL_MODE: enabled

services:
  # TARGET — ntfy webhook sink (POST /{topic} publishes)
  - name: ntfy
    image: binwiederhier/ntfy:latest
    command: serve
    ports: ["8080:80"]
    environment:
      NTFY_AUTH_DEFAULT_ACCESS: "read-write"

proxy:
  type: none

deploy:
  target: server
  cloud:
    provider: hetzner
    region: fsn1
    size: cpx22
    public_ports: ["8000", "8080"]

volumes:
  apprise_config: {}
  ntfy_data: {}
stacker deploy --target cloud --force-new

After it comes up, both containers are running side by side:

project-app-1    caronc/apprise:latest
project-ntfy-1   binwiederhier/ntfy:latest

Step 2: Create the Pipe with Manual Endpoints

apprise is the source (app), ntfy is the target. The target endpoint is ntfy's publish webhook, POST /pipetest. We map a single message field through:

stacker pipe create app ntfy \
  --source-endpoint "GET /status" \
  --target-endpoint "POST /pipetest" \
  --source-fields message \
  --target-fields message \
  --name apprise-to-ntfy --json

Discovery is skipped and the pipe is created immediately:

  Using source    GET /status  — manual endpoint
  Using target    POST /pipetest — manual endpoint
  Manual endpoints — building field mapping from provided fields.

  "id": "eeba4c8e-9c1e-4db4-bf79-e82f4c3fb8a5",
  "field_mapping": { "message": "$.message" },
  "source_container": "app",
  "target_container": "ntfy",
  "status": "draft"

Step 3: Activate and Fire It

stacker pipe activate eeba4c8e-… --trigger manual
#   ✓ completed

stacker pipe trigger  eeba4c8e-… --data '{"message":"hello from the apprise->ntfy pipe"}'
#   ✓ completed   delivered: true   trigger_count: 1

The execution log confirms it:

stacker pipe history eeba4c8e-…
# EXECUTION ID   TRIGGER   STATUS      DURATION
# 78bbf89e-…     manual    ✓ success   0ms

stacker pipe list
# eeba4c8e-…   app → ntfy   ● active   triggers=1   errors=0

Step 4: Verify the Delivery with curl

A pipe just POSTs the mapped payload to the target endpoint — here, ntfy's POST /{topic}. You can confirm it landed by reading the topic. ntfy serves published messages as JSON:

curl -s "http://<server-ip>:8080/pipetest/json?poll=1"
{"event":"message","topic":"pipetest",
 "message":"{\"message\":\"hello from the apprise->ntfy pipe\"}"}

The payload the pipe delivered is right there on the topic. You can reproduce the pipe's raw HTTP call by hand, too:

# exactly what the pipe does under the hood — POST to ntfy's webhook
curl -s -d '{"message":"manual curl check"}' http://<server-ip>:8080/pipetest

And sanity-check both ends of the pipe independently:

curl -sI http://<server-ip>:8000/            # apprise UI → HTTP 200
curl -s  http://<server-ip>:8080/v1/health   # ntfy → {"healthy":true}

Why No Proxy or HTTPS?

The pipe delivers container-to-container over the internal Docker networkhttp://project-ntfy-1:80/pipetest — never through the public edge. ntfy's "requires HTTPS" note only applies to external and mobile-app usage; internal POST /{topic} publishing works over plain HTTP. So there's nothing to terminate and no certificate to manage for the pipe itself. (If you want ntfy reachable over HTTPS for real external clients, add proxy: type: caddy — but that's orthogonal to the pipe.)

How Field Mapping Works Here

With manual endpoints, fields map source → target by name first, then position, then identity. We passed --source-fields message and --target-fields message, so the mapping is simply message ← $.message. Leave --target-fields off and you get a pass-through of the whole payload.

Going Further (Stacker 0.3.2)

Two upgrades make this pipe production-ready and reproducible.

Add a retry policy and a failure handler

Attach retries and lifecycle handlers right at creation — they're stored in the pipe's config so the runtime honors them:

stacker pipe create app ntfy \
  --source-endpoint "GET /status" --target-endpoint "POST /pipetest" \
  --source-fields message --target-fields message \
  --name apprise-to-ntfy \
  --retry 5 --retry-backoff-ms 500 \
  --on-failure oncall-notify        # run another pipe if delivery keeps failing

Declare it in stacker.yml instead (Infrastructure-as-Code)

Rather than a long imperative command, commit the pipe to your config and reconcile it:

pipes:
  - name: apprise-to-ntfy
    source: app
    target: ntfy
    source_endpoint: "GET /status"
    target_endpoint: "POST /pipetest"
    source_fields: [message]
    target_fields: [message]
    retry: 5
    on_failure: oncall-notify
stacker pipe diff     # preview: create / update / unchanged / orphan
stacker pipe apply    # create declared-but-missing pipes (idempotent)
# stacker pipe apply --prune   # also delete pipes not in stacker.yml

Now the pipe is version-controlled, reviewable, and reproducible across environments.

Frequently Asked Questions

Do both apps have to be running to create the pipe?

The manual-endpoint create is a template — it doesn't probe the apps. But delivery does need the target reachable on the server, so both containers should be up when you trigger.

Why does the ntfy message look like raw JSON?

ntfy treats a topic POST body as the message text, so it publishes the JSON payload verbatim. That's expected — the pipe delivered exactly what you sent.

Can I trigger it automatically instead of manually?

Yes — stacker pipe activate <id> --trigger webhook fires on incoming data, and --trigger poll --poll-interval <seconds> checks on a schedule. We used manual here for a deterministic, one-shot test.

Key Takeaways

  • Manual endpoints (--source-endpoint / --target-endpoint) pipe any app, even without a discoverable API
  • A pipe is just a mapped POST to the target endpoint — verify it with a plain curl against the topic
  • Delivery is internal (container-to-container), so no proxy or HTTPS is needed for the pipe
  • Co-locate source and target on one server; the agent resolves them by their my.stacker.service labels
  • --name plus explicit endpoints make the whole flow non-interactive and scriptable

Try It Yourself

Deploy this stack or browse pre-built templates in the marketplace. Your first deployment is always free.