On this page
Operator manual

Configuration

Environment variables configure service connections, paths, secrets and optional integrations. Symfony's configuration reads those values when the application starts. The source installation uses private web settings and a separate worker credentials file; changing one does not update the other.

Behavior that an administrator may want to change while the server runs, such as whether audio is transcoded or which language emails default to, is kept in the database instead, as server settings.

Where a change takes effect

Configuration Applies to How a change takes effect
Web environment and Symfony configuration Web application and console commands launched with that environment Restart the running app after changing environment values; use the upgrade procedure for release or image changes. A new console process reads its own environment.
Worker image and admitted credentials The control/execution worker pair Follow worker deployment and upgrading. Changed deployment recipes require retirement and a fresh boot; editing a file does not alter running containers.
Worker manifest Host controller and the admitted resource/storage recipe Use the exact manifest for observation and recovery. Changes to its profile or recipe require a new admitted deployment.
Server settings Application behavior that reads a setting while running Use the admin UI, settings CLI or API described below. Each feature reads the setting when it acts; already completed work is not undone.
User settings One user's explicitly stored preferences Change through the account UI, admin tools or user settings API. Removing a choice restores the applicable default.

For the source-installation web environment, Symfony reads .env, .env.local, .env.dev and .env.dev.local in that order; existing process environment values win. The tracked .env.dev has development defaults, including an application secret, so put private overrides in .env.dev.local as shown in Getting started. A prod process selects its corresponding environment files instead. Do not put secrets in files copied into an image.

Compose's --env-file options configure Compose interpolation separately from Symfony's environment loading. The installation's dc function names both the private settings and generated image-selection file. Use that function for its commands; Make targets the development preview. See Command context.

The worker pair runs with APP_ENV=prod. It does not read the web's .env.dev.local. Its admitted credentials allow a restricted set of runtime keys; other values, including encoder selection, must be configured in the image as described by the worker and transcoding guides. Web, workers and host controller must reach the same main database through addresses appropriate to their networks. Web and workers must also agree on shared media paths and Redis transport settings. See Shared media paths.

Application

The application name is fixed as Bånder. Environment variables cannot change the name shown in emails, passkey prompts or TOTP enrollment.

Variable Default Description
APP_ENV dev Symfony environment: dev, prod, or test. Controls debugging, caching, and error handling.
APP_SECRET — One cryptographic secret for Symfony security components and encrypted webhook signing secrets. Generate a unique value for each deployment; use the offline rotation procedure to change it.
APP_URL https://localhost Canonical URL of the application. Used for CORS configuration and URL generation.
APP_DOMAIN localhost Domain name displayed or used in API responses.

Database

Variable Default Description
DATABASE_URL postgresql://baander:baander@database:5432/baander?serverVersion=18&charset=utf8 Full Doctrine DBAL connection URL. Format: postgresql://user:password@host:port/dbname?serverVersion=18&charset=utf8. The serverVersion parameter should match your PostgreSQL version.

The test database uses baander_test as the database name — configured in .env.test.

Redis

Variable Default Description
REDIS_PASSWORD — Password for the Redis instance. Optional in dev, required in production. Used by cache pools, messenger transport, and session storage.
REDIS_URL redis://default:%env(REDIS_PASSWORD)%@redis:6379 Redis endpoint URL. Worker deployments require it to be pathless and queryless because the scheduler appends its stream and group. The password is interpolated from REDIS_PASSWORD.

Redis is used for:

  • Cache — tag-aware caching for API responses, OAuth tokens, and user sessions
  • Messenger — async job transport (scan jobs, notification delivery)
  • Rate limiting — limiter state for API requests, for login, passkey, registration, password reset and token refresh attempts, and for the OAuth authorization server endpoints
  • DPoP stores — the single-use nonces of the token endpoints and the proof identifiers seen on API requests (replay protection)

Redis connections from the application pool and Doctrine cache pools have one-second connect and read timeouts. A request can make several Redis calls, so these are per-connection bounds rather than a one-second total request deadline. Rate limiters use the pooled client directly, with atomic state in separate namespaces; they do not use cache pools. Unavailable rate-limit state answers 503 with Retry-After: 1; unavailable DPoP state answers 503 with Retry-After: 5. Both carry Cache-Control: no-store, and the first rejecting check determines the response. Clients keep their sessions. See Sessions and a Redis outage for the narrow logout/revocation exceptions.

Messenger

