Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 35 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,16 @@ and a Discord bot that joins voice chat to play it back.
## Architecture

```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
Frontend │────▶│ Server │────▶│ yt-dlp │
(React) │ │ (Hono) │ │ (Audio) │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐
│ Discord │
│ Bot │
└─────────────┘
┌──────────────────────────┐ ┌─────────────┐
│ Server (Hono + React) │────▶│ yt-dlp │
API + SPA on :3001 │ │ (Audio) │
└──────────────────────────┘ └─────────────┘
┌─────────────┐
│ Discord │
│ Bot │
└─────────────┘
```

## Development
Expand Down Expand Up @@ -167,8 +167,8 @@ git push # Coolify deploys from the repo
```

In Coolify: **+ New → Docker Compose**, point it at this repo / `docker-compose.yml`.
The domain goes on the **`web`** service only — `server` is internal (reached
through nginx in the web image, never published to the host).
The domain goes on the **`server`** service — it serves both the API and the
Vite-built frontend (no separate nginx container).

### 2. Environment variables

Expand All @@ -188,7 +188,7 @@ just fill in the values:

### 3. Ingress: Coolify domain as HTTP, TLS via Cloudflare

- Coolify → `web` service → **Domains**: `http://b2b.nxssie.dev` (**`http://`**, not
- Coolify → `server` service → **Domains**: `http://b2b.nxssie.dev` (**`http://`**, not
`https://`). 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_URL` and `DISCORD_REDIRECT_URI`
Expand Down Expand Up @@ -216,6 +216,28 @@ curl -s https://b2b.nxssie.dev/api/auth/me # {"user":null}
service URL).
- **Redirect loop** → the Domains field is still `https://` (must be `http://`).

## VPS Requirements

| 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:

```bash
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/fstab
```

Providers that fit: Hetzner CX22 (2 vCPU, 4 GB, ~€6/month), Contabo VPS S
(4 vCPU, 4 GB, ~€5/month).

## TODO

