environment separation strategy#

plyr.fm uses a simple three-tier deployment strategy: development → staging → production.

environments#

environment trigger backend URL database redis frontend storage
development local localhost:8001 plyr-dev (neon) localhost:6379 (docker) localhost:5173 audio-dev, images-dev (r2)
staging push to main api-stg.plyr.fm plyr-stg (neon) plyr-redis-stg (fly.io) stg.plyr.fm (main branch) audio-staging, images-staging (r2)
production github release api.plyr.fm plyr-prd (neon) plyr-redis (fly.io) plyr.fm (production-fe branch) audio-prod, images-prod (r2)

workflow#

local development#

# terminal 1: start redis
just dev-services

# terminal 2: start backend (with docket enabled)
DOCKET_URL=redis://localhost:6379 just backend run

# terminal 3: start frontend
just frontend run

# optional: start transcoder
just transcoder run

connects to plyr-dev neon database, local Redis, and uses fm.plyr.dev atproto namespace.

staging deployment (automatic)#

trigger: push to main branch

backend:

  1. github actions runs .github/workflows/deploy-staging.yml
  2. runs alembic upgrade head via release_command
  3. backend available at https://api-stg.plyr.fm (custom domain) and https://relay-api-staging.fly.dev (fly.dev domain)

frontend:

  • cloudflare pages project plyr-fm-stg tracks main branch
  • uses production environment with PUBLIC_API_URL=https://api-stg.plyr.fm
  • available at https://stg.plyr.fm (custom domain)

testing:

  • frontend: https://stg.plyr.fm
  • backend: https://api-stg.plyr.fm/docs
  • database: plyr-staging (neon)
  • storage: audio-staging, images-staging (r2)

production deployment (manual)#

trigger: run just release (creates github tag, merges main → production-fe)

backend:

  1. github actions runs .github/workflows/deploy-prod.yml
  2. runs alembic upgrade head via release_command
  3. backend available at https://api.plyr.fm

frontend:

  1. release script merges mainproduction-fe branch
  2. cloudflare pages production environment tracks production-fe branch
  3. uses production environment with PUBLIC_API_URL=https://api.plyr.fm
  4. available at https://plyr.fm

creating a release:

# after validating changes in staging:
just release

this will:

  1. create timestamped github tag (triggers backend deploy)
  2. merge main → production-fe (triggers frontend deploy)

testing:

  • frontend: https://plyr.fm
  • backend: https://api.plyr.fm/docs
  • database: plyr-prod (neon)
  • storage: audio-prod, images-prod (r2)

configuration files#

backend#

fly.staging.toml / fly.toml:

  • release_command: uv run alembic upgrade head (runs migrations before deploy)
  • environment variables configured via flyctl secrets set

frontend#

cloudflare pages (two separate projects):

plyr-fm (production):

  • framework: sveltekit
  • build command: cd frontend && bun run build
  • build output: frontend/build
  • production branch: production-fe
  • production environment: PUBLIC_API_URL=https://api.plyr.fm
  • custom domain: plyr.fm

plyr-fm-stg (staging):

  • framework: sveltekit
  • build command: cd frontend && bun run build
  • build output: frontend/build
  • production branch: main
  • production environment: PUBLIC_API_URL=https://api-stg.plyr.fm
  • custom domain: stg.plyr.fm

secrets management#