Variable Default Description
MESSENGER_TRANSPORT_DSN redis://default:%env(REDIS_PASSWORD)%@redis:6379/messages Redis URL for the async transport. Worker deployments accept no path, /messages or /messages/baander.
MESSENGER_CONSUMER_NAME ${HOSTNAME:-worker} Standalone consumer fallback. The admitted worker replaces it with an identity derived from the deployment, boot, worker role and launch generation.

Worker deployments accept a single redis:// or rediss:// endpoint in each URL, including percent-encoded credentials and bracketed IPv6 hosts. A host is required; percent escapes in hosts and port zero are rejected. URLs cannot contain fragments, ASCII whitespace or control characters, commas, backslashes, malformed percent escapes or multiple unescaped @ separators. REDIS_URL must have no path or query. MESSENGER_TRANSPORT_DSN may carry unique scalar query options: stream=messages, group=baander, non-negative integer dbindex and retry_interval, or finite non-negative decimal timeout and read_timeout. Integer values use 0 or digits without leading zeroes; decimal values may add a fraction to that form. Unknown options, duplicates and nested option names are rejected. Consumer overrides are rejected, including a consumer path segment.

Credential admission rejects invalid URLs before Docker creation. The supervisor checks again before launching children; diagnostics identify the setting without printing its credential-bearing value. These checks preserve the configured streams, group and generated identity. They do not test endpoint connectivity.

The validated workload catalog in config/packages/messenger.php supplies fifteen default production routes and two explicit-only workloads: lyrics fetch and webhook delivery. Container compilation checks routes, handlers, codecs, JSON serialization and bounded retry policies; producer dispatch checks the selected senders. Execution runs ordinary consumers for async and a separate consumer for scheduler. See Background work for how queues, the outbox and scheduled work reach those consumers. Resource classes io, cpu and coordination describe work and do not change worker reservations. The test environment configures both receivers as in-memory:// in that file; changing a production worker DSN to in-memory:// is rejected.

Both async and scheduler configure max_retries: 3, delay: 1000 milliseconds, multiplier: 2 and max_delay: 60000 milliseconds. Symfony recoverable exceptions can bypass the retry count and supply their own retry delay. These values bound the configured retry policy; they do not impose an unconditional execution cap.

OAuth 2.0

Variable Default Description
OAUTH_PRIVATE_KEY_PATH %kernel.project_dir%/config/secrets/oauth/private.key Path to the RSA private key for signing JWT access tokens. Generate with app:oauth:generate-keys.
OAUTH_PUBLIC_KEY_PATH %kernel.project_dir%/config/secrets/oauth/public.key Path to the RSA public key for verifying JWT access tokens.
AUTH_SPA_CLIENT_ID baander_dev_spa_00001 Public ID of the first-party OAuth client that password and passkey login issue tokens to. The web and Electron apps both use it. Seeded by app:auth:setup-clients.

Provision the RSA key files outside the image and set the two key-path variables to readable paths inside the web container. The default paths resolve relative to the project directory. Keep the private key readable only by the application user; production images contain no OAuth keys.

Token lifetimes

These are configured as parameters in config/packages/auth.yaml and can be overridden per-environment:

Parameter Default Description
auth.access_token.ttl 3600 (1 hour) Access token lifetime in seconds.
auth.refresh_token.ttl 2592000 (30 days) Refresh token lifetime in seconds.
auth.refresh.reuse_grace_seconds 60 Lost-response grace, in seconds, measured on the database clock. For this long after a refresh token was consumed, presenting it again with a proof from the same DPoP key, while the pair it was exchanged for is still unused, issues a fresh pair and revokes that unused pair; a client whose refresh answer never arrived keeps its session. Any other reuse of a consumed token revokes its whole chain. At most 60; 0 disables the grace.
auth.auth_code.ttl 600 (10 minutes) Authorization code lifetime in seconds (authorization code grant with PKCE).
auth.device_code.ttl 900 (15 minutes) Device code lifetime in seconds (RFC 8628).
auth.device_code.interval 5 Minimum seconds between a device's token polls. A faster poll is answered with slow_down and adds 5 seconds to that device code's interval.

Authorization server

Clients other than Baander's own apps obtain tokens through the authorization code grant, the device authorization grant, and the refresh token grant. These parameters are also in config/packages/auth.yaml:

Parameter Default Description
auth.scopes.user_grants profile, email, library, playlist Scopes a user token may carry, whichever path issues it: password or passkey login, the authorization code grant, or the device grant. Requested scopes outside this list are silently dropped. The metadata document at /.well-known/oauth-authorization-server lists them as scopes_supported.
auth.oauth.authorization_page_uri %auth.oauth.issuer%/oauth/authorize Absolute URI of the web app's consent page. The metadata document advertises it as authorization_endpoint. The page checks the request with GET /api/oauth/authorize, sends the user's decision to POST /api/oauth/authorize, and then sends the browser to the redirect URI it gets back.
auth.device.verification_uri %auth.oauth.issuer%/device Absolute URI of the web app page where a signed-in user enters the code a device shows. The device authorization response returns it as verification_uri, and the same URI with ?user_code= appended as verification_uri_complete.