- [ ] Real-time sync over WebSockets (frontend currently polls `/songs`)
Expand Down
46 changes: 7 additions & 39 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ services:
build:
context: .
dockerfile: packages/server/Dockerfile
# Internal only — reached through the web nginx proxy, never published to the host.
# Internal only — Coolify's Traefex reverse-proxies to this port.
# Set the service's domain (https://b2b.nxssie.dev) in the Coolify UI;
# no published host port needed, no Traefik labels.
expose:
- "3001"
volumes:
# Named volume (not a relative bind mount): Coolify resolves it under the
# resource's managed storage and it survives redeploys without the
# convert-to-directory bind-mount pitfalls.
# Named volume: Coolify resolves it under the resource's managed storage
# and it survives redeploys without the convert-to-directory pitfalls.
- b2b_data:/app/data
environment:
- NODE_ENV=production
Expand All @@ -26,8 +27,8 @@ services:
- ADMIN_DISCORD_IDS=${ADMIN_DISCORD_IDS:-}
- ROOM_TTL_HOURS=${ROOM_TTL_HOURS:-24}
restart: unless-stopped
mem_limit: 1g
cpus: "1.5"
mem_limit: 768m
cpus: "1.0"
healthcheck:
# oven/bun image has no curl; use bun's fetch against /health.
test: ["CMD", "bun", "-e", "fetch('http://localhost:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
Expand All @@ -41,38 +42,5 @@ services:
max-size: "10m"
max-file: "3"

web:
build:
context: .
dockerfile: packages/web/Dockerfile
expose:
- "80"
depends_on:
server:
condition: service_healthy
restart: unless-stopped
mem_limit: 128m
cpus: "0.5"
healthcheck:
# nginx:alpine has busybox wget. Use 127.0.0.1 (not localhost): localhost
# resolves to ::1 first and nginx listens IPv4-only, so wget would fail
# forever, the container would never go healthy, and Traefik would drop it
# from the load balancer (503 "no available server").
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:80/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
# No custom networks and no hardcoded Traefik labels: Coolify auto-creates
# the per-stack network, attaches its proxy, and generates the correct
# routers/TLS when you set this service's domain (https://b2b.nxssie.dev) in
# the Coolify UI. Declaring our own network or labels here would multi-home
# the container (intermittent 504s) and collide with Coolify's generated set.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

volumes:
b2b_data:
46 changes: 35 additions & 11 deletions packages/server/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,24 +1,48 @@
FROM oven/bun:1 AS base
# --- Builder: install deps, compile native modules, build web, fetch yt-dlp ---
FROM oven/bun:1 AS builder
WORKDIR /app

# Install system dependencies (yt-dlp, ffmpeg, libopus for @discordjs/opus)
RUN apt-get update && apt-get install -y \
python3-pip \
ffmpeg \
libopus-dev \
# Build tools in case a native module falls back to source compile.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ libopus-dev curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --break-system-packages yt-dlp

# Copy all workspace manifests so bun can resolve the full monorepo lockfile
# yt-dlp standalone binary — no Python runtime needed in the final image.
RUN curl -L -o /usr/local/bin/yt-dlp \
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux \
&& chmod +x /usr/local/bin/yt-dlp

COPY package.json bun.lock* ./
COPY packages/server/package.json ./packages/server/
COPY packages/web/package.json ./packages/web/
RUN bun install

# Copy source
# Build the web frontend (Vite) — output: packages/web/dist
COPY packages/web/ ./packages/web/
RUN cd packages/web && bun run build

# Server source (bun runs TS directly, no build step)
COPY packages/server/ ./packages/server/

# Migrations are applied at startup against the mounted volume (see src/db),
# not baked into the image at build time.
# --- Runtime: minimal ---
FROM oven/bun:1-slim AS runtime
WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
libopus0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# yt-dlp binary fetched in the builder (no curl/python in the runtime image).
COPY --from=builder /usr/local/bin/yt-dlp /usr/local/bin/yt-dlp

COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages

# Expose the ffmpeg-static binary on PATH so yt-dlp's ffmpeg downloader
# fallback works without a second system ffmpeg install.
RUN ln -s /app/node_modules/ffmpeg-static/ffmpeg /usr/local/bin/ffmpeg

ENV NODE_ENV=production

EXPOSE 3001
CMD ["bun", "run", "--cwd", "packages/server", "start"]
19 changes: 19 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { extractVideoId, isPlaylistUrl } from "./lib/youtube";
import { detectSource, isSoundcloudSetUrl, type Source } from "./lib/sources";
import { encodeJwt, decodeJwt } from "./lib/jwt";
import { skipThreshold } from "./lib/voting";
import path from "node:path";

// --- Config ---
const DISCORD_TOKEN = process.env.DISCORD_TOKEN;
Expand All @@ -46,6 +47,8 @@ const DISCORD_REDIRECT_URI =
const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173";
const IS_PROD = process.env.NODE_ENV === "production";
const IS_DEV = process.env.NODE_ENV === "development";
const WEB_DIST_DIR = path.join(import.meta.dirname, "../../web/dist");
const INDEX_HTML = path.join(WEB_DIST_DIR, "index.html");
const PORT = Number(process.env.PORT) || 3001;
const DEFAULT_JWT_SECRET = "back2back-secret-change-in-production";
const JWT_SECRET = process.env.JWT_SECRET || DEFAULT_JWT_SECRET;
Expand Down Expand Up @@ -1391,6 +1394,22 @@ app.post("/api/admin/guilds/:guildId/reject", async (c) => {
return c.json({ success: true });
});

// ==================== STATIC FILES (merged web) ====================
// In production the Vite-built frontend is bundled into the server image.
// Serve it as static files with SPA fallback for client-side routing.
if (IS_PROD) {
const API_PREFIXES = ["/api", "/auth", "/health", "/ready", "/metrics"];
app.get("*", async (c) => {
const p = c.req.path;
if (API_PREFIXES.some((prefix) => p.startsWith(prefix))) {
return c.json({ error: "Not found" }, 404);
}
const file = Bun.file(path.join(WEB_DIST_DIR, p));
if (await file.exists()) return new Response(file);
return new Response(Bun.file(INDEX_HTML));
});
}

// ==================== DISCORD BOT ====================

discord.once(Events.ClientReady, async (c) => {
Expand Down
20 changes: 0 additions & 20 deletions packages/web/Dockerfile

This file was deleted.

30 changes: 0 additions & 30 deletions packages/web/nginx.conf

This file was deleted.

Loading