Synchronized music playback with your friends. Replaces the Discord workflow of passing links to bots β a shared room with a vote-ordered queue, in-app search, and a Discord bot that joins voice chat to play it back.
- Vote-ordered queue β the highest-voted unplayed track plays next; ties break by recency.
- YouTube & SoundCloud β single tracks, YouTube playlists, SoundCloud sets, and Mixcloud mixes.
- In-app search β find tracks on YouTube or SoundCloud without leaving the room.
- Discord OAuth β JWT sessions in an http-only cookie, revocable on logout via token versioning.
- Discord bot β slash commands to play and control playback from a voice channel.
- Vote-to-skip β presence-based threshold; the song's owner skips free.
- Ownership & moderation β creators delete their own rooms/songs; admins delete any.
- Admin panel β live room list with song, pending, and presence counts, plus
/metrics. - Hardened by default β rate limiting, input validation, hourly GC of stale rooms and old played songs, graceful shutdown.
- Runtime: Bun
- Backend: Hono (HTTP API + Discord gateway client)
- Frontend: React 19 + Vite 6, Tailwind CSS 4, React Router 7
- Database: SQLite (libSQL) + Drizzle ORM
- Audio: yt-dlp + ffmpeg + @discordjs/voice
- Bot: Discord.js
ββββββββββββββββββββββββββββ βββββββββββββββ
β Server (Hono + React) ββββββΆβ yt-dlp β
β API + SPA on :3001 β β (Audio) β
ββββββββββββββββββββββββββββ βββββββββββββββ
β
βΌ
βββββββββββββββ
β Discord β
β Bot β
βββββββββββββββ
- Bun
- yt-dlp on
$PATH(audio extraction) - A Discord application: bot token + OAuth2 client id/secret + redirect URI
- Docker (optional, for Compose-based deploy)
# Clone and install dependencies
git clone git@github.com:Nxssie/back2back.git
cd back2back
bun install
# Copy environment variables
cp .env.example .env
# Edit .env with your Discord credentials
# Run database migrations
bun db:generate
bun db:pushOption 1: Docker (recommended for production)
docker compose upOption 2: Local development
Install yt-dlp once:
pip install yt-dlpFrom the repo root, one command starts both packages:
bun devOr use the convenience script, which loads .env into the server and gives each
process its own process group for clean Ctrl+C shutdown:
./dev.shTo run them individually:
bun dev:server # http://localhost:3001
bun dev:web # http://localhost:5173- Frontend: http://localhost:5173
- Backend: http://localhost:3001
All /api/* routes except GET /api/rooms/:id and GET /api/rooms/:id/songs
require a valid b2b_token session cookie. Mutating routes return 401 when
unauthenticated and 429 when rate-limited.
GET /auth/discordβ Start the Discord OAuth flowGET /auth/discord/callbackβ OAuth callback; sets the session cookie and redirects toFRONTEND_URLGET /auth/logoutβ Revoke the session (bumps token version) and clear the cookieGET /api/auth/meβ Current user ({ user }or{ user: null })
GET /api/rooms/:idβ Room info, ownership, andcanDeletePOST /api/user/roomβ Set/clear the caller's current room{ roomId }(drives presence + skip threshold)DELETE /api/rooms/:idβ Delete a room and cascade its songs/votes (owner or admin)
GET /api/rooms/:id/songsβ Queue, the caller's votes, presence count, and the currently-streaming songPOST /api/rooms/:id/songsβ Add a track or playlist{ url }(YouTube, SoundCloud, Mixcloud, or Twitch)DELETE /api/rooms/:id/songs/:songIdβ Remove a song (adder or admin)POST /api/rooms/:id/songs/:songId/voteβ Cast a vote (one per user per song)POST /api/rooms/:id/skipβ Skip the streaming song (owner skips free; otherwise votes β₯ threshold)POST /api/rooms/:id/playlists/:playlistId/skipβ Skip the rest of a playlist (adder or admin)
POST /api/rooms/:id/connectβ Summon the bot into the caller's current voice channel
GET /api/search?q=&n=&source=youtube|soundcloudβ yt-dlp search
GET /metricsβ Operational metrics (admin only)GET /api/admin/roomsβ All rooms with song/presence counts (admin only)
GET /healthβ LivenessGET /readyβ Readiness (DB + Discord gateway)
Slash commands (the bot must be invited to the server). /play accepts a
YouTube, SoundCloud, Mixcloud, or Twitch URL:
/play <url>β Add a track to the queue and start playing/listenβ Join the caller's voice channel and start the queue/stopβ Stop playback and disconnect/skipβ Skip the current song/queueβ Show the current queue/resetβ Mark all songs as playable again/roomβ Show which room this server is currently playing from
Deployed as a Docker Compose resource in Coolify, with a Cloudflare Tunnel
terminating TLS at the edge (no public ports open on the host). Coolify's Traefik
only serves HTTP and routes by Host; it must not request a Let's Encrypt
certificate. The compose declares no Traefik labels and no custom networks on
purpose β Coolify generates the router and the per-stack network from the UI
domain. Do not re-add them.
git push # Coolify deploys from the repoIn Coolify: + New β Docker Compose, point it at this repo / docker-compose.yml.
The domain goes on the server service β it serves both the API and the
Vite-built frontend (no separate nginx container).
Coolify auto-detects the ${VAR} placeholders from the compose and lists them;
just fill in the values:
| Variable | Value | Notes |
|---|---|---|
DISCORD_TOKEN |
bot token | mark Is Secret? |
DISCORD_CLIENT_ID |
application id | |
DISCORD_CLIENT_SECRET |
client secret | mark Is Secret? |
DISCORD_REDIRECT_URI |
https://b2b.nxssie.dev/auth/discord/callback |
public origin, https |
FRONTEND_URL |
https://b2b.nxssie.dev |
public origin, no trailing slash |
JWT_SECRET |
openssl rand -hex 32 |
mark Is Secret?; server refuses to boot without a strong one |
ADMIN_DISCORD_IDS |
comma-separated ids | optional |
ROOM_TTL_HOURS |
24 |
optional |
- Coolify β
serverservice β Domains:http://b2b.nxssie.dev(http://, nothttps://). This stops Traefik from requesting a Let's Encrypt cert and from adding an httpβhttps redirect (which would loop behind Cloudflare). - Only the Domains field is
http://βFRONTEND_URLandDISCORD_REDIRECT_URIstayhttps://(that is the public scheme the browser sees). - Cloudflare Zero Trust β Networks β Tunnels β your tunnel β Public Hostname
for
b2b.nxssie.devβ ServiceHTTPβhttp://localhost:80(Traefik / coolify-proxy on the host; use the host LAN IP ifcloudflaredruns elsewhere). Leave the HTTP Host Header empty so the original host is preserved for Traefik. - Cloudflare β SSL/TLS β Full.
Discord Developer Portal β your app β OAuth2 β Redirects β add exactly
https://b2b.nxssie.dev/auth/discord/callback (https, no port, no trailing slash).
It must byte-match DISCORD_REDIRECT_URI.
curl -I https://b2b.nxssie.dev/ # 200, serves the SPA
curl -s https://b2b.nxssie.dev/api/auth/me # {"user":null}- 502 / 523 β the tunnel can't reach Traefik on
:80(check the Public Hostname service URL). - Redirect loop β the Domains field is still
https://(must behttp://).
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 1 core | 2 cores |
| RAM | 1 GB | 2 GB |
| Storage | 5 GB SSD | 10 GB SSD |
| Bandwidth | 500 GB/month | 1 TB/month |
The merged server container (API + SPA + Discord bot) runs under 768 MB in steady state. Add a swap file on RAM-constrained VPS to absorb transient yt-dlp/ffmpeg spikes:
sudo fallocate -l 1G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstabProviders that fit: Hetzner CX22 (2 vCPU, 4 GB, ~β¬6/month), Contabo VPS S (4 vCPU, 4 GB, ~β¬5/month).
- Real-time sync over WebSockets (frontend currently polls
/songs) - UI polish