The issuer is APP_URL. app:auth:setup-clients creates only the first-party client; administrators register device, public and confidential clients with the app:oauth:client:* commands or the admin API (see OAuth Clients).

Web Push (VAPID)

Variable Default Description
VAPID_PUBLIC_KEY — VAPID public key for browser push notifications. Generate with php bin/console app:generate-vapid-keys.
VAPID_PRIVATE_KEY — VAPID private key. Keep this secret.

Mail

Variable Default Description
MAILER_DSN smtp://localhost:1025 Mailer transport DSN. Required for email features (password reset, email verification, notifications). Use your provider's SMTP URL in production.
MAIL_FROM_ADDRESS noreply@localhost From address for outgoing email.

config/packages/mailer.yaml builds the From header with the fixed name Bånder and the address from MAIL_FROM_ADDRESS. Mail servers often reject a sender address whose domain they cannot verify, so use an address at a domain your SMTP provider is allowed to send for. The test environment replaces MAILER_DSN with null://null, so tests never send real mail.

External APIs

API keys are optional. MusicBrainz and LRCLIB lookups work without them; other providers need credentials in the process that makes the request.

Variable Default Description
DISCOGS_TOKEN — Discogs API personal access token. The admitted worker recipe cannot currently provision it for queued enrichment; see External APIs.
LASTFM_API_KEY — Last.fm API key for web artist search and browse information; stored metadata sync does not use it.
LASTFM_API_SECRET — Listed in .env.example, but not consumed by the current Last.fm adapter.
SPOTIFY_CLIENT_ID — Adapter credential; no current application workflow calls Spotify.
SPOTIFY_CLIENT_SECRET — Companion adapter secret; it does not enable a current workflow.
TASTE_DIVE_API_KEY — Adapter credential; no current application workflow calls TasteDive.
MUSICBRAINZ_VERSION 1.0.0 Listed in .env.example, but not consumed by the current MusicBrainz adapter.
MUSICBRAINZ_CONTACT noreply@localhost Listed in .env.example, but not consumed by the current MusicBrainz adapter.

The MusicBrainz adapter sends a fixed application user agent. Changing the two MusicBrainz environment values above does not change it. See External APIs for provider behavior and limits.

Security

Rate limiting

Limits are set in config/packages/auth.yaml. They feed the Symfony policies in config/packages/framework.yaml; all 19 configured limiters consume state through atomic Redis compare-and-set. Each limiter has its own namespace, so the admin Rate Limits tab and app:rate-limiter:clear reset its generation without changing other limiters. The catalog reports storageNamespace, not a cache pool. Changing a limiter's effective configuration starts fresh subject budgets; it does not carry a shared budget across different configurations during a rolling change. See Rate Limiter Monitor.

