Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Remote Access & Security

By default BirdNet-Behavior binds to 0.0.0.0:8502 — reachable from any device on your LAN. Viewing the dashboard is open (no login); signing in is required for the /admin* panel, the Settings management tabs and every action that changes something — with a password a fresh install sets for you. For a station reachable from beyond the LAN, private mode puts the dashboard itself behind that sign-in. This page covers reaching the station from your network and from elsewhere, safely.

On your local network

Out of the box the dashboard is already reachable from other devices. Browse to http://<pi-ip>:8502 from any device on the network — find the Pi’s address with hostname -I.

The 0.0.0.0 default exposes the dashboard to everyone on your network. That’s usually fine at home: the read-only views are open, and the /admin panel is protected by the auto-generated password (see below). On an untrusted network (a shared flat, public Wi-Fi), confirm CADDY_PWD is set and consider restricting the bind.

Restrict to this device only

To make the dashboard reachable only from the machine itself, bind to loopback:

BIRDNET_LISTEN=127.0.0.1:8502

(Or answer “restrict to this device” in the interactive installer.) Then reach it remotely with an SSH tunnel (ssh -L 8502:localhost:8502 pi@host) or a VPN — see the private-tunnel section below.

Built-in HTTPS

The server terminates TLS itself. It is off by default — a station on a trusted LAN behind a reverse proxy has no need of it, and turning it on for everyone would break every existing bookmark.

The one-command version

birdnet-behavior --tls-mode self-signed

HTTPS comes up on 8503 (plain HTTP keeps answering on 8502) and the log prints one path:

self-signed HTTPS: import this CA file once to stop the browser warning
  ca=/home/pi/BirdNet-Behavior/tls/local-ca.crt

(The folder is tls beside the database, or --tls-dir: ~/BirdNet-Behavior/tls for the account the bare-metal service runs as — pi here — and /data/BirdNet-Behavior/tls in Docker.)

That file is a small certificate authority the station generated for itself, and the server certificate is signed by it. Import the CA once — into your browser, your OS trust store, or curl --cacert — and the warning stops. It keeps working when the server certificate rotates (the CA is good for ten years; the certificate it signs for 397 days and is replaced a month before it expires — checked once a day while the station runs, and swapped in without a restart), so you do this once per station, not once per year. On a Pi whose clock has not been set yet (no battery-backed clock, NTP not yet reached) no certificate is minted: the station serves plain HTTP on the same address, says so in the journal and in --doctor, and restarts itself to mint one as soon as the clock is set.

The CA is limited to this station’s own names: localhost, .local names, the loopback, private and link-local address ranges, and any extra name you gave with --tls-hostnames. Anything that can read the CA’s private key (it is local-ca.key, mode 0600, owned by the service user) can therefore not mint a certificate for a public website that your devices would trust. A phone joining the network, a new DHCP lease or a new .local name needs nothing from you. A new public name added to --tls-hostnames does: the station makes a new CA for it, logs a warning saying so, and you import the new local-ca.crt on the devices that trusted the old one. The same happens once, when you upgrade from a release before CAs were limited this way.

To serve only HTTPS on the usual port, point both at the same address:

birdnet-behavior --listen 0.0.0.0:8502 --tls-mode self-signed --tls-listen 0.0.0.0:8502

Or keep 8502 open and have it redirect:

birdnet-behavior --tls-mode self-signed --tls-redirect

With a real certificate

If you already have one — from your own ACME client, an internal CA, or a purchase:

birdnet-behavior --tls-mode manual \
  --tls-cert /etc/letsencrypt/live/birds.example.com/fullchain.pem \
  --tls-key  /etc/letsencrypt/live/birds.example.com/privkey.pem

Both files are re-checked once a minute and re-read when they change on disk, so a certbot renew in the small hours is serving within a minute, from the next handshake — no restart, no cron hook.

What the browser shows

Settings → Access → Secure connection says which of the three modes the station is in. In self-signed mode it shows when the certificate runs to (read from the certificate’s own record, not the configured length) and offers the station’s local CA certificate for download, so you can add it to a phone or computer once instead of clicking through the browser warning every time. The CA’s private key is never served. The page has no upload or generate button, on purpose: the page used to fix a plain-HTTP station is itself plain HTTP, so a form for a private key would send the key across the network in the clear; the certificate and key are files on the station, and self-signed already generates and renews its own.

Checking it before you rely on it

--doctor does exactly what startup does, early enough to be useful:

$ birdnet-behavior --doctor
[ PASS ] HTTPS — self-signed on 0.0.0.0:8503, covering localhost, pi, pi.local, 127.0.0.1 (valid 397 days)
[ PASS ] HTTPS — import /home/pi/BirdNet-Behavior/tls/local-ca.crt to stop the browser warning

A mistyped path or a key that does not match its certificate is a [ FAIL ] here rather than a service that restart-loops after you have gone inside.

Every setting has a BIRDNET_TLS_* environment variable and a config-file key; see .env.example.

