From 6317004004fcddb8633bdee7d620bdf463d0c0a5 Mon Sep 17 00:00:00 2001 From: chris Date: Sat, 3 Oct 2026 16:02:38 -0400 Subject: [PATCH] Run the whole stack in Docker - API and web images built with turbo prune; the API image also runs the worker and migrations. Non-root, health-checked, Debian slim. - docker compose up -d --build starts Postgres, Redis (persistent), a one-shot migrate job, API, worker and web, in dependency order. - Fix the API production build (tsup flags moved to a config file) and bundle the migration script; silence Postgres NOTICE noise. - npm run infra:up now starts only Postgres and Redis for hot-reload dev. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8 --- .dockerignore | 15 +++++++ README.md | 14 ++++++- apps/api/Dockerfile | 38 +++++++++++++++++ apps/api/package.json | 5 ++- apps/api/src/db/index.ts | 3 +- apps/api/src/db/migrate.ts | 6 ++- apps/api/tsconfig.json | 3 +- apps/api/tsup.config.ts | 11 +++++ apps/web/Dockerfile | 36 ++++++++++++++++ apps/web/next.config.ts | 5 +++ docker-compose.yml | 84 ++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 12 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/tsup.config.ts create mode 100644 apps/web/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7eddc29 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Dependencies and build output are rebuilt inside the image +**/node_modules +**/.next +**/dist +**/.turbo +**/.expo +*.tsbuildinfo + +# Never bake secrets into images; configuration comes from env at runtime +**/.env +**/.env.* +!**/.env.example + +.git +.github diff --git a/README.md b/README.md index de6e386..862ebd0 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,23 @@ packages/ shared/ Types, zod schemas, plan entitlements, streaming-link helpers ``` -## Running locally +## Running with Docker (everything) + +```bash +cp apps/api/.env.example apps/api/.env # first time: set TOKEN_ENCRYPTION_KEY (openssl rand -base64 32) +docker compose up -d --build # Postgres, Redis, migrations, API, worker, web +``` + +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`. + +## Running for development (hot reload) Requires Node 22.13+ and Docker. ```bash npm install -npm run infra:up # Postgres on :5447, Redis on :6380 +docker compose up -d postgres redis # Postgres on :5447, Redis on :6380 cp apps/api/.env.example apps/api/.env # then set TOKEN_ENCRYPTION_KEY (openssl rand -base64 32) cp apps/web/.env.example apps/web/.env.local npm run db:migrate diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..e18e464 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,38 @@ +# Builds the API image. The same image runs the API server, the worker, and migrations +# (see docker-compose.yml). Build from the repo root: docker build -f apps/api/Dockerfile . + +FROM node:22-bookworm-slim AS base +WORKDIR /app + +# Prune the monorepo to just the API and what it depends on, so unrelated changes +# (e.g. a new web dependency) don't invalidate the install layer. +FROM base AS prepare +RUN npm install -g turbo@2.11.7 +COPY . . +RUN turbo prune @musicshare/api --docker + +FROM base AS builder +COPY --from=prepare /app/out/json/ . +RUN npm ci +COPY --from=prepare /app/out/full/ . +# turbo prune only copies workspaces; the shared tsconfig they extend lives at the root +COPY --from=prepare /app/tsconfig.base.json ./tsconfig.base.json +RUN npx turbo run build --filter=@musicshare/api \ + && npm prune --omit=dev \ + && mkdir -p apps/api/node_modules + +FROM base AS runner +ENV NODE_ENV=production \ + MIGRATIONS_DIR=/app/apps/api/drizzle +COPY --from=builder --chown=node:node /app/package.json ./package.json +COPY --from=builder --chown=node:node /app/node_modules ./node_modules +COPY --from=builder --chown=node:node /app/apps/api/package.json ./apps/api/package.json +COPY --from=builder --chown=node:node /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=builder --chown=node:node /app/apps/api/dist ./apps/api/dist +COPY --from=builder --chown=node:node /app/apps/api/drizzle ./apps/api/drizzle +WORKDIR /app/apps/api +USER node +EXPOSE 4000 +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 4000) + '/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" +CMD ["node", "dist/server.js"] diff --git a/apps/api/package.json b/apps/api/package.json index 16772aa..1365e45 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "concurrently -n api,worker -c blue,magenta \"tsx watch src/server.ts\" \"tsx watch src/worker.ts\"", - "build": "tsup src/server.ts src/worker.ts --format esm --target node22 --noExternal @musicshare/shared", + "build": "tsup", "start": "node dist/server.js", "start:worker": "node dist/worker.js", "typecheck": "tsc -p .", @@ -13,7 +13,8 @@ "db:migrate": "tsx src/db/migrate.ts", "test": "vitest run", "test:unit": "vitest run --project unit", - "test:watch": "vitest" + "test:watch": "vitest", + "start:migrate": "node dist/db/migrate.js" }, "dependencies": { "@fastify/cookie": "^11.1.2", diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 976a43b..e39baef 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -3,6 +3,7 @@ import postgres from 'postgres'; import { env } from '../env'; import * as schema from './schema'; -export const sql = postgres(env.DATABASE_URL, { max: 10 }); +// onnotice: silence harmless NOTICEs (e.g. "schema already exists" on every migrate run) +export const sql = postgres(env.DATABASE_URL, { max: 10, onnotice: () => {} }); export const db = drizzle(sql, { schema }); export { schema }; diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index 9cd98e4..1ca78d0 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -1,6 +1,10 @@ +import { fileURLToPath } from 'node:url'; import { migrate } from 'drizzle-orm/postgres-js/migrator'; import { db, sql } from './index'; -await migrate(db, { migrationsFolder: new URL('../../drizzle', import.meta.url).pathname }); +// In the Docker image the bundled script lives in dist/, so the folder is passed explicitly. +const migrationsFolder = process.env.MIGRATIONS_DIR ?? fileURLToPath(new URL('../../drizzle', import.meta.url)); + +await migrate(db, { migrationsFolder }); console.log('migrations applied'); await sql.end(); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 7b555aa..e02715c 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -9,6 +9,7 @@ "src", "test", "drizzle.config.ts", - "vitest.config.ts" + "vitest.config.ts", + "tsup.config.ts" ] } diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts new file mode 100644 index 0000000..6f53592 --- /dev/null +++ b/apps/api/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/server.ts', 'src/worker.ts', 'src/db/migrate.ts'], + format: ['esm'], + target: 'node22', + platform: 'node', + clean: true, + // The shared package ships TypeScript source, so bundle it; everything else stays in node_modules. + noExternal: ['@musicshare/shared'], +}); diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..c57c52c --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,36 @@ +# Builds the web app as a Next.js standalone server. Build from the repo root: +# docker build -f apps/web/Dockerfile --build-arg NEXT_PUBLIC_API_URL=https://api.example.com . + +FROM node:22-bookworm-slim AS base +WORKDIR /app + +FROM base AS prepare +RUN npm install -g turbo@2.11.7 +COPY . . +RUN turbo prune @musicshare/web --docker + +FROM base AS builder +COPY --from=prepare /app/out/json/ . +RUN npm ci +COPY --from=prepare /app/out/full/ . +# turbo prune only copies workspaces; the shared tsconfig they extend lives at the root +COPY --from=prepare /app/tsconfig.base.json ./tsconfig.base.json +# NEXT_PUBLIC_* values are inlined into the browser bundle at build time, +# so the API URL is a build argument, not a runtime setting. +ARG NEXT_PUBLIC_API_URL=http://localhost:4000 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ + NEXT_TELEMETRY_DISABLED=1 +RUN npx turbo run build --filter=@musicshare/web + +FROM base AS runner +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3737 \ + HOSTNAME=0.0.0.0 +COPY --from=builder --chown=node:node /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=node:node /app/apps/web/.next/static ./apps/web/.next/static +USER node +EXPOSE 3737 +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:' + process.env.PORT).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 764d8b8..9c82f37 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,12 @@ +import { fileURLToPath } from 'node:url'; import type { NextConfig } from 'next'; const config: NextConfig = { transpilePackages: ['@musicshare/shared'], + // Self-contained server bundle for the Docker image (only the files it needs). + output: 'standalone', + // Monorepo: trace files from the repo root so the shared package is included. + outputFileTracingRoot: fileURLToPath(new URL('../..', import.meta.url)), }; export default config; diff --git a/docker-compose.yml b/docker-compose.yml index eacfc08..2ce0d50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,22 @@ +# Full stack: `docker compose up -d --build`, then open http://localhost:3737 +# Configuration comes from apps/api/.env (copy apps/api/.env.example first). + +x-api: &api + build: + context: . + dockerfile: apps/api/Dockerfile + image: musicshare-api:local + env_file: apps/api/.env + environment: + # Inside the compose network, services reach each other by name. + DATABASE_URL: postgres://musicshare:musicshare@postgres:5432/musicshare + REDIS_URL: redis://redis:6379 + extra_hosts: + # Lets the API reach servers on your machine (e.g. a local Jellyfin) as host.docker.internal + - host.docker.internal:host-gateway + init: true + restart: unless-stopped + services: postgres: image: postgres:17 @@ -9,9 +28,74 @@ services: - "${POSTGRES_PORT:-5447}:5432" volumes: - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U musicshare -d musicshare"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + redis: image: redis:7 + # Persist to disk so queued jobs and poll schedules survive restarts. + command: ["redis-server", "--appendonly", "yes"] ports: - "${REDIS_PORT:-6380}:6379" + volumes: + - redisdata:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + # Applies database migrations, then exits. The API and worker wait for it. + migrate: + <<: *api + command: ["node", "dist/db/migrate.js"] + restart: "no" + depends_on: + postgres: + condition: service_healthy + + api: + <<: *api + ports: + - "${API_PORT:-4000}:4000" + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + + worker: + <<: *api + command: ["node", "dist/worker.js"] + healthcheck: + disable: true + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + args: + # The URL the *browser* uses to reach the API. + NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:4000} + image: musicshare-web:local + ports: + - "${WEB_PORT:-3737}:3737" + init: true + restart: unless-stopped + depends_on: + api: + condition: service_healthy + volumes: pgdata: + redisdata: diff --git a/package.json b/package.json index 274c324..4237e02 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "turbo run dev", "build": "turbo run build", "typecheck": "turbo run typecheck", - "infra:up": "docker compose up -d", + "infra:up": "docker compose up -d postgres redis", "infra:down": "docker compose down", "db:generate": "npm run db:generate -w @musicshare/api", "db:migrate": "npm run db:migrate -w @musicshare/api",