Parameter Default Description
auth.rate_limit.anonymous_api.max_requests 360 Max /api requests per client IP for requests without an authenticated user (sliding window).
auth.rate_limit.anonymous_api.window 60 (1 min) Window in seconds for anonymous API requests.
auth.rate_limit.authenticated_api.max_requests 1800 Max /api requests per authenticated user (sliding window).
auth.rate_limit.authenticated_api.window 60 (1 min) Window in seconds for authenticated API requests.
auth.rate_limit.login.max_attempts 20 Max login attempts per IP within the window.
auth.rate_limit.login.window 300 (5 min) Window in seconds for both login limits.
auth.rate_limit.login_per_email.max_attempts 10 Max login attempts per IP and email pair (prevents distributed brute force).
auth.rate_limit.passkey.max_attempts 40 Max passkey sign-in requests per IP within the window. One sign-in uses two requests.
auth.rate_limit.passkey.window 300 (5 min) Window in seconds for passkey sign-in.
auth.rate_limit.register.max_attempts 10 Max registration attempts per IP within the window.
auth.rate_limit.register.window 900 (15 min) Window in seconds for registration.
auth.rate_limit.password_reset.max_attempts 10 Max password reset requests and redemptions per IP within the window.
auth.rate_limit.password_reset_per_email.max_attempts 10 Max password reset requests per account (normalized email) within the window.
auth.rate_limit.password_reset.window 900 (15 min) Window in seconds for both password reset limits.
auth.rate_limit.refresh.max_attempts 60 Max token refresh requests per refresh token within the window.
auth.rate_limit.refresh.window 60 (1 min) Window in seconds for token refresh.
auth.rate_limit.email_verification.max_attempts 10 Max email verification attempts and verification resend requests per IP within the window.
auth.rate_limit.email_verification.window 900 (15 min) Window in seconds for the per-IP verification limit.
auth.rate_limit.email_verification_resend_per_user.max_attempts 3 Max verification emails a signed-in user can ask for within the window.
auth.rate_limit.email_verification_resend_per_user.window 3600 (1 hour) Window in seconds for the per-user resend limit.
auth.rate_limit.oauth_token.max_attempts 120 Max OAuth token endpoint requests per IP within the window. A polling device makes about 12 per minute at the default interval.
auth.rate_limit.oauth_token.window 60 (1 min) Window in seconds for both token endpoint limits.
auth.rate_limit.oauth_token_client.max_attempts 600 Max token endpoint requests per client_id within the oauth_token window, from all addresses together.
auth.rate_limit.oauth_authorize.max_attempts 30 Max authorization endpoint requests per IP within the window.
auth.rate_limit.oauth_authorize.window 60 (1 min) Window in seconds for the authorization endpoint.
auth.rate_limit.oauth_device_authorize.max_attempts 20 Max device authorization requests per IP within the window. Each request stores a device code.
auth.rate_limit.oauth_device_authorize.window 300 (5 min) Window in seconds for device authorization.
auth.rate_limit.oauth_device_verify.max_attempts 30 Max user code lookups, approvals and denials per IP within the window. This bounds user code guessing.
auth.rate_limit.oauth_device_verify.window 300 (5 min) Window in seconds for user code lookups, approvals and denials.

Each limiter counts requests per key:

Limiter Applies to Key
anonymous_api Every /api request without an authenticated user, including requests whose credentials are rejected Client IP
authenticated_api Every /api request with an authenticated user User ID
auth_login_ip POST /api/auth/login Client IP
auth_login_ip_email POST /api/auth/login Client IP and email
auth_passkey_ip POST /api/auth/passkey/authenticate/options, POST /api/auth/passkey/authenticate, POST /api/auth/login/passkey Client IP
auth_register_ip POST /api/auth/register Client IP
auth_password_reset_ip POST /api/auth/password/reset-request, POST /api/auth/password/reset (one shared bucket) Client IP
auth_password_reset_email POST /api/auth/password/reset-request Lower-cased email
auth_refresh_client POST /api/auth/refresh Refresh token, or client IP when the body has none
auth_email_verification_ip POST /api/auth/email/verify, POST /api/auth/me/email/verification (one shared bucket) Client IP
auth_email_verification_user POST /api/auth/me/email/verification User ID
oauth_token_ip POST /api/oauth/token Client IP
oauth_token_client POST /api/oauth/token client_id from the form or JSON body; skipped when the body has none
oauth_authorize_ip GET and POST /api/oauth/authorize Client IP
oauth_device_authorize_ip POST /api/oauth/device/authorize Client IP
oauth_device_verify_ip GET /api/oauth/device/verify, POST /api/oauth/device/approve Client IP
config_check Admin configuration check (10 per minute) One bucket shared by all callers
batch_cover_extract POST /api/albums/covers/extract (10 per minute) Client IP
discovery_endpoint_ip Discovery registration and pairing (30 per minute) Client IP

The last three have fixed limits in config/packages/framework.yaml and no auth.yaml parameter.

A request over a limit gets 429 Too Many Requests with a Retry-After header in seconds. The password reset account limit and the verification resend user limit are the exceptions. Over the reset account limit, the endpoint returns the same 200 response as for any other address but issues no reset token, so the response never shows whether an account exists. Over the resend user limit, the endpoint returns its usual 200 response but sends no email. The per-IP reset and verification limits still answer 429.

Media delivery routes are exempt from anonymous_api and authenticated_api, so playback is never throttled by the general API budget: the audio stream (/api/stream/track), the HLS and DASH manifests and segments under /api/transcode/{id}/ (master.m3u8, media.m3u8, manifest.mpd, init, segment), subtitle playlists and segments, image files (/api/images/{id}/file) and album covers (GET /api/albums/{id}/cover). Health, readiness and metrics endpoints (/health, /ready, /live, /metrics) are outside /api and are never limited.

With APP_ENV=dev, only the config_check, auth_password_reset_email and auth_email_verification_user limits apply; the others are skipped.

Both HTTP layers read TRUSTED_PROXIES, a comma-separated list of trusted proxy addresses or CIDRs that defaults to empty. With that default, a direct client cannot select a rate-limit identity by supplying X-Forwarded-For. Behind nginx or another proxy, configure only its actual source addresses or deliberately restricted CIDRs and make that proxy replace incoming forwarding headers. Otherwise clients share the proxy's address and its anonymous budget. Do not enable broad subnet trust merely because the service runs in Docker. See Request identity and proxy deployment.

Passkeys (WebAuthn)

Configured in config/packages/auth.yaml:

Parameter Default Description
auth.passkey.timeout 300000 (5 min) WebAuthn ceremony timeout in milliseconds.
auth.passkey.authenticator_attachment platform Prefer platform authenticators (Touch ID, Windows Hello).
auth.passkey.user_verification preferred Whether to require user verification during authentication.
auth.passkey.resident_key preferred Whether credentials should be discoverable (passkeys).
auth.passkey.attestation none Attestation conveyance preference.

Passwords (Argon2id)

Variable Default Description
ARGON2ID_MEMORY_COST 65536 Argon2id memory cost, in KiB.
ARGON2ID_TIME_COST 4 Argon2id time cost (iterations).
ARGON2ID_THREAD_COST 3 Argon2id parallelism (thread count).

Password reset

Variable Default Description
PASSWORD_RESET_EXPIRE 60 Lifetime of a password reset token, in minutes. Must be at least 1.

A user has at most one outstanding reset token; a new request replaces it. The token is removed when it is redeemed, when the account is deleted, and when the account's email address or password changes. Baander stores only a SHA-256 hash of the token.

Self-service reset needs working email. When a user asks for a reset on the web login page, POST /api/auth/password/reset-request issues a token for an existing account and emails a link through MAILER_DSN (see Mail). The link opens APP_URL/reset-password, so APP_URL must be the address users reach the web app at. The token sits in the link's fragment (#token=…); browsers do not send fragments to the server, so the token stays out of access logs and Referer headers. The email states the link's lifetime from PASSWORD_RESET_EXPIRE.

Baander sends the email after the HTTP response has gone out, so the request takes as long for an unknown address as for a real account. A failed send does not change the response either. Baander logs the failure with the user ID but without the token or the address, and does not retry; the user can ask for a new link. The email is written in the user's email language, which Baander looks up when it sends the email, after the response.

Without working email, reset a forgotten password with app:user:reset-password or from the admin panel.

A password change revokes the sessions that the old password started. Redeeming a reset token, an administrator reset and app:user:reset-password revoke all of the user's access and refresh tokens. When users change their own password, the session that made the change stays signed in and their other sessions are revoked.

Email verification

Parameter Default Description
auth.email_verification_token.ttl 86400 (24 hours) Lifetime of an email verification token, in seconds. Set in config/packages/auth.yaml; must be at least 60.

Verification confirms that the user owns the account's email address. Until it does, Baander sends the account no notification email. Operator-created accounts start verified; every other account needs working email to verify (see Mail).

Baander emails a verification link when a user registers and whenever an email address changes, whether the user changes it, an administrator changes it in the admin panel, or an operator runs app:user:change-email. A changed address starts unverified. The link opens APP_URL/verify-email, so APP_URL must be the address users reach the web app at. As with password reset, the token sits in the link's fragment (#token=…) and stays out of access logs and Referer headers. A signed-in user whose address is unverified can ask for a new link in Settings, which calls POST /api/auth/me/email/verification. That endpoint answers the same way whether or not it sent an email.

A user has at most one outstanding verification token; a new link replaces it. A token verifies only the address it was sent to. It is removed when it is redeemed, when the account is deleted, and when the account's email address changes, so a link sent to an old address stops working. Baander stores only a SHA-256 hash of the token, with the address.

Baander sends the email after the HTTP response has gone out, or at once when the change comes from the command line. A failed send never fails the registration or email change that caused it. Baander logs the failure with the user ID but without the token or the address, and does not retry; the user can ask for a new link. Like the reset email, the verification email is written in the user's email language.

Token binding

Constrains how many distinct IPs a single token can bind to, to detect token theft and sharing.

Variable Default Description
TOKEN_BINDING_MAX_IP_CHANGES 10 Maximum number of distinct IPs a token may bind to.
TOKEN_BINDING_CONCURRENT_IP_WINDOWS_SECONDS 300 Window (seconds) over which concurrent IPs are counted.
TOKEN_BINDING_MAX_CONCURRENT_IPS 1 Maximum concurrent IPs allowed per token.
TOKEN_BINDING_MIN_IP_CHANGE_INTERVAL_MINUTES 5 Minimum minutes between allowed IP changes.

Server

