configuration#

plyr.fm uses nested pydantic settings for configuration management, following a pattern similar to prefect.

settings structure#

settings are organized into logical sections:

from backend.config import settings

# application settings
settings.app.name                              # "plyr"
settings.app.port                              # 8001 (from PORT)
settings.app.debug                             # false
settings.app.broadcast_channel_prefix          # "plyr"
settings.app.canonical_host                    # "plyr.fm"
settings.app.canonical_url                     # computed: https://plyr.fm

# frontend settings
settings.frontend.url                          # from FRONTEND_URL
settings.frontend.cors_origin_regex            # from FRONTEND_CORS_ORIGIN_REGEX (optional)
settings.frontend.resolved_cors_origin_regex   # computed: defaults to relay-4i6.pages.dev pattern

# database settings
settings.database.url                          # from DATABASE_URL

# storage settings (cloudflare r2)
settings.storage.backend                       # from STORAGE_BACKEND
settings.storage.r2_bucket                     # from R2_BUCKET (public audio files)
settings.storage.r2_private_bucket             # from R2_PRIVATE_BUCKET (gated audio files)
settings.storage.r2_image_bucket               # from R2_IMAGE_BUCKET (image files)
settings.storage.r2_endpoint_url               # from R2_ENDPOINT_URL
settings.storage.r2_public_bucket_url          # from R2_PUBLIC_BUCKET_URL (audio files)
settings.storage.r2_public_image_bucket_url    # from R2_PUBLIC_IMAGE_BUCKET_URL (image files)
settings.storage.aws_access_key_id             # from AWS_ACCESS_KEY_ID
settings.storage.aws_secret_access_key         # from AWS_SECRET_ACCESS_KEY

# atproto settings
settings.atproto.pds_url                       # from ATPROTO_PDS_URL
settings.atproto.client_id                     # from ATPROTO_CLIENT_ID
settings.atproto.client_secret                 # from ATPROTO_CLIENT_SECRET
settings.atproto.redirect_uri                  # from ATPROTO_REDIRECT_URI
settings.atproto.app_namespace                 # from ATPROTO_APP_NAMESPACE
settings.atproto.old_app_namespace             # from ATPROTO_OLD_APP_NAMESPACE (optional)
settings.atproto.oauth_encryption_key          # from OAUTH_ENCRYPTION_KEY
settings.atproto.track_collection              # computed: "{namespace}.track"
settings.atproto.old_track_collection          # computed: "{old_namespace}.track" (if set)
settings.atproto.resolved_scope                # computed: "atproto repo:{collections}"

# observability settings (pydantic logfire)
settings.observability.enabled                 # from LOGFIRE_ENABLED
settings.observability.write_token             # from LOGFIRE_WRITE_TOKEN
settings.observability.environment             # from LOGFIRE_ENVIRONMENT
settings.observability.suppressed_loggers      # from LOGFIRE_SUPPRESSED_LOGGERS (default: {"docket"})

# notification settings
settings.notify.enabled                        # from NOTIFY_ENABLED
settings.notify.recipient_handle               # from NOTIFY_RECIPIENT_HANDLE
settings.notify.bot.handle                     # from NOTIFY_BOT_HANDLE
settings.notify.bot.password                   # from NOTIFY_BOT_PASSWORD

# background task settings (docket/redis)
settings.docket.name                           # "plyr" (queue namespace)
settings.docket.url                            # from DOCKET_URL (empty = disabled)
settings.docket.worker_concurrency             # 10 (concurrent tasks)

environment variables#

all settings can be configured via environment variables. the variable names match the flat structure used in .env:

required#

# database
DATABASE_URL=postgresql+psycopg://user:pass@host/db

# oauth (uses client metadata discovery - no registration required)
ATPROTO_CLIENT_ID=https://your-domain.com/oauth-client-metadata.json
ATPROTO_CLIENT_SECRET=<optional-client-secret>
ATPROTO_REDIRECT_URI=https://your-domain.com/auth/callback
OAUTH_ENCRYPTION_KEY=<base64-encoded-32-byte-key>

# storage
STORAGE_BACKEND=r2  # or "filesystem"
R2_BUCKET=your-audio-bucket
R2_PRIVATE_BUCKET=your-private-audio-bucket  # for supporter-gated content
R2_IMAGE_BUCKET=your-image-bucket
R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com
R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev  # for audio files
R2_PUBLIC_IMAGE_BUCKET_URL=https://pub-xxx.r2.dev  # for image files
AWS_ACCESS_KEY_ID=your-r2-access-key
AWS_SECRET_ACCESS_KEY=your-r2-secret

optional#

# app
PORT=8001
FRONTEND_URL=http://localhost:5173

# observability
LOGFIRE_ENABLED=true
LOGFIRE_WRITE_TOKEN=pylf_xxx
LOGFIRE_SUPPRESSED_LOGGERS=docket  # comma-separated, suppress noisy loggers

# notifications (bluesky DMs)
NOTIFY_ENABLED=true
NOTIFY_RECIPIENT_HANDLE=your.handle
NOTIFY_BOT_HANDLE=bot.handle
NOTIFY_BOT_PASSWORD=app-password