all secrets configured via flyctl secrets set. key environment variables:

  • DATABASE_URL → neon connection string (env-specific)
  • DOCKET_URL → redis URL for background tasks (env-specific, use rediss:// for TLS)
  • FRONTEND_URL → frontend URL for CORS (production: https://plyr.fm, staging: https://stg.plyr.fm)
  • ATPROTO_APP_NAMESPACE → atproto namespace (environment-specific, separates records by environment)
    • development: fm.plyr.dev (local .env)
    • staging: fm.plyr.stg
    • production: fm.plyr
  • ATPROTO_CLIENT_ID, ATPROTO_REDIRECT_URI → oauth config (env-specific, must use custom domains for cookie-based auth)
    • production: https://api.plyr.fm/oauth-client-metadata.json and https://api.plyr.fm/auth/callback
    • staging: https://api-stg.plyr.fm/oauth-client-metadata.json and https://api-stg.plyr.fm/auth/callback- OAUTH_ENCRYPTION_KEY → unique per environment
  • AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY → r2 credentials
  • LOGFIRE_WRITE_TOKEN, LOGFIRE_ENVIRONMENT → observability config

database migrations#

migrations run automatically on deploy via fly.io release_command.

both environments:

  • use alembic upgrade head to run migrations
  • migrations run before deployment completes
  • alembic tracks applied migrations via alembic_version table

rollback strategy#

if a migration fails:

  1. staging: fix and push to main
  2. production:
    • revert via alembic: uv run alembic downgrade -1
    • or restore database from neon backup

monitoring#

staging:

  • logfire: environment filter LOGFIRE_ENVIRONMENT=staging
  • backend logs: flyctl logs -a relay-api-staging

production:

  • logfire: environment filter LOGFIRE_ENVIRONMENT=production
  • backend logs: flyctl logs -a relay-api

costs#

current: ~$25-30/month

  • fly.io backend (production): ~$10/month (shared-cpu-1x, 256MB RAM)
  • fly.io backend (staging): ~$10/month (shared-cpu-1x, 256MB RAM)
  • fly.io transcoder: ~$0-5/month (auto-scales to zero when idle)
  • neon postgres: $5/month (starter plan)
  • cloudflare pages: free (frontend hosting)
  • cloudflare R2: ~$0.16/month (6 buckets across dev/staging/prod)

workflow summary#

  • merge PR to main: deploys staging backend + staging frontend to stg.plyr.fm
  • run just release: deploys production backend + production frontend to plyr.fm
  • database migrations: run automatically before deploy completes
  • rollback: revert github release or restore database from neon backup

custom domain architecture#

both environments use custom domains on the same eTLD+1 (plyr.fm) to enable secure cookie-based authentication:

staging:

  • frontend: stg.plyr.fm → cloudflare pages project plyr-fm-stg
  • backend: api-stg.plyr.fm → fly.io app relay-api-staging
  • same eTLD+1 allows HttpOnly cookies with Domain=.plyr.fm

production:

  • frontend: plyr.fm → cloudflare pages project plyr-fm
  • backend: api.plyr.fm → fly.io app relay-api
  • same eTLD+1 allows HttpOnly cookies with Domain=.plyr.fm

this architecture prevents XSS attacks by storing session tokens in HttpOnly cookies instead of localStorage.

cloudflare access (staging only)#

staging environments are protected with Cloudflare Access to prevent public access while maintaining accessibility for authorized developers.

configuration#

protected domains:

  • stg.plyr.fm (frontend) - requires GitHub authentication
  • api-stg.plyr.fm (backend API) - requires GitHub authentication

public bypass paths (no authentication required):

  • api-stg.plyr.fm/health - uptime monitoring
  • api-stg.plyr.fm/docs - API documentation
  • stg.plyr.fm/manifest.webmanifest - PWA manifest
  • stg.plyr.fm/icons/* - PWA icons

how it works#

  1. DNS proxy: both stg.plyr.fm and api-stg.plyr.fm are proxied through Cloudflare (orange cloud)
  2. access policies: GitHub OAuth or one-time PIN authentication required for all paths except bypassed endpoints
  3. shared authentication: both frontend and API share the same eTLD+1 (plyr.fm), allowing the CF_Authorization cookie to work across both domains
  4. application ordering: bypass applications for specific paths (/health, /docs, etc.) are ordered above the wildcard application to take precedence

requirements for proxied setup#

  • Cloudflare SSL/TLS mode: set to "Full" (encrypts browser → Cloudflare → origin)
  • Fly.io certificates: both domains must have valid certificates on Fly.io (flyctl certs list)
  • DNS records: both domains must be set to "Proxied" (orange cloud) in Cloudflare DNS

debugging access issues#

if staging is still publicly accessible:

  1. verify DNS records are proxied (orange cloud) in Cloudflare DNS
  2. check application ordering in Cloudflare Access (specific paths before wildcards)
  3. verify policy action is "Allow" with authentication rules (not "Bypass" with "Everyone")
  4. clear browser cache or use incognito mode to bypass cached responses
  5. wait 1-2 minutes for Access policy changes to propagate

if legitimate requests are blocked:

  1. check if path needs a bypass rule (e.g., /health, /docs)
  2. verify bypass applications are ordered above the main application
  3. ensure bypass policy uses "Bypass" action with "Everyone" selector