Variable Default Description
DEFAULT_URI https://localhost Base URL used by the router for generating absolute URLs. Should match APP_URL in production.
HEALTH_MONITOR_INTERVAL_SECONDS 60 Seconds between the web server's health checks, which raise health alerts.
WEB_DRAIN_SECONDS 30 The web server's drain deadline: how many seconds a stop or restart of the web server may take at most. At least 8; a smaller value stops the server from starting. Give the container a stop timeout of this value plus 5 seconds; see Stopping and restarting the web server.

Swoole

Configured in config/packages/swoole.yaml:

Setting Default Description
swoole.http_server.host 0.0.0.0 Address to bind the HTTP server.
swoole.http_server.port 9501 HTTP server port.
swoole.http_server.shutdown_timeout WEB_DRAIN_SECONDS Seconds swoole:server:stop expects a stop to take; it waits 5 seconds longer for the server to exit. When the server is its container's PID 1, as in the shipped images, the command does not wait: it exits once the stop is requested.
swoole.http_server.settings.heartbeat_check_interval 1 Seconds between Swoole's checks for idle connections. A stop waits for the current interval to end before any worker stops, so keep it short.

Swoole's max_wait_time is not set in this file. When the server starts, it derives the whole drain budget from the deadline D, WEB_DRAIN_SECONDS:

  • the drain window, during which requests finish and clients are told to reconnect, is floor((D - 4) / 2): 13 seconds at the default of 30;
  • max_wait_time is floor((D - 4 - window) / 2): 6 seconds at the default. After the window, Swoole stops the HTTP workers. The formula retains room for two Swoole stop phases;
  • 4 seconds remain reserved as shutdown margin. Web starts no CPU process pool; encoders belong to the independent worker deployment.

The window, twice max_wait_time and those 4 seconds never add up to more than D.

The startup table of app:serve shows the derived max_wait_time, and the line the server logs when a drain starts names the deadline, the window and max_wait_time.

Coroutine file I/O

The image builds Swoole 6.2 with its asynchronous thread backend for coroutine file I/O. An io_uring-enabled build cannot read coroutine files under Docker's default seccomp profile, and this Swoole version has no supported runtime INI switch to select another backend. Rebuild the image with the shipped flags; changing PHP INI or relaxing the worker's security profile is not the deployment recipe.

Web configures aio_core_worker_num: 1 and aio_worker_num: 4. Each private media job sets both limits to 1 before its helper forks or a reactor starts. These finite thread limits apply independently of the host CPU count. Include the AIO and encoder threads in container PID accounting.

PHP memory limit

The production image sets memory_limit to 512M in /usr/local/etc/php/conf.d/zz-baander-production-memory.ini. Every PHP process in the image reads it: the web server, the app:health:check HEALTHCHECK and console commands. The web server's memory check measures each HTTP worker against this limit. The worker supervisor (app:worker) sets its children's limits itself with -d. To change the limit, override the file or add a later one in conf.d.

Library Scanner Security

Limits applied when scanning media libraries, to prevent path traversal, oversized files, and metadata-based attacks. All variables use the SCANNER_ prefix.

Variable Default Description
SCANNER_ALLOWED_PATHS /home,/media,/mnt,/Users,/Volumes Comma-separated roots the scanner may read. Paths outside these are rejected.
SCANNER_MAX_AUDIO_SIZE_MB 500 Maximum audio file size, in MB.
SCANNER_MAX_VIDEO_SIZE_MB 5000 Maximum video file size, in MB.
SCANNER_MAX_LYRICS_SIZE_MB 1 Maximum lyrics file size, in MB.
SCANNER_MAX_IMAGE_SIZE_MB 10 Maximum image file size, in MB.
SCANNER_MAX_SUBTITLE_SIZE_MB 1 Maximum subtitle file size, in MB.
SCANNER_MAX_TOTAL_FILES 100000 Maximum number of files a single scan processes.
SCANNER_MAX_DIRECTORY_DEPTH 20 Maximum directory recursion depth.
SCANNER_FILE_TIMEOUT 30 Per-file processing timeout, in seconds.
SCANNER_DIR_TIMEOUT 300 Per-directory processing timeout, in seconds.
SCANNER_VALIDATE_MAGIC_BYTES true Verify file magic bytes match the claimed MIME type.
SCANNER_ALLOW_MIME_MISMATCH false If true, accept files whose MIME type differs from their extension.
SCANNER_SANITIZE_METADATA true Neutralize unsafe metadata fields during import.
SCANNER_MAX_TITLE_LENGTH 255 Maximum title length retained from metadata.
SCANNER_MAX_ARTIST_LENGTH 255 Maximum artist name length.
SCANNER_MAX_ALBUM_LENGTH 255 Maximum album name length.
SCANNER_MAX_GENRE_LENGTH 100 Maximum genre length.
SCANNER_MAX_COMMENT_LENGTH 1000 Maximum comment length.
SCANNER_MAX_LYRICS_LENGTH 50000 Maximum lyrics text length.