# background tasks (docket/redis)
DOCKET_URL=redis://localhost:6379  # or rediss:// for TLS
DOCKET_NAME=plyr                   # queue namespace (default: plyr)
DOCKET_WORKER_CONCURRENCY=10       # concurrent task limit (default: 10)

computed fields#

some settings are computed from other values:

settings.app.canonical_url#

automatically determines the protocol based on host:

  • localhost or 127.0.0.1http://
  • anything else → https://

can be overridden with canonical_url_override if needed.

settings.frontend.resolved_cors_origin_regex#

constructs the CORS origin regex pattern:

# default: allows localhost + relay-4i6.pages.dev (including preview deployments)
r"^(https://([a-z0-9]+\.)?relay-4i6\.pages\.dev|http://localhost:5173)$"

can be overridden with FRONTEND_CORS_ORIGIN_REGEX if needed.

settings.atproto.track_collection#

constructs the atproto collection name from the namespace:

f"{settings.atproto.app_namespace}.track"
# default: "fm.plyr.track"

settings.atproto.resolved_scope#

constructs the oauth scope from the collection(s):

# base scopes: our track collection + our like collection
scopes = [
    f"repo:{settings.atproto.track_collection}",
    f"repo:{settings.atproto.app_namespace}.like",
]

# if we have an old namespace, add old track collection too
if settings.atproto.old_app_namespace:
    scopes.append(f"repo:{settings.atproto.old_track_collection}")

return f"atproto {' '.join(scopes)}"
# default: "atproto repo:fm.plyr.track repo:fm.plyr.like"

can be overridden with ATPROTO_SCOPE_OVERRIDE if needed.

atproto namespace#

plyr.fm uses fm.plyr as the ATProto namespace:

ATPROTO_APP_NAMESPACE=fm.plyr  # default

this defines the collections:

  • track_collection"fm.plyr.track"
  • like_collection"fm.plyr.like" (implicit)
  • resolved_scope"atproto repo:fm.plyr.track repo:fm.plyr.like"

environment-specific namespaces#

each environment uses a separate namespace to prevent test data from polluting production collections:

development (local):

ATPROTO_APP_NAMESPACE=fm.plyr.dev
  • track_collection"fm.plyr.dev.track"
  • like_collection"fm.plyr.dev.like"
  • records written to dev-specific collections on user's PDS

staging:

ATPROTO_APP_NAMESPACE=fm.plyr.stg
  • track_collection"fm.plyr.stg.track"
  • like_collection"fm.plyr.stg.like"
  • records written to staging-specific collections on user's PDS

production:

ATPROTO_APP_NAMESPACE=fm.plyr
  • track_collection"fm.plyr.track"
  • like_collection"fm.plyr.like"
  • records written to production collections on user's PDS

this separation ensures that:

  • test tracks/likes created in dev/staging don't pollute production collections
  • OAuth scopes are environment-specific
  • database and ATProto records stay aligned within each environment

see docs/deployment/environments.md for more details on environment configuration.

namespace migration#

optionally supports migration from an old namespace:

ATPROTO_OLD_APP_NAMESPACE=app.relay  # optional, for migration

when set, OAuth scopes will include both old and new namespaces:

  • old_track_collection"app.relay.track"
  • resolved_scope"atproto repo:fm.plyr.track repo:fm.plyr.like repo:app.relay.track"

usage in code#

from backend.config import settings

# access nested settings
database_url = settings.database.url
r2_bucket = settings.storage.r2_bucket
track_collection = settings.atproto.track_collection

# computed properties work seamlessly
canonical_url = settings.app.canonical_url
oauth_scope = settings.atproto.resolved_scope

testing#

tests automatically override settings with local defaults via tests/__init__.py:

os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://localhost/relay")
os.environ.setdefault("LOGFIRE_ENABLED", "false")
os.environ.setdefault("NOTIFY_ENABLED", "false")

individual tests can override settings using pytest fixtures:

from backend.config import Settings

def test_something(monkeypatch):
    monkeypatch.setenv("PORT", "9100")
    monkeypatch.setenv("ATPROTO_APP_NAMESPACE", "com.example.test")

    settings = Settings()  # reload with new env vars
    assert settings.app.port == 9100
    assert settings.atproto.track_collection == "com.example.test.track"

migration from flat settings#

the refactor maintains backward compatibility with all existing environment variables:

old (flat) new (nested) env var
settings.port settings.app.port PORT
settings.database_url settings.database.url DATABASE_URL
settings.r2_bucket settings.storage.r2_bucket R2_BUCKET
settings.atproto_scope settings.atproto.resolved_scope (computed)

all code has been updated to use the nested structure.

design#

the settings design follows the prefect pattern:

  • each section extends BaseSettings subclass
  • sections are composed in the root Settings class
  • environment variables map directly to field names
  • computed fields derive values from other settings
  • type hints ensure correct types at runtime

see src/backend/config.py for implementation details.