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.0default exposes the dashboard to everyone on your network. That’s usually fine at home: the read-only views are open, and the/adminpanel is protected by the auto-generated password (see below). On an untrusted network (a shared flat, public Wi-Fi), confirmCADDY_PWDis 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-Forline. It is how the station tells visitors apart for its rate limit, its sign-in throttle and the audit log. A proxy that sets onlyX-Real-IPpasses the visitor’s ownX-Forwarded-Forthrough, 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 setX-Real-IPitself, 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-Protoline too. The station marks its sign-in cookieSecurewhen the request reached it over HTTPS: on its own TLS listener, when a trusted proxy says so withX-Forwarded-Proto(orForwarded: proto=), or whenBNB_PUBLIC_URLis anhttps://address and the request was addressed to that host. A proxy that sends none of these, with noBNB_PUBLIC_URLset, gets a cookie withoutSecure, 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/detectionsand/api/v2/ws/spectrogram). Make sure your proxy forwards theUpgrade/Connectionheaders (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
locationblock. 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.conflines 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,
httporhttps, host and optional port, no path.https://*.example.comcovers every subdomain of a domain you own. Name your own host under a shared service rather than the service: a wildcard overui.nabu.casa,github.io,pages.dev,co.ukand 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’sallowembeddingis 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
--doctorsay which entry. Dropping just the bad one would silently narrow what you typed. - With a list set,
X-Frame-Optionsis left off (it cannot name a third party) andframe-ancestorsalone 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_PWDset so/adminstays 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
8502directly — always terminate TLS at a proxy or use a VPN for off-LAN access. - Forward WebSocket upgrade headers in the proxy.