From 05d7d3a94481091c3244ca3b07311d00ba456dfb Mon Sep 17 00:00:00 2001 From: Nxssie Date: Fri, 3 Jul 2026 11:09:27 +0100 Subject: [PATCH] build: merge web into server and reduce image from 2.6GB to 755MB - Rewrite server Dockerfile as multi-stage build with oven/bun:1-slim runtime - Replace pip-installed yt-dlp with standalone binary (drops Python entirely) - Expose ffmpeg-static on PATH so yt-dlp's downloader fallback works - Serve Vite-built SPA from Hono in prod, removing the nginx container - Remove packages/web/Dockerfile and nginx.conf (dead code) - Lower compose limits: 768MB RAM / 1 CPU (down from 1GB / 1.5 CPU) - Add VPS requirements section to README with swap setup guide --- README.md | 48 ++++++++++++++++++++++++++---------- docker-compose.yml | 46 ++++++---------------------------- packages/server/Dockerfile | 46 +++++++++++++++++++++++++--------- packages/server/src/index.ts | 19 ++++++++++++++ packages/web/Dockerfile | 20 --------------- packages/web/nginx.conf | 30 ---------------------- 6 files changed, 96 insertions(+), 113 deletions(-) delete mode 100644 packages/web/Dockerfile delete mode 100644 packages/web/nginx.conf diff --git a/README.md b/README.md index 2933607..ab4de76 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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` @@ -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`) diff --git a/docker-compose.yml b/docker-compose.yml index ca9fe6c..ce96b99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -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))"] @@ -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: diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index 416d8b9..d1257ca 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -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"] diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1f06e71..14aa2f4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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; @@ -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; @@ -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) => { diff --git a/packages/web/Dockerfile b/packages/web/Dockerfile deleted file mode 100644 index 44a281d..0000000 --- a/packages/web/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM oven/bun:1 AS builder -WORKDIR /app - -# Copy all workspace manifests so bun can resolve the full monorepo lockfile. -# --ignore-scripts skips native builds (e.g. @discordjs/opus) that the web -# builder doesn't need and can't compile without extra system deps. -COPY package.json bun.lock* ./ -COPY packages/web/package.json ./packages/web/ -COPY packages/server/package.json ./packages/server/ -RUN bun install --ignore-scripts - -# Copy source and build -COPY packages/web/ ./packages/web/ -RUN cd packages/web && bun run build - -# Production stage -FROM nginx:alpine -COPY --from=builder /app/packages/web/dist /usr/share/nginx/html -COPY packages/web/nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 diff --git a/packages/web/nginx.conf b/packages/web/nginx.conf deleted file mode 100644 index 023d25a..0000000 --- a/packages/web/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -server { - listen 80; - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - } - - location /api/ { - proxy_pass http://server:3001; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - - # Discord OAuth flow (/auth/discord, /auth/discord/callback, /auth/logout). - # Must reach the backend — otherwise it falls through to the SPA index.html - # and login breaks. X-Forwarded-Proto lets the backend build correct URLs. - location /auth/ { - proxy_pass http://server:3001; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -}