Production deploy for musicshare.binarygnome.com

- docker-compose.prod.yml overlay: Caddy with automatic HTTPS on one
  origin (web at /, API at /api incl. websocket), internal-only Postgres/
  Redis/API/web, secure cookies, private-network servers blocked, nightly
  pg_dump backups kept 14 days
- deploy/export-db.sh and import-db.sh to carry a database to the server
  (same TOKEN_ENCRYPTION_KEY required for stored connections)
- Base compose no longer requires apps/api/.env
- README: deployment, Spotify via scrobblers, SMTP settings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-05 19:16:40 -04:00
co-authored by Claude Opus 5.5
parent 3568b71e90
commit 7f46ba2d1b
8 changed files with 194 additions and 5 deletions
+4
View File
@@ -6,3 +6,7 @@ dist
.turbo
*.tsbuildinfo
next-env.d.ts
# Production secrets and database backups
deploy/production.env
backups/
+16 -4
View File
@@ -23,6 +23,18 @@ docker compose up -d --build # Postgres, Redis, migrations, API, wor
Open http://localhost:3737. Logs: `docker compose logs -f api worker`. Stop: `docker compose down` (data is kept in volumes).
Migrations run automatically before the API and worker start. A Jellyfin/Plex server on your machine is reachable from the containers as `host.docker.internal`.
## Deploying (musicshare.binarygnome.com)
Everything is served from one origin: the web app at `/`, the API at `/api` (Caddy strips the prefix), including the `/api/ws` websocket. Caddy obtains and renews HTTPS certificates automatically.
1. **DNS:** an `A` record (and `AAAA` if the server has IPv6) for `musicshare.binarygnome.com` pointing at the server. Ports 80 and 443 must reach it.
2. **Config:** `cp deploy/production.env.example deploy/production.env` and fill it in (`openssl rand -base64 32` for the password and encryption key).
3. **Start:** `docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file deploy/production.env up -d --build`
4. **Spotify (optional):** app credentials only enable exact Spotify links; no redirect URI is needed.
5. **Mobile builds:** set `EXPO_PUBLIC_API_URL=https://musicshare.binarygnome.com/api` and `EXPO_PUBLIC_WEB_URL=https://musicshare.binarygnome.com`.
In production only Caddy is exposed; Postgres, Redis, the API and the web app stay on the internal Docker network. A `backup` service writes a gzipped `pg_dump` to `./backups` nightly and keeps 14 days. Copy that folder off the server regularly. The website is installable as a PWA once it's on HTTPS.
## Running for development (hot reload)
Requires Node 22.13+ and Docker.
@@ -40,9 +52,9 @@ Optional keys in `apps/api/.env`:
| Key | Enables |
|---|---|
| `SPOTIFY_CLIENT_ID/SECRET` | Spotify connections + exact Spotify links via ISRC. Add redirect URI `http://127.0.0.1:4000/connections/spotify/callback` in the Spotify dashboard (Spotify rejects `localhost`). |
| `SPOTIFY_CLIENT_ID/SECRET` | Optional: exact "Open in Spotify" links via catalog search (app-level credentials; no user sign-in). Without it, Spotify links come from song.link or a search. |
| `LASTFM_API_KEY` | Last.fm connections (covers YouTube Music, Tidal, Deezer, SoundCloud… via scrobbling) |
| `LASTFM_SHARED_SECRET` | One-tap "Sign in with Last.fm" (instead of typing a username). Also powers "Spotify via Last.fm", the route that works for every Spotify user while direct Spotify sign-in is capped. |
| `LASTFM_SHARED_SECRET` | One-tap "Sign in with Last.fm" (instead of typing a username). Also the main route for Spotify: users link Spotify to Last.fm on Last.fm's site. |
| `APPLE_TEAM_ID`, `APPLE_MUSICKIT_KEY_ID`, `APPLE_MUSICKIT_PRIVATE_KEY` | Apple Music. Create a key with MusicKit enabled under Apple Developer → Keys; paste the `.p8` contents with newlines as `\n`. |
| `ODESLI_API_KEY` | Direct YouTube Music + TIDAL links via song.link. Without it, those fall back to search links. |
| `PLEX_CLIENT_ID` | Identifies the app in Plex's sign-in flow. Pick a unique value before launch and never change it. |
@@ -61,7 +73,7 @@ npm run mobile # starts Metro; scan the QR code with a dev build or Expo
- The app talks to the API on the same host as Metro (your LAN IP, port 4000), so phone and computer must be on the same network. Set `EXPO_PUBLIC_API_URL` for other environments.
- The app includes custom native code (the Android now-playing reader), so it needs a **development build**, not Expo Go: `npm run android -w @musicshare/mobile` (needs the Android SDK and JDK 17) or `npx eas-cli@latest build --profile development`. In Expo Go the reader is simply hidden.
- `android/` and `ios/` are generated by `expo prebuild` and gitignored; configure native settings in `app.json` and `modules/`.
- **Spotify/Plex sign-in from a phone:** the OAuth callback goes to `API_PUBLIC_URL`, which defaults to `127.0.0.1` — that's the phone itself. Expose the API with a tunnel (`cloudflared tunnel --url http://localhost:4000`) and set `API_PUBLIC_URL` to the https URL (and add it as a redirect URI in the Spotify dashboard).
- **Plex/Last.fm sign-in from a phone:** the sign-in callback goes to `API_PUBLIC_URL`, so set it to an address the phone can reach (your LAN IP, a tunnel, or the production domain).
- Bundle IDs are `tech.chrisedwards.musicshare` (in `apps/mobile/app.json`); change before your first store build, they're permanent after.
### Tests
@@ -111,7 +123,7 @@ Links are https universal links, so on phones they open the native app. Missing
| Service | Status | Notes |
|---|---|---|
| Spotify | ✅ | **Development mode now caps an app at 5 allow-listed users** (Feb 2026). Accounts beyond that are sent back with a "limited during beta" message offering Spotify via Last.fm. Since May 2025, extended quota is only granted to registered businesses with an established user base, so plan for this before launch. Last.fm is the fallback for Spotify users beyond the cap. |
| Spotify | ✅ | **No direct sign-in** (Spotify caps new apps at 5 accounts). Users link Spotify to **Last.fm** or **ListenBrainz** (Spotify's own integrations) and we read it from there; on Android the phone reader shares Spotify automatically. Guided in onboarding and Settings. |
| Last.fm | ✅ | Covers almost any service via scrobbling |
| ListenBrainz | ✅ | |
| Plex | ✅ | Plex sign-in (PIN flow), server discovery via plex.tv, polls `/status/sessions` on each server (remote → relay → LAN). Optional Plex Pass webhook for instant updates. |
+24
View File
@@ -0,0 +1,24 @@
# One public origin for everything. Caddy fetches and renews the HTTPS certificate itself.
# https://musicshare.binarygnome.com/ -> web app
# https://musicshare.binarygnome.com/api/... -> API (prefix stripped; includes the /api/ws websocket)
{$DOMAIN} {
encode zstd gzip
handle_path /api/* {
reverse_proxy api:4000
}
handle {
reverse_proxy web:3737
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
log {
output stdout
format console
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Save the local database to a file you can carry to the server.
# deploy/export-db.sh [output-file]
set -euo pipefail
cd "$(dirname "$0")/.."
out="${1:-musicshare-$(date +%Y%m%d-%H%M).dump}"
docker compose exec -T postgres pg_dump -U musicshare -d musicshare --format=custom --no-owner > "$out"
echo "Saved $(du -h "$out" | cut -f1) to $out"
echo
echo "IMPORTANT: connected services (Plex, Last.fm, Jellyfin...) are encrypted with TOKEN_ENCRYPTION_KEY."
echo "Put the SAME key in deploy/production.env on the server, or those connections must be redone:"
grep '^TOKEN_ENCRYPTION_KEY=' apps/api/.env 2>/dev/null | sed 's/=.*/=<copy the value from apps\/api\/.env>/' || true
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Restore a dump from export-db.sh on the server, replacing what's there.
# deploy/import-db.sh <dump-file>
set -euo pipefail
cd "$(dirname "$0")/.."
file="${1:?usage: deploy/import-db.sh <dump-file>}"
compose=(docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file deploy/production.env)
echo "This REPLACES the server's database with $file."
read -r -p "Type 'replace' to continue: " answer
[ "$answer" = "replace" ] || { echo "Aborted."; exit 1; }
"${compose[@]}" stop api worker web
"${compose[@]}" up -d postgres
until "${compose[@]}" exec -T postgres pg_isready -U musicshare -d musicshare >/dev/null 2>&1; do sleep 1; done
"${compose[@]}" exec -T postgres pg_restore -U musicshare -d musicshare --clean --if-exists --no-owner < "$file"
# Apply any migrations newer than the dump, then bring everything back.
"${compose[@]}" run --rm migrate
"${compose[@]}" up -d
echo "Restored. Everyone needs to sign in again (sessions are per-domain), and Plex/Jellyfin webhook URLs now use the new domain."
+28
View File
@@ -0,0 +1,28 @@
# Copy to deploy/production.env on the server and fill in. Never commit the real file.
DOMAIN=musicshare.binarygnome.com
# Generate each with: openssl rand -base64 32
POSTGRES_PASSWORD=
TOKEN_ENCRYPTION_KEY=
# Optional: exact "Open in Spotify" links (catalog search only; no user sign-in)
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
LASTFM_API_KEY=
LASTFM_SHARED_SECRET=
ODESLI_API_KEY=
PLEX_CLIENT_ID=musicshare
APPLE_TEAM_ID=
APPLE_MUSICKIT_KEY_ID=
APPLE_MUSICKIT_PRIVATE_KEY=
# Optional donation link shown on the website
DONATION_URL=
# Outgoing email (verification, password reset). SMTPS on 465.
SMTP_HOST=
SMTP_PORT=465
SMTP_USER=
SMTP_PASSWORD=
MAIL_FROM=musicshare <app@musicshare.binarygnome.com>
+86
View File
@@ -0,0 +1,86 @@
# Production overlay. On the server:
# cp deploy/production.env.example deploy/production.env # fill in secrets
# docker compose -f docker-compose.yml -f docker-compose.prod.yml --env-file deploy/production.env up -d --build
#
# Adds Caddy (HTTPS on 80/443) in front, keeps the database, Redis, API and web
# app off the public internet, and runs nightly database backups.
x-prod-api: &prod-api
env_file: deploy/production.env
environment:
NODE_ENV: production
DATABASE_URL: postgres://musicshare:${POSTGRES_PASSWORD:?set in production.env}@postgres:5432/musicshare
REDIS_URL: redis://redis:6379
WEB_URL: https://${DOMAIN}
API_PUBLIC_URL: https://${DOMAIN}/api
COOKIE_SECURE: 'true'
# Hosted: never let users point us at private networks.
ALLOW_PRIVATE_SERVER_URLS: 'false'
services:
postgres:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in production.env}
ports: !reset []
redis:
ports: !reset []
migrate:
<<: *prod-api
api:
<<: *prod-api
ports: !reset []
worker:
<<: *prod-api
web:
build:
args:
NEXT_PUBLIC_API_URL: https://${DOMAIN}/api
NEXT_PUBLIC_DONATION_URL: ${DONATION_URL:-}
ports: !reset []
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
environment:
DOMAIN: ${DOMAIN:?set in production.env}
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
- api
# Nightly pg_dump into ./backups, keeping the last 14 days.
backup:
image: postgres:17
restart: unless-stopped
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:?set in production.env}
volumes:
- ./backups:/backups
entrypoint: ["/bin/sh", "-c"]
command:
- |
while true; do
f=/backups/musicshare-$$(date +%Y%m%d-%H%M).sql.gz
pg_dump -h postgres -U musicshare musicshare | gzip > "$$f" && echo "backup ok: $$f" || echo "backup FAILED"
find /backups -name 'musicshare-*.sql.gz' -mtime +14 -delete
sleep 86400
done
depends_on:
postgres:
condition: service_healthy
volumes:
caddy_data:
caddy_config:
+4 -1
View File
@@ -6,7 +6,10 @@ x-api: &api
context: .
dockerfile: apps/api/Dockerfile
image: musicshare-api:local
env_file: apps/api/.env
# Local development config; on a server, docker-compose.prod.yml supplies deploy/production.env instead.
env_file:
- path: apps/api/.env
required: false
environment:
# Inside the compose network, services reach each other by name.
DATABASE_URL: postgres://musicshare:musicshare@postgres:5432/musicshare