---
title: Push relay
description: The Pipedash push relay that forwards failed-pipeline alerts to APNs. Covers the APNs key, configuration, Docker, the relay API, limits and data retention.
---

The current iOS release disables remote push. The relay serves earlier paired builds and separate deployments. It forwards failed-pipeline alerts from a self-hosted `pipedash-web` server to Apple's APNs service. Servers do not receive the APNs signing key.

```mermaid
flowchart LR
    APP["iOS app"] -->|"registers device token"| RELAY["Push relay"]
    WEB["pipedash-web"] -->|"pipeline failed + subscription id"| RELAY
    RELAY --> APNS["Apple APNs"]
    APNS --> PHONE["iPhone"]
```

This page documents relay operation and retained subscriptions. Deploying a relay does not activate push in the current App Store build. The [iOS release page](/docs/resources/ios-release#disabled-build-watching) explains the restriction. The source is the [`pipedash-push-relay` crate](https://github.com/hcavarsan/pipedash/tree/main/crates/pipedash-push-relay).

## Requirements

- An Apple developer account.
- An App ID with the "Push Notifications" capability. The default bundle ID is `com.henrique.pipedash`.
- An APNs authentication key (`.p8`).

### Create the APNs key

1. Open [Certificates, Identifiers & Profiles > Keys](https://developer.apple.com/account/resources/authkeys/list) in the Apple developer portal.
2. Click "+", name the key and tick "Apple Push Notifications service (APNs)". Continue, then register the key.
3. Copy the Key ID (10 characters). Download `AuthKey_<KEYID>.p8`. Apple allows this download once.
4. Copy the Team ID from the [Membership details](https://developer.apple.com/account#MembershipDetailsCard).

One key works for every app of the team, in both the sandbox and production APNs environments.

## Configuration

The relay reads these environment variables.

| Variable | Required | Default | Meaning |
|---|---|---|---|
| `RELAY_APNS_KEY` | One of the two | | The `.p8` file contents (PEM). `\n` escape sequences work for single-line env files |
| `RELAY_APNS_KEY_PATH` | One of the two | | Path to the `.p8` file. Used when `RELAY_APNS_KEY` is empty |
| `RELAY_APNS_KEY_ID` | Yes | | The 10-character Key ID |
| `RELAY_APNS_TEAM_ID` | Yes | | The Apple Team ID |
| `RELAY_ALLOWED_BUNDLE_IDS` | Yes | | Comma-separated bundle IDs the relay accepts, for example `com.henrique.pipedash`. A subscription uses its bundle ID as the `apns-topic` |
| `RELAY_BIND_ADDR` | No | `0.0.0.0:8080` | Listen address |
| `RELAY_DATABASE_PATH` | No | `pipedash-push-relay.db` (`/data/relay.db` in the image) | SQLite file |
| `RELAY_TRUSTED_PROXY` | No | `false` | `true` or `1` takes the client address for registration limits from the last `X-Forwarded-For` entry |
| `RUST_LOG` | No | `info` | Log filter |

## Run it with Docker

The project publishes no relay image. Build it from the repository root:

```bash
docker build -f docker/push-relay.Dockerfile -t pipedash-push-relay .
```

Run it:

```bash
docker run -d --name pipedash-push-relay \
  -p 8080:8080 \
  -v pipedash-push-relay-data:/data \
  -v "$PWD/AuthKey_ABC123DEFG.p8:/run/secrets/apns.p8:ro" \
  -e RELAY_APNS_KEY_PATH=/run/secrets/apns.p8 \
  -e RELAY_APNS_KEY_ID=ABC123DEFG \
  -e RELAY_APNS_TEAM_ID=<TEAM_ID> \
  -e RELAY_ALLOWED_BUNDLE_IDS=com.henrique.pipedash \
  pipedash-push-relay
```

The container runs as user `65532`. That user must read the key file, and it must write to a bind-mounted `/data`. Named volumes work as is.

The relay speaks plain HTTP. A reverse proxy that terminates TLS must sit in front of it, and every other component uses the `https://` URL. `GET /healthz` returns `200 ok` for probes.

The proxy must not log requests for `/v1/` paths. The path of a subscription request contains its secret ID.

## Point pipedash-web at the relay

Every server that sends pushes needs the relay URL. An environment variable sets it:

```bash
PIPEDASH_PUSH_RELAY_URL=https://relay.example.com
```

The config file sets it too:

```toml
[push]
relay_url = "https://relay.example.com"
```

The environment variable wins. Without a relay URL the server refuses to register devices with `422` and sends nothing.

An iOS build that supports pairing takes the same URL from `PIPEDASH_PUSH_RELAY_URL` at build time. That is the relay the app registers its device with. A server and an app must point at the same relay.

The current iOS release keeps remote push and build watching disabled and omits their entitlements and background declarations. Release validation rejects reactivation, so changing `PipedashBuildWatchEnabled` alone is not a supported release configuration.

Pairing happens in the iOS app. For each server it pairs, the app creates a separate relay subscription and gives the server the subscription ID. Unpairing deletes that relay subscription.

The server exposes these routes, and all of them need the server API token:

| Route | Purpose |
|---|---|
| `GET /api/v1/push/subscriptions` | Lists the paired devices and reports whether the server has a relay URL |
| `POST /api/v1/push/subscriptions` | Registers a paired device with its relay subscription ID, a device name and the platform `ios` |
| `POST /api/v1/push/subscriptions/{id}/test` | Sends a test push |
| `DELETE /api/v1/push/subscriptions/{id}` | Forgets a device |

The `id` in these paths derives from the relay subscription ID. The server never returns the relay subscription ID itself.

A push goes out once for each run that newly moves into a failed state. A pipeline that was already failing when the server first saw it, a refresh and a restart send nothing again. When the relay answers `404` or `410` for a device, the server deletes that device.

## Relay API

| Method and path | Body | Answer |
|---|---|---|
| `POST /v1/subscriptions` | `{ device_token, environment: "production" or "sandbox", bundle_id }` | `201 { subscription_id }`, `400` invalid device token, `403` bundle ID not allowed, `429` rate limited |
| `PUT /v1/subscriptions/{subscription_id}` | `{ device_token }` | `204`, `404` unknown ID |
| `DELETE /v1/subscriptions/{subscription_id}` | | `204` |
| `POST /v1/notifications` | `{ subscription_id, title, body, thread_id?, pipeline_id?, provider_id?, run_number? }` | `202`, `400` invalid notification, `404` unknown ID, `410` APNs says the token is gone (the relay deletes it), `429` rate limited, `502` APNs refused |
| `GET /healthz` | | `200` |

The subscription ID is 32 random bytes (base64url) and the only credential. Whoever holds it can push to that device through the relay.

### Limits

- A request body holds 16 KiB at most.
- A title holds 1 to 512 bytes, a body 2048 bytes and a thread ID or pipeline ID 512 bytes.
- A device token is a hex string of 64 to 200 characters.
- Each subscription accepts 30 notifications per minute.
- Each client address makes 20 registrations per hour and gets `429` after that.
- Each device token keeps 10 subscriptions. The 11th registration of a token deletes the oldest subscription of that token.

A client address is the peer address of the connection, with IPv6 grouped by /64. Behind a reverse proxy, `RELAY_TRUSTED_PROXY=true` makes the relay use the last `X-Forwarded-For` entry instead. Enable it only when every request passes through the proxy. Otherwise clients can spoof their address.

### APNs delivery

The relay talks to `api.push.apple.com` (production) or `api.sandbox.push.apple.com` (sandbox) over HTTP/2. It sends `apns-push-type: alert` and the bundle ID as `apns-topic`. It signs an ES256 token with the key, reuses it and renews it after 50 minutes. It deletes a subscription when APNs answers `410`, `BadDeviceToken` or `Unregistered`.

## Data

For each subscription, the relay stores these values in its SQLite file:

- The SHA-256 hash of the subscription ID, never the ID itself
- The APNs device token
- The APNs environment (`production` or `sandbox`)
- The bundle ID
- The creation time
- The time of the last activity (creation, token rotation or a successful push)

The relay keeps the rate-limit counters and the current APNs token only in memory. Counters use the subscription hash for pushes and the client address for registrations. They never reach the disk. A sweep every 5 minutes drops each counter after its window ends. The window is 1 minute for pushes and 1 hour for registrations.

The relay never stores or logs notification titles, bodies and thread IDs, subscription IDs, device tokens, pipeline names or client IP addresses. It logs start-up and the HTTP status of a push that APNs refused. It also logs the number of inactive subscriptions it deleted, and errors from signing, APNs requests and the database.

The relay deletes a subscription in these cases:

- The app unpairs it.
- APNs reports its token as invalid. This happens after the user uninstalls the app and the next push fails.
- It stays inactive for 90 days. The app sends a token rotation on every start, and that counts as activity.

The relay checks for inactive subscriptions every hour. Deleting the SQLite file clears every record.

Each `pipedash-web` server stores one row per paired device in its own database. The row holds the relay subscription ID, the device name the app chose and the platform. It also holds the time of adding and the time of the last successful push. These rows are permanent configuration. They move with the rest of the configuration when storage migrates to another data directory or database backend. A factory reset deletes them. The [privacy policy](/privacy) lists what the relay receives and keeps.
