Pipedash
Resources

Push relay

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.

registers device token

pipeline failed + subscription id

iOS app

Push relay

pipedash-web

Apple APNs

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 explains the restriction. The source is the pipedash-push-relay crate.

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

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

Configuration

The relay reads these environment variables.

VariableRequiredDefaultMeaning
RELAY_APNS_KEYOne of the twoThe .p8 file contents (PEM). \n escape sequences work for single-line env files
RELAY_APNS_KEY_PATHOne of the twoPath to the .p8 file. Used when RELAY_APNS_KEY is empty
RELAY_APNS_KEY_IDYesThe 10-character Key ID
RELAY_APNS_TEAM_IDYesThe Apple Team ID
RELAY_ALLOWED_BUNDLE_IDSYesComma-separated bundle IDs the relay accepts, for example com.henrique.pipedash. A subscription uses its bundle ID as the apns-topic
RELAY_BIND_ADDRNo0.0.0.0:8080Listen address
RELAY_DATABASE_PATHNopipedash-push-relay.db (/data/relay.db in the image)SQLite file
RELAY_TRUSTED_PROXYNofalsetrue or 1 takes the client address for registration limits from the last X-Forwarded-For entry
RUST_LOGNoinfoLog filter

Run it with Docker

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

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

Run it:

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:

PIPEDASH_PUSH_RELAY_URL=https://relay.example.com

The config file sets it too:

[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:

RoutePurpose
GET /api/v1/push/subscriptionsLists the paired devices and reports whether the server has a relay URL
POST /api/v1/push/subscriptionsRegisters a paired device with its relay subscription ID, a device name and the platform ios
POST /api/v1/push/subscriptions/{id}/testSends 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 pathBodyAnswer
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 /healthz200

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 lists what the relay receives and keeps.