Self-hosted FCM relay for the BirdWatch Android app (AGPLv3, end-to-end encrypted)
Find a file
2026-05-03 22:47:38 -05:00
internal Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
.dockerignore Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
.env.example Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
.gitignore Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
docker-compose.example.yml Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
Dockerfile Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
go.mod Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
LICENSE Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
main.go Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
README.md Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00
setup.sh Initial public release of birdwatch-relay 2026-05-03 22:47:38 -05:00

BirdWatch Relay

Self-hosted bridge between Frigate NVR and the BirdWatch Android app. Forwards Frigate event webhooks to your phone via Firebase Cloud Messaging, with payloads end-to-end encrypted (NaCl sealed box, X25519 + XSalsa20-Poly1305) so Google sees ciphertext only.

You run this on the same Linux machine as Frigate (your NVR). Source is AGPLv3.

Quick install

The interactive installer asks the questions, generates the secret, writes the config, builds the image, starts the container, and prints the exact strings to paste into Frigate and the BirdWatch app.

git clone https://github.com/cyrilinait/birdwatch-relay.git
cd birdwatch-relay
./setup.sh

The script needs Docker, Docker Compose v2, openssl, and curl — all present on a typical Frigate NVR.

You'll be asked for two things during setup:

  1. The path to your Firebase service-account JSON file (instructions below if you don't have one).
  2. The hostname or LAN IP your phone will reach the relay at.

That's it. The script handles the rest, then prints the URL to paste into the BirdWatch app and the webhook URL + Authorization header to add to Frigate's config.yml.

Getting a Firebase service-account key (one time, ~5 minutes)

The relay sends pushes via Google's Firebase Cloud Messaging. You need a free Firebase project to do that. Google sees only ciphertext and a device token — they cannot read the events.

  1. Open https://console.firebase.google.com
  2. Click Add project. Name it whatever you want (e.g. birdwatch-home). Skip Analytics if asked.
  3. Once the project loads, click the gear icon (top-left) → Project settings.
  4. Open the Service accounts tab.
  5. Click Generate new private key. A .json file downloads — this is the file the installer will ask for.
  6. Move it to your NVR (e.g. with scp).

Anyone with this file can send pushes to your phones, so keep it private (the installer copies it to ./firebase-key.json with mode 600 and the .gitignore excludes it).

Running alongside an existing Frigate stack

If your Frigate is already deployed via docker-compose.yml, you can merge the relay into the same stack instead of running it standalone. After running ./setup.sh once to generate .env, edit your existing Frigate docker-compose.yml and add this service block:

  birdwatch-relay:
    build: /path/to/birdwatch-relay
    container_name: birdwatch-relay
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - /path/to/birdwatch-relay/data:/data
      - /path/to/birdwatch-relay/firebase-key.json:/secrets/firebase-key.json:ro
    environment:
      RELAY_FIREBASE_KEY_PATH: /secrets/firebase-key.json
      RELAY_WEBHOOK_SECRET: ${RELAY_WEBHOOK_SECRET}
      RELAY_DB_PATH: /data/relay.db
      RELAY_PORT: "8080"

Replace /path/to/birdwatch-relay with the absolute path of this clone. Add RELAY_WEBHOOK_SECRET=... to your stack's .env (copy the value from birdwatch-relay/.env). Then docker compose up -d.

The advantage: one stack, one place to start/stop, the relay starts and stops with Frigate.

What ends up where (the data picture)

Where What lives there
Your NVR Container, SQLite (./data/relay.db), Firebase key (./firebase-key.json), webhook secret (./.env). All on your hardware.
Your phone The X25519 private key (Android Keystore-protected, never leaves the device).
Google FCM Your phone's FCM token + the encrypted blob + delivery timestamps. No event content.
Anywhere else (cyrilina.it, etc.) Nothing. There is no third-party endpoint in the data path.

Endpoints

  • POST /v1/devices — phones register here. Body: {"device_id":"...", "fcm_token":"...", "public_key":"<base64-32-bytes-X25519>"}. Returns 204.
  • POST /v1/webhook — Frigate sends events here. Authorization: Bearer <secret>. Body: any JSON ≤ 64 KB. Returns 202.
  • GET /v1/health — liveness probe. Returns ok / 200.

Manual install (if you don't want to use setup.sh)

# 1. Generate a webhook secret.
openssl rand -hex 32 > /tmp/secret.txt

# 2. Place your Firebase key.
cp /path/to/your-firebase-key.json ./firebase-key.json
chmod 600 ./firebase-key.json

# 3. Write .env.
echo "RELAY_WEBHOOK_SECRET=$(cat /tmp/secret.txt)" > .env
chmod 600 .env

# 4. Copy the example compose file and adjust ports if you want.
cp docker-compose.example.yml docker-compose.yml

# 5. Build and start.
docker compose up -d --build

# 6. Verify.
curl http://localhost:8080/v1/health    # should print: ok

Webhook URL for Frigate: http://<this-machine>:8080/v1/webhook with header Authorization: Bearer <the secret from .env>.

Troubleshooting

Container won't start. docker compose logs relay. Most common: missing RELAY_FIREBASE_KEY_PATH or RELAY_WEBHOOK_SECRET, or the secret is shorter than 32 characters.

Webhook returns 401. The Authorization: Bearer … value Frigate is sending doesn't match RELAY_WEBHOOK_SECRET in .env. Re-paste, restart Frigate.

Relay logs webhook fanout sent=N but no notification on phone. The push reached FCM. Check the BirdWatch app has notifications enabled at the OS level, and that battery-saver mode is ON inside BirdWatch.

Notification arrives but says "Encrypted message" / can't decrypt. Phone's private key doesn't match the public key registered with the relay. Toggle BirdWatch's battery-saver switch off then on to re-register.

firebase init failed: invalid_grant. The Firebase service-account JSON is corrupted or wrong file. Re-download from Firebase Console → Project Settings → Service accounts.

Re-run setup. ./setup.sh is idempotent. It backs up the previous .env and docker-compose.yml to *.bak before writing new ones.

Security model

  • The relay holds your Firebase admin key. If it leaks, an attacker can send pushes (but not read past notifications — those are encrypted to your phone). .gitignore excludes the file. Keep it off public storage.
  • The webhook secret is the only thing stopping random people on your network from sending fake events. It's 64 hex characters by default (256 bits). Compared with constant-time comparison server-side. Rotate if exposed.
  • Distroless static container, nonroot user, no shell, no package manager. Minimal attack surface inside the container.
  • TLS — the relay does not terminate TLS itself. For LAN-only deployments that's fine. If you expose the relay to the public internet (e.g. so phones can receive notifications away from home Wi-Fi), put a TLS reverse proxy in front (Caddy, Traefik, nginx) and use https:// in the BirdWatch app and Frigate.

License

AGPL-3.0-or-later. See LICENSE. Source must accompany any deployment that serves users over a network — that's the AGPL difference.

Contributing

This relay is a small dedicated service. Patches welcome via PR. Before publishing changes, run the security and optimize agents per feedback_security_optimize_before_ship.md in the BirdWatch project memory.