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

Reverse proxy & CORS

BeetstreamNext reads standard HTTP headers to determine the original client’s IP, so putting it behind a reverse proxy is straightforward.

Enable the reverse_proxy option (see configuration) so it trusts those forwarded headers, and set the proxy_hops to the number of trusted proxies in front of it.

Unix socket

With the proxy on the same machine (or sharing a volume with the container), BeetstreamNext can listen on a unix socket instead of a port.

Set host to unix:/absolute/path/of/socket:

BSN_HOST=unix:/run/beetstreamnext/bsn.sock

The socket is created with 660 permissions: add the proxy’s user to the group that runs BeetstreamNext.

  • reverse_proxy has to be on, the server refuses to start otherwise: every request via a socket looks local, so the real client addresses can only come from the proxy’s forwarded headers.
  • It can’t be combined with other host entries
  • It isn’t available on Windows, nor in debug mode.
  • The folder must exist and be writable by BeetstreamNext, and an old socket file from a previous run is replaced.
  • In Docker, mount the socket’s folder as a volume shared with the proxy.

Restricting which peers are trusted

By default, with reverse_proxy on, X-Forwarded-* headers are trusted from any peer that can reach the server. If the port is reachable by anything but the proxy, set trusted_proxies to the proxy’s address(es) (e.g. 172.18.0.0/16 for a Docker network). Headers from any other address are then ignored, so a spoofed X-Forwarded-For can’t change the client IP seen by the rate limiter and IP filters.

Nginx

Example configuration:

location /beetstreamnext {
    proxy_pass http://127.0.0.1:8080;  # or http://unix:/run/beetstreamnext/bsn.sock if using a Unix socket

    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # If hosting in a subfolder, tell BeetstreamNext what the subfolder is!
    proxy_set_header X-Forwarded-Prefix /beetstreamnext;
}

Tip

The admin panel’s live views (the import log, notifications, etc) use a long-lived text/event-stream connection. If your proxy ignores the X-Accel-Buffering header, you should set proxy_buffering off; for the admin location, otherwise live updates can show up late.

Caddy 2

Caddy passes all the required headers by default:

example.com {
    reverse_proxy 127.0.0.1:8080
}