Do NOT expose it directly to the internet

Built-in HTTPS encrypts the traffic; it does not make the station safe to port-forward. There is no rate-limited login lockout, no WAF, and the self-signed mode carries no publicly-trusted name. Never port-forward 8502/8503 straight to the internet. Use a VPN or a private tunnel, or put it behind a reverse proxy that terminates HTTPS with a real certificate and adds authentication.

Reverse proxy with HTTPS

Caddy (simplest — automatic HTTPS)

Caddy fetches and renews a Let’s Encrypt certificate for you. A whole Caddyfile can be:

birds.example.com {
    reverse_proxy 127.0.0.1:8502
    basic_auth {
        # generate the hash with: caddy hash-password
        birder $2a$14$....hashed-password....
    }
}

nginx

server {
    listen 443 ssl;
    server_name birds.example.com;
    # ssl_certificate / ssl_certificate_key from certbot or your CA

    location / {
        proxy_pass http://127.0.0.1:8502;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;          # WebSocket (live feed,
        proxy_set_header Connection "upgrade";           # spectrogram, kiosk)
    }
    auth_basic "BirdNet-Behavior";
    auth_basic_user_file /etc/nginx/.htpasswd;            # htpasswd -c … birder
}

Keep the X-Forwarded-For line. It is how the station tells visitors apart for its rate limit, its sign-in throttle and the audit log. A proxy that sets only X-Real-IP passes the visitor’s own X-Forwarded-For through, and a visitor could then claim any address. When the two headers name different visitors the station believes neither and counts the request as the proxy’s own — safe, but every visitor then shares one rate-limit bucket. The log says so once: “a trusted proxy sent X-Forwarded-For and X-Real-IP naming different clients”. For the same reason, if your proxy does not set X-Real-IP itself, have it clear one a visitor sends (nginx: proxy_set_header X-Real-IP "";), so nobody can spend the proxy’s own sign-in throttle by forging it.

Keep the X-Forwarded-Proto line too. The station marks its sign-in cookie Secure when the request reached it over HTTPS: on its own TLS listener, when a trusted proxy says so with X-Forwarded-Proto (or Forwarded: proto=), or when BNB_PUBLIC_URL is an https:// address and the request was addressed to that host. A proxy that sends none of these, with no BNB_PUBLIC_URL set, gets a cookie without Secure, which a browser will also send over plain HTTP.

WebSocket matters here. The live dashboard feed, the spectrogram, and kiosk mode all use WebSockets (/api/v2/ws/detections and /api/v2/ws/spectrogram). Make sure your proxy forwards the Upgrade/Connection headers (shown above) or those features will silently stall.

Sharing one hostname: serving under a path

The setup above gives the station its own hostname. If you already run other services behind one hostname and want the station at https://home.example/birdnet, set a base path:

BIRDNET_BASE_PATH=/birdnet

The station then serves everything — pages, API, static assets, WebSockets — from under that prefix, and every link, form, redirect and cookie it emits carries it. A visitor who reaches the bare host root is redirected to the prefix, so a mistyped bookmark still lands somewhere useful.

Your proxy must pass the full path through unchanged. The station expects to see /birdnet/today, not /today. This is the opposite of the usual “strip the prefix” recipe, and it is the one thing that goes wrong:

