Deploying the community server on Railway¶
The community-hosted spotDL instance runs in hosted mode on
Railway, fronted by Cloudflare on the spotdl.dev zone
(spotdl.dev for the site, api.spotdl.dev for the API). The Railway project,
domain, managed Postgres, and managed Redis are already provisioned; this page is
the config-as-code contract plus the operator runbook.
The image Railway builds is the same deploy/Dockerfile self-hosters pull
from GHCR — byte-identical build, same tag (spec §9). Nothing about hosting is a
private fork.
Config-as-code: deploy/railway.toml¶
Build and deploy behavior is pinned in deploy/railway.toml (in the repository):
[build]
builder = "DOCKERFILE"
dockerfilePath = "deploy/Dockerfile"
watchPatterns = ["apps/server/**", "apps/web/**", "packages/**", "deploy/Dockerfile"]
[deploy]
startCommand = "/app/docker-entrypoint.sh" # migrations then uvicorn --factory
healthcheckPath = "/api/v1/health"
healthcheckTimeout = 100
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3
numReplicas = 1
The startCommand is the same entrypoint the container ships with: it runs
upgrade_to_head (the programmatic, idempotent Alembic upgrade — never raw
alembic) and then launches uvicorn --factory spotdl_server.app:create_app.
Migrate-on-boot ⟷ numReplicas = 1 (READ BEFORE SCALING)¶
Migrations run on boot, inside the entrypoint (CONTRACT B). That is only
race-free with a single instance. numReplicas = 1 is therefore a hard
safety coupling, not a capacity choice:
- Self-host (one compose container) is single-instance by construction.
- Railway is single-instance only because
railway.tomlpinsnumReplicas = 1.
Two replicas booting concurrently would race Alembic against the same database.
Do not raise numReplicas above 1 until you have decoupled migrations first,
by one of:
- Postgres advisory lock — wrap the
upgrade_to_headcall so it acquires apg_advisory_lockbefore migrating and releases it after; late replicas block on the lock, observe head, and proceed. Then it is safe to scale. - Release-phase / pre-deploy migration — move
upgrade_to_headout of the entrypoint into a separate one-off step (Railway pre-deploy command or a dedicated release job) that runs once per deploy, and strip the migration line fromdocker-entrypoint.shso the app containers only serve.
This coupling is called out in three places that must stay in sync: the
docker-entrypoint.sh comment, the numReplicas comment in railway.toml, and
this document.
Service variables (set in the Railway dashboard — NEVER committed)¶
Set these on the service in the Railway dashboard. Reference variables
(${{Postgres.DATABASE_URL}}, ${{Redis.REDIS_URL}}) are resolved by Railway
from the linked plugins at deploy time.
| Variable | Value | Notes |
|---|---|---|
SPOTDL_MODE |
hosted |
Full community surface; no local download engine. |
SPOTDL_DATABASE_URL |
${{Postgres.DATABASE_URL}} |
See the +asyncpg driver note below. |
SPOTDL_REDIS_URL |
${{Redis.REDIS_URL}} |
Presence enables the RedisRateLimiter (Plan 6). |
SPOTDL_RATE_LIMIT_ENABLED |
true |
Belt-and-suspenders; hosted mode defaults it on anyway. |
SPOTDL_AUTH_SECRET_KEY |
<openssl rand -hex 32> |
Token signing key; server fails fast without it. |
SPOTDL_SPOTIFY_CLIENT_ID |
<operator> |
Operator's Spotify app credential. |
SPOTDL_SPOTIFY_CLIENT_SECRET |
<operator> |
Operator's Spotify app credential. |
SPOTDL_SPOTIFY_PREFER_ANONYMOUS |
false |
Required on datacenter IPs. The provider defaults to the anonymous web-player token (right for residential self-hosts), but Spotify persistently 429s anonymous API calls from cloud-provider IPs — and because the anon token fetch still succeeds, the client-credentials fallback never engages. false makes the operator credentials primary; anonymous becomes the fallback. |
SPOTDL_SENTRY_DSN |
<optional> |
Error reporting; blank disables. |
SPOTDL_LOG_LEVEL |
INFO |
Structured JSON log level. |
SPOTDL_CLIENT_IP_HEADER |
cf-connecting-ip |
Trust Cloudflare's client-IP header for rate limiting + logs. |
+asyncpg driver mapping¶
Railway's ${{Postgres.DATABASE_URL}} is a plain postgresql://… URL, but the
server needs an async driver. Settings.effective_database_url() asserts the
URL names +asyncpg (or +aiosqlite). Map the reference variable to the async
driver form when you set SPOTDL_DATABASE_URL, e.g.:
Either edit the value to insert +asyncpg after postgresql, or set
SPOTDL_DATABASE_URL to ${{Postgres.DATABASE_URL}} and confirm the resolved
value carries +asyncpg (Railway lets you template it). A postgresql:// URL
without an async driver will fail the startup assertion by design — no silent
sync fallback.
Redis rate limiting¶
The rate limiter's backend is selected purely by the presence of
SPOTDL_REDIS_URL: build_rate_limiter() returns a RedisRateLimiter when the
URL is set and the redis package is importable, otherwise the in-process
limiter. The hosted image bakes the redis extra in at build time
(uv sync --frozen --no-dev --no-editable --package spotdl-server --extra redis
in deploy/Dockerfile, CONTRACT B), so Redis "just works" once the reference
variable is set. The same image is what self-hosters pull; redis sits unused
there unless they set SPOTDL_REDIS_URL too.
Staging → production promotion path¶
Deployments are not auto-promoted. The path (documented, run by the operator):
- Deploy to the staging Railway environment first — it has its own Postgres and Redis plugins, isolated from production data.
- Smoke it:
curl https://<staging-host>/api/v1/healthreturns{"status":"ok"}andcurl https://<staging-host>/metricsexports thespotdl_*metrics. - Promote the same image tag to the production environment (Railway environment promotion; no rebuild — the artifact that passed staging is what ships).
A local dry-run of the config is railway up --detach (builds and deploys the
current tree). It is not run in CI — CI validates that railway.toml parses;
the actual railway up is an operator action against the live project.
Cloudflare in front (production)¶
Cloudflare sits in front of the production domain (spec §9):
- Proxied DNS — the
spotdl.dev/api.spotdl.devrecords are orange-clouded (proxied) so all traffic passes through Cloudflare. - TLS is terminated at Cloudflare → Railway. Cloudflare holds the edge
certificate; the origin connection to Railway is TLS as well. There is no
Caddy in the hosted path — Caddy (
deploy/Caddyfile.example) is the self-host alternative for operators who terminate TLS themselves. - Cache bypass for the API and metrics — add cache rules that bypass cache
for
/api/*and/metricsso dynamic responses and the Prometheus scrape are never served stale from the edge. The SPA static assets can stay cached. - WAF / rate rules complement the app limiter — Cloudflare WAF and rate rules
are a coarse outer layer (bot/abuse mitigation, per-IP ceilings) that sits
in front of, not instead of, the app's Redis-backed limiter. Because
SPOTDL_CLIENT_IP_HEADER=cf-connecting-ip, the app trusts Cloudflare's client-IP header for its own per-identity limiting and structured logs.
Go-live gate (GA)¶
The community server must be live in hosted mode with both Postgres and Redis
before GA (spec §12.8). This gate is referenced by the GA runbook (Plan 11
Task 12) and by the CLI default-endpoint work (Task 6, which points the CLI's
DEFAULT_API_URL at https://api.spotdl.dev). GA cannot be declared until a
health check and a /metrics scrape against the production api.spotdl.dev host
both pass.