Local Network Discovery

Variable Default Description
DISCOVERY_ENABLED true Enable LAN server discovery, so clients find the server without a manual URL.
DISCOVERY_PORT 41234 UDP port used for discovery broadcasts.
DISCOVERY_SERVER_NAME Bånder Server name advertised to clients.

Media Storage & Tools

Variable Default Description
MEDIA_STORAGE_PATH /storage/media Root directory for original media files.
CONVERT_STORAGE_PATH /storage/conversions Root directory for transcoded/converted output.
FFMPEG_PATH /usr/local/bin/ffmpeg Path to the FFmpeg binary.
FFPROBE_PATH /usr/local/bin/ffprobe Path to the FFprobe binary.

Stream Authentication

Variable Default Description
STREAM_URL_HMAC_SECRET change-me-... Secret used to HMAC-sign streaming URLs. Set a random 64-character value in production.

HTTP

API routes accept cross-origin requests only from the origin in APP_URL. Four paths allow any origin: /.well-known/, which serves the public JWKS keys and the OAuth authorization server metadata; POST /api/oauth/token and POST /api/oauth/device/authorize, which third-party and device clients call with a DPoP proof rather than cookies; and /api/discovery/, whose endpoints still require authentication. The authorization endpoint /api/oauth/authorize, token revocation, and device verification and approval follow the APP_URL rule, because only the web app's consent and device pages call them for the signed-in user. Third-party clients never call /api/oauth/authorize themselves: they send the user's browser to the consent page. The policy is set in config/packages/nelmio_cors.yaml; no environment variable changes it.

Job Monitoring

Variable Default Description
JOB_MONITOR_MAX_PAYLOAD_SIZE 20000000 Maximum payload size, in bytes, stored per job monitor entry.

Server Settings

A server setting is a named value in the system_settings table that an administrator can change while Baander runs. The part of Baander that uses a setting also defines it: its key, its value type, the values it allows and its default. A setting nobody has changed has no row and takes its default. Baander reads a setting each time it acts on it, so a change applies to the next request, message or scheduled run without a restart, including in workers that are already running.

Changing a setting

The admin panel, the CLI and the API change settings through the same validation:

  • Admin panel. Admin → Settings (/admin/settings) lists every server setting by group, with a switch, select or number input and a Reset button for each. A setting the server does not act on yet carries a Not yet enforced badge. Super administrators can change settings; other administrators see the page read-only, and the admin sidebar links to it only for super administrators.
  • CLI. app:settings:list, get, set and reset.
  • API. The endpoints below.
Method Path Role Purpose
GET /api/admin/settings ROLE_ADMIN Every server setting with its current value, its stored value and whether the stored value is still allowed
GET /api/admin/settings/definitions ROLE_ADMIN The definition of every setting, server and per-user: type, allowed values with labels, default, group and whether it is enforced
PATCH /api/admin/settings ROLE_SUPER_ADMIN Change settings; the body is {"settings": {"<key>": <value>, …}}
DELETE /api/admin/settings/{key} ROLE_SUPER_ADMIN Reset a setting to its default

PATCH checks every key before it writes any. An unknown key or a value its setting does not allow rejects the whole request with 422 and a message for each failing key, and nothing is saved. A malformed body gets 400. DELETE on an unknown key gets 404.

A stored value can stop being allowed, for example when a setting's allowed values change in a new release. Baander then uses the default; the API reports the stored value with storedValueValid: false, and the CLI marks it (invalid). Setting or resetting the setting replaces it.

Settings reference

Key Default What it controls
admin.can_view_users true Whether administrators who are not super administrators may list users. See Who can manage users.
admin.can_create_users false Whether those administrators may create users. They can create only users with the User role.
i18n.default_language en The language of emails to users who have not chosen one: en (English), da (Dansk) or th (ไทย). See Email language.
lyrics.auto_fetch false Fetch lyrics from LRCLIB for each new track a scan adds that has no .lrc file beside it. See Library Management.
metadata.auto_sync false Sync each new album a scan adds from the external metadata services. See Library Management.
notifications.admin_alerts true Alert administrators when a component's health check reads unhealthy. The web server runs the checks every 60 seconds. See Health alerts.
notifications.push_enabled true Deliver browser push notifications. See Notifications.
recommendations.auto_generate false Let the daily Generate recommendations job generate. See Scheduled recommendations.
transcode.enabled false Allow on-the-fly audio transcoding. See Audio transcoding.
transcode.max_bitrate 320 The highest bitrate of a transcoded audio stream, in kbps: 128, 192, 256 or 320. It is also the bitrate used when a client asks for none.

