feat(docker): image-based prod stack (Dockerfiles + prod compose + push script)

Adds a pull-based production option so the host no longer builds/installs deps
on every restart:
- backend/Dockerfile + scrobble/Dockerfile bake the heavy deps (ffmpeg, Deno,
  yt-dlp, node libs). Backend still refreshes yt-dlp on start (non-fatal).
- docker-compose.prod.yml pulls ${REGISTRY}/novasonic-*:${TAG}.
- scripts/build-push-images.mjs (npm run push-images) builds + pushes all three.
- .env.example documents every var incl. REGISTRY/TAG; .env gitignored.

The build-on-host docker-compose.yml stays as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-09-02 07:45:17 +02:00
1 parent 93ee958434
commit 149443e4f7
8 files changed
+205 -19

No files matched your search

+27 -15
View File
@@ -1,20 +1,32 @@
# Copy to .env and fill in your values
# Copy to .env next to your docker-compose file and fill in. cp .env.example .env
# Used by docker-compose.yml (build-on-host) AND docker-compose.prod.yml (pulls images).
# Pre-fills the URLs on the setup screen
NAVIDROME_URL=https://music.yourdomain.com
BACKEND_URL=https://novasonic-api.yourdomain.com
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # play-log / stats service (port 3710)
# ── URLs pre-filled on the frontend setup screen ─────────────────────────────
NAVIDROME_URL=https://music.example.com
BACKEND_URL=https://backend.example.com
SCROBBLE_URL=https://scrobble.example.com
# Absolute path to your Navidrome music library on the host
MUSIC_PATH=/srv/navidrome/music
AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
# ── Library / downloads (backend) ────────────────────────────────────────────
# Absolute music path — mounted 1:1 into the backend (no remapping), same value inside the container.
MUSIC_PATH=/opt/navidrome/music
AUDIO_FMT=mp3
AUDIO_QUALITY=0
MAX_PARALLEL=2
DOWNLOAD_BACKEND=ytdlp
# Ticketmaster Events (optional — free API key)
# 1. Sign up at https://developer.ticketmaster.com/
# 2. Create an app — copy the Consumer Key
TICKETMASTER_API_KEY=
# ── Android APK download button + in-app updater ─────────────────────────────
# The backend proxies the fixed Gitea release ("apk-latest") over the LAN, so Gitea can stay internal.
GITEA_URL=http://192.168.2.51:3000 # internal Gitea base reachable from the backend container
GITEA_REPO=Docker/NovaSonic
GITEA_TOKEN= # read token (only needed for a private repo)
GITEA_APK_TAG=apk-latest
# ── Pre-built images (docker-compose.prod.yml only) ──────────────────────────
# REGISTRY = where `npm run push-images` pushed to and where the host pulls from.
# Gitea container registry (lowercase owner): gitea.honeyy.duckdns.org/docker
# Docker Hub: docker.io/youruser
# Plain-HTTP internal registries need the host's Docker daemon to allow it
# (/etc/docker/daemon.json → {"insecure-registries":["192.168.2.51:3000"]}).
REGISTRY=gitea.honeyy.duckdns.org/docker
TAG=latest
+1
View File
@@ -3,3 +3,4 @@
node_modules/
dist/
scripts/.env.release
.env
+9 -3
View File
@@ -41,9 +41,15 @@ npm run build # production build → dist/
npm run preview # preview the production build
```
Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives at
`/opt/navidrome/music`. Restart after backend changes: `docker compose restart novasonic-backend`
(this also re-pulls the latest yt-dlp).
Backend runs in Docker. Two ways:
- **`docker-compose.yml`** — builds on the host (mounts `./backend`, installs deps at container
start). Restart after backend changes: `docker compose restart novasonic-backend` (re-pulls yt-dlp).
- **`docker-compose.prod.yml`** — PULLS pre-built images (deps baked into `backend/Dockerfile` +
`scrobble/Dockerfile`), so restarts are instant. Build+push from dev with `npm run push-images`
(needs `REGISTRY`/`TAG` in `.env`), then on the host `docker compose -f docker-compose.prod.yml pull
&& … up -d`. See `.env.example`.
On the deploy host the music lives at `/opt/navidrome/music`.
## Download pipeline (hard-won knowledge)
+32
View File
@@ -0,0 +1,32 @@
# NovaSonic download backend — bakes the heavy deps (ffmpeg, Deno, yt-dlp, node libs) so restarts are
# instant instead of re-installing everything on every boot. Build context = ./backend.
FROM node:22-slim
# System deps: python (yt-dlp), ffmpeg (tagging/convert), curl/git/unzip (installers), ca-certs.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip ffmpeg curl git unzip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# yt-dlp (nightly) + the Spotify helper tooling.
RUN pip install --break-system-packages -U --pre 'yt-dlp[default]' \
&& pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader'
# Deno — yt-dlp uses it to solve YouTube's JS signature/nsig challenge.
RUN curl -fsSL https://deno.land/install.sh | sh
ENV PATH="/root/.deno/bin:${PATH}"
# Node libs the server loads (express, kuromoji) — kept out of the app dir like the compose setup did.
WORKDIR /deps
RUN npm install express kuromoji
ENV NODE_PATH=/deps/node_modules
# App code.
WORKDIR /app
COPY . /app/
ENV PORT=3700 DATA_PATH=/data
EXPOSE 3700
# Refresh yt-dlp on start (heavy deps already baked, so this is a quick pull) — non-fatal if offline,
# the baked version still works. Keeps the "always-latest yt-dlp" behaviour of the old compose command.
CMD ["sh","-c","pip install --break-system-packages -U --pre 'yt-dlp[default]' >/dev/null 2>&1 || true; node novasonic-server.js"]
+63
View File
@@ -0,0 +1,63 @@
# Production stack — PULLS pre-built images instead of building on the host.
#
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d
#
# Build & push the images from your dev machine with: npm run push-images
# (see scripts/build-push-images.mjs). Set REGISTRY + TAG in .env (see .env.example).
services:
novasonic-frontend:
image: ${REGISTRY}/novasonic-frontend:${TAG:-latest}
pull_policy: always
container_name: novasonic-frontend
restart: unless-stopped
ports:
- "8080:80"
environment:
- NAVIDROME_URL=${NAVIDROME_URL}
- BACKEND_URL=${BACKEND_URL}
- SCROBBLE_URL=${SCROBBLE_URL}
novasonic-backend:
image: ${REGISTRY}/novasonic-backend:${TAG:-latest}
pull_policy: always
container_name: novasonic-backend
restart: unless-stopped
volumes:
- ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping
- novasonic-data:/data
ports:
- "3700:3700"
environment:
- PORT=3700
- MUSIC_PATH=${MUSIC_PATH}
- DATA_PATH=/data
- AUDIO_FMT=${AUDIO_FMT:-mp3}
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
- MAX_PARALLEL=${MAX_PARALLEL:-2}
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
# Android APK download button — backend proxies the fixed Gitea release ("apk-latest").
- GITEA_URL=${GITEA_URL:-}
- GITEA_REPO=${GITEA_REPO:-}
- GITEA_TOKEN=${GITEA_TOKEN:-}
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
novasonic-scrobble:
image: ${REGISTRY}/novasonic-scrobble:${TAG:-latest}
pull_policy: always
container_name: novasonic-scrobble
restart: unless-stopped
volumes:
- novasonic-scrobble-data:/data
ports:
- "3710:3710"
environment:
- PORT=3710
- DATA_PATH=/data
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
volumes:
novasonic-data:
novasonic-scrobble-data:
+2 -1
View File
@@ -6,7 +6,8 @@
"build": "vite build",
"preview": "vite preview",
"icons": "node scripts/gen-icons.mjs",
"publish-apk": "node scripts/publish-apk.mjs"
"publish-apk": "node scripts/publish-apk.mjs",
"push-images": "node scripts/build-push-images.mjs"
},
"devDependencies": {
"sharp": "^0.34.5",
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// Build the three NovaSonic images and push them to your registry, so the production host just PULLS
// (docker-compose.prod.yml) instead of building. Run from your dev machine after changes:
//
// npm run push-images # builds + pushes REGISTRY/*:TAG (and :latest)
//
// Config (env or a gitignored scripts/.env.release / repo .env):
// REGISTRY e.g. gitea.honeyy.duckdns.org/docker (Gitea uses a LOWERCASE owner)
// TAG image tag (default: latest)
// You must `docker login <registry-host>` once first. Plain-HTTP internal registries also need the
// daemon's insecure-registries list (see .env.example).
import { spawnSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
// Load REGISTRY/TAG from a local env file if present (repo .env, then scripts/.env.release).
for (const f of [join(root, '.env'), join(root, 'scripts', '.env.release')]) {
if (!existsSync(f)) continue;
for (const line of readFileSync(f, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
const REGISTRY = (process.env.REGISTRY || '').replace(/\/$/, '');
const TAG = process.env.TAG || 'latest';
if (!REGISTRY) { console.error('\n✖ REGISTRY muss gesetzt sein (env oder .env). Siehe .env.example.'); process.exit(1); }
// name → build context (relative to repo root). Frontend uses the root Dockerfile.
const images = {
'novasonic-frontend': '.',
'novasonic-backend': 'backend',
'novasonic-scrobble': 'scrobble',
};
function run(cmd, args) {
console.log(`\n$ ${cmd} ${args.join(' ')}`);
const r = spawnSync(cmd, args, { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' });
if (r.status !== 0) { console.error(`\n✖ Fehlgeschlagen: ${cmd} ${args.join(' ')}`); process.exit(1); }
}
for (const [name, ctx] of Object.entries(images)) {
const ref = `${REGISTRY}/${name}:${TAG}`;
const latest = `${REGISTRY}/${name}:latest`;
console.log(`\n=== ${name} → ${ref} ===`);
run('docker', ['build', '-t', ref, '-t', latest, ctx]);
run('docker', ['push', ref]);
if (latest !== ref) run('docker', ['push', latest]);
}
console.log(`\n✔ Alle Images gebaut + gepusht (${REGISTRY}/*:${TAG}). Auf dem Server:`);
console.log(' docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d');
+15
View File
@@ -0,0 +1,15 @@
# NovaSonic scrobble service — isolated timestamped play-log. Uses Node's built-in SQLite
# (--experimental-sqlite), so the only dependency is express. Build context = ./scrobble.
FROM node:22-slim
WORKDIR /deps
RUN npm install express
ENV NODE_PATH=/deps/node_modules
WORKDIR /app
COPY . /app/
ENV PORT=3710 DATA_PATH=/data
EXPOSE 3710
CMD ["node","--experimental-sqlite","scrobble-server.js"]