home.example {
    handle /birdnet/* {
        reverse_proxy 127.0.0.1:8502    # NOT handle_path — that strips /birdnet
    }
}
location /birdnet/ {
    proxy_pass http://127.0.0.1:8502;   # no trailing path: adding one makes
    proxy_set_header Host $host;        # nginx strip the prefix
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

Home Assistant ingress works the same way.

A prefix that cannot be used — one containing .., whitespace, or a query string — is refused and logged, and the station serves from the root. It is not quietly corrected: a corrected prefix would not match what your proxy sends, and every request would 404 with nothing to explain why.

Home Assistant, Nextcloud and the rest each need their own location block. Nothing about the base path changes the TLS or authentication advice above.

Built-in admin sign-in

The binary gates the /admin panel itself — no proxy required — using the BirdNET-Pi CADDY_PWD convention for the password. Viewing the dashboard and the read-only /api/v2/* endpoints stay open. Signing in is required for the /admin* panel, the Settings management tabs (/station/capture, /station/alerts, /station/data, /station/settings, /station/access) and every action that changes something — deleting, relabelling, reviewing or locking a detection, approving or deleting a quarantined record, bulk review from Search, and saving the onboarding wizard.

Requesting /admin* without a session redirects (303) to a sign-in form at /login, which issues a session cookie. It is not HTTP Basic Auth, so curl -u user:pass will not work — post the form, or sign in through the browser.

A fresh install auto-generates a strong password, prints it once in the post-install summary, and stores it as CADDY_PWD in birdnet.conf. Sign in with the username admin — the account the dashboard seeds, and the only one that exists until you add more in the admin panel.

CADDY_PWD=a-long-random-password

CADDY_USER is read from the process environment only. Under Docker, where compose passes it through, it renames the sign-in; on a bare-metal install the systemd unit sets no EnvironmentFile, so a CADDY_USER line in birdnet.conf has no effect and the sign-in name stays admin.

After editing the config, restart the service (sudo systemctl restart birdnet-behavior). This is still plain HTTP — only rely on it behind TLS, or on a trusted LAN. Clearing CADDY_PWD leaves /admin open to anyone who can reach the dashboard; if the server binds to a non-loopback address (e.g. the default 0.0.0.0) with no CADDY_PWD set, it logs a prominent warning at startup. The live-detection WebSocket and /api/v2/health are exempt from this auth (they sit outside the gated router, and a browser cannot attach a session to a WebSocket handshake anyway), and are read-only in any case — restrict those at the network layer if you need to.

Admins and viewers

Each account on Settings → Access is an admin or a viewer.

  • An admin can do everything the sign-in protects.
  • A viewer can open every page behind the sign-in, the settings forms included, but can change nothing: every save, delete, lock, review or other change is refused (403). The settings forms show a viewer every stored password, token and key masked.
  • A viewer is also refused the few pages that would hand over credentials or the whole database: the full backup and the backup files, the support bundle, the alert-rules export (webhook URLs carry their tokens), an audio source’s own settings (an RTSP URL carries its password), the birdnet.conf lines the BirdNET-Pi import offers to download, and Diagnostics (/admin/doctor, which prints the MQTT broker and the certificate and model paths). The source’s status pill stays visible, and the model panel on the Capture tab shows a viewer what is wrong without the file path.

Cross-origin requests (CORS)

By default the API allows no cross-origin reads — a website you happen to visit can’t read your station’s data over the LAN. If you serve a separate dashboard from a different origin, allow it explicitly:

BIRDNET_CORS_ALLOWED_ORIGINS=https://dashboard.example.com

List origins by name; * is not supported. The station logs an error, ignores that entry and keeps the others, because a wildcard would let any website read the API. State-changing requests are protected by a CSRF guard regardless of this setting.

Embedding the station in another page

By default no other website can show the station inside a frame: every page carries Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN. With sites listed (below), only the dashboard (/), the wall display (/kiosk) and the live view (/recordings) change; the sign-in, /admin, /station, the setup wizard and every other page keep that policy whatever you list. That is the right default for a page with an admin sign-in behind it, because a site that can frame the station can lay invisible controls over it to trick a visitor into clicking (clickjacking). It also means a Home Assistant card or a Grafana panel cannot show the live view.

To allow it, name the places that may, in birdnet.conf or under Settings → General → Online services:

EMBED_ORIGINS=http://homeassistant.local:8123, https://abcd1234.ui.nabu.casa
  • Each entry is a full origin, http or https, host and optional port, no path. https://*.example.com covers every subdomain of a domain you own. Name your own host under a shared service rather than the service: a wildcard over ui.nabu.casa, github.io, pages.dev, co.uk and the like would let everyone who registers a name there show the station, so those are refused (from a short built-in list of such suffixes, not the full Public Suffix List: a suffix that is not on it is not protected).
  • There is no way to allow every site. A bare *, a scheme alone and a wildcard over a whole top-level domain (https://*.com) are refused. tphakala/birdnet-go’s allowembedding is a switch for all sites at once; here you list the sites, because everyone who asks for this knows where they are embedding from.
  • One entry the station cannot read refuses the whole list, and then no site may embed; the journal and --doctor say which entry. Dropping just the bad one would silently narrow what you typed.
  • With a list set, X-Frame-Options is left off (it cannot name a third party) and frame-ancestors alone decides: 'self' plus your origins. Nothing else in the policy changes.
  • Applies after a restart.

For a page on another site, what you get in the frame is the station as an anonymous visitor sees it: the sign-in cookie is SameSite=Lax, which a browser does not send from inside a frame on another site, so signing in does not carry into the frame. A site is the registrable domain, not the origin, so this does not hold for the usual set-up: Home Assistant at http://192.168.1.10:8123 framing the station at http://192.168.1.10:8502 (another port), or a sibling subdomain, is the same site. There the cookie is sent, and if you are signed in to the station in that browser the framed dashboard shows its administrator controls, which the embedding page could click for you if it were compromised. List only places you trust with your browser, and sign out of the station on a shared screen.

A safer alternative: a private tunnel

For remote access without opening any ports, a mesh VPN like Tailscale or WireGuard is the easiest secure option: install it on the Pi and your phone/laptop, and reach http://<tailscale-ip>:8502 as if you were on the same LAN — encrypted, no public exposure, no certificates to manage.

Checklist

  • Keep CADDY_PWD set so /admin stays protected (a fresh install generates one) — don’t clear it on a non-loopback bind.
  • Restrict the bind to 127.0.0.1 (+ SSH/VPN) if you don’t need LAN access.
  • Never port-forward 8502 directly — always terminate TLS at a proxy or use a VPN for off-LAN access.
  • Forward WebSocket upgrade headers in the proxy.