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:
- github actions runs
.github/workflows/deploy-staging.yml - runs
alembic upgrade headviarelease_command - backend available at
https://api-stg.plyr.fm(custom domain) andhttps://relay-api-staging.fly.dev(fly.dev domain)
frontend:
- cloudflare pages project
plyr-fm-stgtracksmainbranch - 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:
- github actions runs
.github/workflows/deploy-prod.yml - runs
alembic upgrade headviarelease_command - backend available at
https://api.plyr.fm
frontend:
- release script merges
main→production-febranch - cloudflare pages production environment tracks
production-febranch - uses production environment with
PUBLIC_API_URL=https://api.plyr.fm - available at
https://plyr.fm
creating a release:
# after validating changes in staging:
just release
this will:
- create timestamped github tag (triggers backend deploy)
- 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, userediss://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
- development:
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.jsonandhttps://api.plyr.fm/auth/callback - staging:
https://api-stg.plyr.fm/oauth-client-metadata.jsonandhttps://api-stg.plyr.fm/auth/callback-OAUTH_ENCRYPTION_KEY→ unique per environment
- production:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY→ r2 credentialsLOGFIRE_WRITE_TOKEN,LOGFIRE_ENVIRONMENT→ observability config
database migrations#
migrations run automatically on deploy via fly.io release_command.
both environments:
- use
alembic upgrade headto run migrations - migrations run before deployment completes
- alembic tracks applied migrations via
alembic_versiontable
rollback strategy#
if a migration fails:
- staging: fix and push to main
- production:
- revert via alembic:
uv run alembic downgrade -1 - or restore database from neon backup
- revert via alembic:
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 toplyr.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 projectplyr-fm-stg - backend:
api-stg.plyr.fm→ fly.io apprelay-api-staging - same eTLD+1 allows HttpOnly cookies with
Domain=.plyr.fm
production:
- frontend:
plyr.fm→ cloudflare pages projectplyr-fm - backend:
api.plyr.fm→ fly.io apprelay-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 authenticationapi-stg.plyr.fm(backend API) - requires GitHub authentication
public bypass paths (no authentication required):
api-stg.plyr.fm/health- uptime monitoringapi-stg.plyr.fm/docs- API documentationstg.plyr.fm/manifest.webmanifest- PWA manifeststg.plyr.fm/icons/*- PWA icons
how it works#
- DNS proxy: both
stg.plyr.fmandapi-stg.plyr.fmare proxied through Cloudflare (orange cloud) - access policies: GitHub OAuth or one-time PIN authentication required for all paths except bypassed endpoints
- shared authentication: both frontend and API share the same eTLD+1 (
plyr.fm), allowing theCF_Authorizationcookie to work across both domains - 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:
- verify DNS records are proxied (orange cloud) in Cloudflare DNS
- check application ordering in Cloudflare Access (specific paths before wildcards)
- verify policy action is "Allow" with authentication rules (not "Bypass" with "Everyone")
- clear browser cache or use incognito mode to bypass cached responses
- wait 1-2 minutes for Access policy changes to propagate
if legitimate requests are blocked:
- check if path needs a bypass rule (e.g.,
/health,/docs) - verify bypass applications are ordered above the main application
- ensure bypass policy uses "Bypass" action with "Everyone" selector