Every setting in the table is enforced. Four features arrived with server settings and start switched off: audio transcoding, metadata sync for new albums, lyrics fetch for new tracks, and scheduled recommendations. Turn on the ones you want.

Scheduled recommendations

A fresh install has an active scheduled job, Generate recommendations, that runs every day at 04:00 UTC in incremental mode, which recomputes recommendations for songs updated in the last seven days. Each run reads recommendations.auto_generate when it fires. While the setting is off, the run generates nothing, logs that it was skipped, and records skipped: recommendations.auto_generate is off as the job's last result.

The setting governs only that job. app:recommendation:generate, the admin action and schedules an administrator creates always generate.

You can change the job's schedule, pause it, or switch it to full mode in the scheduler admin. A full run loads every song and the listening history of every user, and it runs inside the scheduler worker, whose memory is limited (the worker deployment manifest requires a reservation of at least 320 MiB for it). On a large library it may run out of memory or time, which is why the seeded job is incremental.

Email language

Baander writes emails in English, Danish or Thai. It picks the language of each email when it sends it:

  1. the user's own choice, while Baander still offers that language;
  2. otherwise the server default, i18n.default_language, while Baander still offers it;
  3. otherwise English.

Users choose under Language in the Account section of Settings. The first option, for example Server default (Dansk), names the current server default; choosing it removes the user's choice, so the user follows the default when an administrator changes it later. Administrators set a user's language in the admin user dialog, and operators with app:user:setting; see User settings.

At registration Baander reads the browser's Accept-Language header and takes the supported language the browser ranks highest. It ignores * and entries with q=0, and maps a regional tag such as da-DK to da. It stores that language as the user's choice only when it differs from the server default: most browsers ask for English, and storing it would tie those users to English after an administrator changes the default. Accounts created by an operator or administrator store no language and follow the server default until one is set for them.

The same choice sets the language of the messages the API returns, such as errors. A signed-in user with a saved choice gets messages in that language. Without a choice, and for requests that are not signed in, the API follows the request's Accept-Language header, then falls back to English; the server default applies to emails only.

The language applies to these emails:

  • Password reset and email verification. Baander looks up the language when it sends the email: after the HTTP response, or at once when the email comes from a console command. If the lookup fails, the email goes out in English and the failure is logged without the address or the link.
  • Notification emails. The worker looks up the language when it sends each email, so a change made after the notification was queued, such as a new server default, applies to it.

In-app notifications, push notifications and webhook payloads are always in English.

Each language must have a translation of every authentication and notification message before Baander can offer it. The Shared kernel guide describes how a language is added.

User settings

A user setting is a per-user value with the same kind of definition as a server setting. The user_settings table holds a row only for an explicit choice; a user without one gets the setting's default, or, for a setting that follows a server setting, that server setting's current value. language is currently the only user setting, and it follows i18n.default_language.

Signed-in users read and change their own settings through these endpoints:

Method Path Purpose
GET /api/user/settings Every user setting with the user's choice, the value Baander uses, the value after a reset, and where the value comes from
PUT /api/user/settings/{key} Store a choice; the body is {"value": <value>}
DELETE /api/user/settings/{key} Remove the choice, so the setting follows its default

An unknown key gets 404, a setting the user may not change gets 403, and an invalid value gets 422 and stores nothing. Users never read server settings directly; the value after a reset is how the Language control learns the current server default. A stored choice that is no longer allowed is presented to the user as no choice.

Administrators read and change a user's settings through the admin API and the CLI; see User settings.

Protect configuration

  • Keep deployment credentials out of Git and image build contexts. The source installation uses .env.dev.local; the worker credentials file has its own ownership and mode requirements in the worker deployment reference.
  • All env vars in .env can be overridden by setting real environment variables in your container runtime (Docker Compose, Kubernetes, etc.).
  • For each deployment, generate a unique APP_SECRET with php -r 'echo bin2hex(random_bytes(32));'.
  • OAuth keys should be at least 2048-bit RSA. Generate with openssl genrsa -out private.key 2048 and openssl rsa -in private.key -pubout > public.key.
  • Rate limiting requires Redis. Unavailable state stops protected work with 503; logout and revocation retain the narrow availability exceptions described above.