(or reverse_proxy unix//run/beetstreamnext/bsn.sock if using a Unix socket)

Traefik with Docker

A common setup: the admin panel on a private hostname, the Subsonic API and accounts on another, and only the public routes (shares, tokenised links) on a public one.

You can achieve this easily with a port for each scope (port, public_port and admin_port), and one Traefik router for each.

services:
  beetstreamnext:
    image: ghcr.io/florentlm/beetstreamnext:latest
    container_name: beetstreamnext
    restart: unless-stopped
    environment:
      PUID: "1000"
      PGID: "1000"
      BEETS_LIBRARY_DB: /data/library.db
      MUSIC_ROOT: /music
      BSN_PORT: 8080                               # Subsonic API, login, account pages
      BSN_PUBLIC_PORT: 8081                        # Public routes only
      BSN_ADMIN_PORT: 8082                         # Admin panel and control API
      BSN_REVERSE_PROXY: "true"
      BSN_PROXY_HOPS: 1                            # if Traefik is the only proxy
      BSN_EXTERNAL_HOSTNAME: share.example.com     # Host used in share links
    volumes:
      - ./config:/config
      - /path/to/library.db:/data/library.db:ro
      - /path/to/music:/music:ro
    networks:
      - proxy
    labels:
      - traefik.enable=true
      - traefik.docker.network=proxy
      
      # Public: shares and tokenised links, on a port that serves nothing else
      - traefik.http.routers.bsn-public.rule=Host(`share.example.com`)
      - traefik.http.routers.bsn-public.entrypoints=websecure
      - traefik.http.routers.bsn-public.tls.certresolver=letsencrypt
      - traefik.http.routers.bsn-public.service=bsn-public
      - traefik.http.services.bsn-public.loadbalancer.server.port=8081

      # Subsonic clients and account pages
      - traefik.http.routers.bsn.rule=Host(`music.example.com`)
      - traefik.http.routers.bsn.entrypoints=websecure
      - traefik.http.routers.bsn.tls.certresolver=letsencrypt
      - traefik.http.routers.bsn.service=bsn
      - traefik.http.services.bsn.loadbalancer.server.port=8080

      # Admin panel and control API
      - traefik.http.routers.bsn-admin.rule=Host(`admin.example.com`)
      - traefik.http.routers.bsn-admin.entrypoints=websecure
      - traefik.http.routers.bsn-admin.tls.certresolver=letsencrypt
      - traefik.http.routers.bsn-admin.service=bsn-admin
      - traefik.http.routers.bsn-admin.middlewares=admin-allowlist
      - traefik.http.middlewares.admin-allowlist.ipallowlist.sourcerange=192.168.0.0/16,100.64.0.0/10
      - traefik.http.services.bsn-admin.loadbalancer.server.port=8082

networks:
  proxy:
    external: true

(This assumes a Traefik container on the external proxy network, with a websecure entrypoint and a letsencrypt certificate resolver. Adjust to your configuration.)

  • Each router reaches a different port, and each port serves a different scope: 8081 the public routes, 8080 also the Subsonic API and account pages, 8082 also the admin panel and control API. A request for share.example.com with a spoofed Host: admin.example.com header still only gets public routes.

  • The admin-allowlist middleware limits who can reach the admin hostname (an IP allow-list here, or forward-auth with Authelia/Authentik, etc.).

  • External scripts that use the control API (/control-api) to trigger imports must reach the admin port. Note: I might make this more flexible again

  • BSN_PROXY_HOPS is the number of proxies in front of BeetstreamNext. Use 2 or more if something else (Cloudflare, for instance) is in front of Traefik, or if you offload file serving with another proxy.

Note

To achieve similar thing without Docker, on a host with several addresses, use public_hosts and admin_hosts.

Credentials in proxy logs

The Subsonic protocol sends credentials as URL query parameters: p (password), t and s (token and salt), and apiKey. Creating or updating a user over the API also puts the new password in the query string.

BeetstreamNext redacts these from its own logs, but your reverse proxy sees the full URL, and its access log will contain them in clear text (and so will any other log or monitoring tool in front of BeetstreamNext). Anyone who can read those logs can read your users’ passwords and API keys.

To avoid it, either turn access logging off, or log the path only and leave the query string out.

Nginx: log $uri instead of $request/$request_uri:

log_format bsn '$remote_addr [$time_local] "$request_method $uri" $status $body_bytes_sent';
access_log /var/log/nginx/beetstreamnext.access.log bsn;

Apache: log %U (path only) instead of %r:

LogFormat "%h %t \"%m %U\" %>s %b" bsn
CustomLog logs/beetstreamnext_access.log bsn

Caddy: replace the sensitive parameters in the log:

example.com {
    log {
        format filter {
            wrap json
            fields {
                request>uri query {
                    replace p REDACTED
                    replace t REDACTED
                    replace s REDACTED
                    replace apiKey REDACTED
                    replace password REDACTED
                }
            }
        }
    }
    reverse_proxy 127.0.0.1:8080
}

Prefer clients that support API keys (apiKey) over passwords where possible, and give them an app password rather than the account password: an app password can be revoked (and limited to read-only access), whereas a leaked account password is also the user’s web UI login (and, for admins, the admin panel login).

Tip

You should also just avoid using an account with the Admin role in a Subsonic client (create a non-admin account for that).

Also, use HTTPS so the URLs aren’t readable on the network.

Offloading file serving to the proxy

Instead of streaming raw file bytes through the Python process, the reverse proxy can serve them directly.

This only takes effect when reverse_proxy is enabled, and only helps for direct (non-transcoded) playback/downloads.

Set sendfile_method to x-accel-redirect for Nginx, or x-sendfile for Apache.

Nginx (x-accel-redirect)

Add an internal-only location block aliased to your music root, and point sendfile_internal_prefix at it:

location /_bsn_internal/ {
    internal;
    alias /path/to/your/music/;
}
beetstreamnext:
    sendfile_method: x-accel-redirect
    sendfile_internal_prefix: /_bsn_internal

/_bsn_internal is the default for sendfile_internal_prefix.

Apache (x-sendfile, via mod_xsendfile)

XSendFile On
XSendFilePath /path/to/your/music/
beetstreamnext:
    sendfile_method: x-sendfile

sendfile_internal_prefix isn’t used with x-sendfile.

Full working example: Nginx sidecar for a Docker deployment

Putting everything together, a minimal nginx.conf that reverse-proxies a beetstreamnext container on the same Docker network and offloads direct file serving via X-Accel-Redirect:

worker_processes 1;
pid /tmp/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    access_log off;

    server {
        listen 8080;

        location /_bsn_internal/ {
            internal;
            alias /music/;
        }

        location / {
            proxy_pass http://beetstreamnext:8888;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

Here 8888 can be whatever port (BSN_PORT/port setting) BeetstreamNext is listening on internally, only Nginx’s port needs to be published to the host.

/music must be the same music folder mounted into both the nginx and beetstreamnext containers, with the same relative layout under each mount point (but the absolute host path doesn’t need to match).

The matching BeetstreamNext settings:

beetstreamnext:
    reverse_proxy: true
    proxy_hops: 2   # The number of proxies in front of BeetstreamNext
    sendfile_method: x-accel-redirect
    sendfile_internal_prefix: /_bsn_internal

Important

For proxy_hops, use 1 if the Nginx container is the only proxy, more if Nginx is itself behind a reverse proxy (Traefik, Cloudflare, etc)

Web clients and CORS

By default, CORS is disabled. Native mobile/desktop apps usually ignore CORS entirely, so you probably don’t need to change anything for those.

However, if you want to use a web-based Subsonic player hosted on a different domain than BeetstreamNext, your browser will block the connection unless you explicitly allow that origin:

beetstreamnext:
    cors_origins: 'https://music.example.com' # comma-separated list, or '*' for all

If you’re behind an SSO gateway (Authelia, Authentik, etc), or if the web player is a bit quirky, you might also need:

beetstreamnext:
    cors_supports_credentials: true

Caution

️ Never set cors_origins: '*' together with cors_supports_credentials: true. Doing so would allow any website you visit to silently interact with your BeetstreamNext server in the background.