diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 923bcb367..fecb90f0f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -39,8 +39,8 @@ jobs: dockerfile: Dockerfile target: production-backend - service: frontend - dockerfile: Dockerfile.frontend - target: '' + dockerfile: Dockerfile + target: production-frontend - service: nginx dockerfile: Dockerfile.nginx target: '' diff --git a/Dockerfile b/Dockerfile index f0a6e32b9..0f05700fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,6 +65,15 @@ RUN npm run build:shared RUN npm run build --workspace=packages/bot RUN npm run build --workspace=packages/backend +# Frontend build — inherits the build stage's deps + toolchain, so we get +# build-base + python3-dev + opus-dev "for free." Previously the standalone +# Dockerfile.frontend re-ran `npm ci` for ~all workspace deps (including +# @discordjs/opus) but lacked the C toolchain, which broke node:26-alpine +# in PR #846. Sharing the build stage eliminates that class of failure. +FROM build AS build-frontend +COPY packages/frontend ./packages/frontend +RUN npm run build --workspace=packages/frontend + # Production deps — slim install (no dev deps) FROM node:${NODE_VERSION} AS deps-production @@ -155,3 +164,17 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD node -e "require('http').get('http://127.0.0.1:3000/api/toggles/global', r => process.exit(r.statusCode < 500 ? 0 : 1)).on('error', () => process.exit(1))" || exit 1 CMD ["node", "packages/backend/dist/index.js"] + +# Production stage — frontend (static SPA served by non-root nginx). +# Replaces the former standalone Dockerfile.frontend. +FROM nginxinc/nginx-unprivileged:1.27-alpine AS production-frontend + +COPY --from=build-frontend /app/packages/frontend/dist /usr/share/nginx/html +COPY nginx/frontend.conf /etc/nginx/conf.d/default.conf + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -q --spider http://127.0.0.1:8080/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend deleted file mode 100644 index 35073cc0c..000000000 --- a/Dockerfile.frontend +++ /dev/null @@ -1,38 +0,0 @@ -# syntax=docker/dockerfile:1 -FROM node:22-alpine AS builder - -WORKDIR /app - -COPY package*.json ./ -COPY packages/shared/package*.json ./packages/shared/ -COPY packages/bot/package*.json ./packages/bot/ -COPY packages/backend/package*.json ./packages/backend/ -COPY packages/frontend/package*.json ./packages/frontend/ - -RUN --mount=type=cache,id=npm-frontend-builder,target=/root/.npm,sharing=locked \ - YOUTUBE_DL_SKIP_DOWNLOAD=1 \ - YOUTUBE_DL_SKIP_PYTHON_CHECK=1 \ - npm ci --legacy-peer-deps --no-audit --no-fund && \ - (npm cache verify 2>/dev/null || true) - -COPY packages/frontend ./packages/frontend -COPY packages/shared ./packages/shared -COPY prisma ./prisma - -RUN npx prisma generate - -RUN npm run build --workspace=packages/shared -RUN npm run build --workspace=packages/frontend - -# Runtime: nginx-unprivileged listens on 8080 as UID 101 (no root, no port-80 cap). -FROM nginxinc/nginx-unprivileged:1.27-alpine - -COPY --from=builder /app/packages/frontend/dist /usr/share/nginx/html -COPY nginx/frontend.conf /etc/nginx/conf.d/default.conf - -EXPOSE 8080 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD wget -q --spider http://127.0.0.1:8080/ || exit 1 - -CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.yml b/docker-compose.yml index 3e631caa3..2e63846a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,36 @@ x-large-svc: &large-svc cpus: 1.0 restart: unless-stopped +# Environment shared by bot + backend. Both apps connect to the same +# postgres, redis, Discord auth pair, Sentry org, and integrations +# (last.fm + spotify). Service-specific vars stay inline in each +# service's `environment:` block. +x-common-app-env: &common-app-env + NODE_ENV: production + DISCORD_TOKEN: ${DISCORD_TOKEN} + CLIENT_ID: ${CLIENT_ID} + SENTRY_DSN: ${SENTRY_DSN:-} + SENTRY_ENABLED: ${SENTRY_ENABLED:-true} + SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production} + SENTRY_RELEASE: ${SENTRY_RELEASE:-} + SENTRY_SERVER_NAME: ${SENTRY_SERVER_NAME:-} + SENTRY_APP_NAME: ${SENTRY_APP_NAME:-lucky} + SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-} + SENTRY_PROFILES_SAMPLE_RATE: ${SENTRY_PROFILES_SAMPLE_RATE:-} + DATABASE_URL: postgresql://discordbot:${POSTGRES_PASSWORD?POSTGRES_PASSWORD_required}@postgres:5432/discordbot + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_DB: ${REDIS_DB:-0} + LASTFM_API_KEY: ${LASTFM_API_KEY:-} + LASTFM_API_SECRET: ${LASTFM_API_SECRET:-} + LASTFM_LINK_SECRET: ${LASTFM_LINK_SECRET:-} + SPOTIFY_CLIENT_ID: ${SPOTIFY_CLIENT_ID:-} + SPOTIFY_CLIENT_SECRET: ${SPOTIFY_CLIENT_SECRET:-} + SPOTIFY_REDIRECT_URI: ${SPOTIFY_REDIRECT_URI:-} + SPOTIFY_LINK_SECRET: ${SPOTIFY_LINK_SECRET:-} + WEBAPP_REDIRECT_URI: ${WEBAPP_REDIRECT_URI:-} + WEBAPP_SESSION_SECRET: ${WEBAPP_SESSION_SECRET:-} + services: postgres: <<: *large-svc @@ -78,42 +108,19 @@ services: redis: condition: service_healthy environment: - - NODE_ENV=production - - DISCORD_TOKEN=${DISCORD_TOKEN} - - CLIENT_ID=${CLIENT_ID} - - SENTRY_DSN=${SENTRY_DSN:-} - - SENTRY_ENABLED=${SENTRY_ENABLED:-true} - - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-production} - - SENTRY_RELEASE=${SENTRY_RELEASE:-} - - SENTRY_SERVER_NAME=${SENTRY_SERVER_NAME:-} - - SENTRY_APP_NAME=${SENTRY_APP_NAME:-lucky} - - SENTRY_SERVICE_NAME=${SENTRY_SERVICE_NAME:-bot} - - SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-} - - SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-} - - DATABASE_URL=postgresql://discordbot:${POSTGRES_PASSWORD?POSTGRES_PASSWORD_required}@postgres:5432/discordbot - - REDIS_HOST=redis - - REDIS_PORT=6379 - - REDIS_DB=${REDIS_DB:-0} - - BOT_NAME=${BOT_NAME:-Lucky} - - LASTFM_API_KEY=${LASTFM_API_KEY:-} - - LASTFM_API_SECRET=${LASTFM_API_SECRET:-} - - LASTFM_SESSION_KEY=${LASTFM_SESSION_KEY:-} - - LASTFM_LINK_SECRET=${LASTFM_LINK_SECRET:-} - - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID:-} - - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET:-} - - SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI:-} - - SPOTIFY_LINK_SECRET=${SPOTIFY_LINK_SECRET:-} - - WEBAPP_REDIRECT_URI=${WEBAPP_REDIRECT_URI:-} - - WEBAPP_SESSION_SECRET=${WEBAPP_SESSION_SECRET:-} - - PLAYER_CONNECTION_TIMEOUT=${PLAYER_CONNECTION_TIMEOUT:-15000} - - MUSIC_WATCHDOG_RECOVERY_WAIT_MS=${MUSIC_WATCHDOG_RECOVERY_WAIT_MS:-5000} - - TWITCH_CLIENT_ID=${TWITCH_CLIENT_ID:-} - - TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET:-} - - TWITCH_ACCESS_TOKEN=${TWITCH_ACCESS_TOKEN:-} - - TWITCH_REFRESH_TOKEN=${TWITCH_REFRESH_TOKEN:-} - - AI_DEV_TOOLKIT_BOARD_ENABLED=${AI_DEV_TOOLKIT_BOARD_ENABLED:-false} - - AI_DEV_TOOLKIT_CHANNEL_ID=${AI_DEV_TOOLKIT_CHANNEL_ID:-} - - GITHUB_TOKEN=${GITHUB_TOKEN:-} + <<: *common-app-env + SENTRY_SERVICE_NAME: ${SENTRY_SERVICE_NAME:-bot} + BOT_NAME: ${BOT_NAME:-Lucky} + LASTFM_SESSION_KEY: ${LASTFM_SESSION_KEY:-} + PLAYER_CONNECTION_TIMEOUT: ${PLAYER_CONNECTION_TIMEOUT:-15000} + MUSIC_WATCHDOG_RECOVERY_WAIT_MS: ${MUSIC_WATCHDOG_RECOVERY_WAIT_MS:-5000} + TWITCH_CLIENT_ID: ${TWITCH_CLIENT_ID:-} + TWITCH_CLIENT_SECRET: ${TWITCH_CLIENT_SECRET:-} + TWITCH_ACCESS_TOKEN: ${TWITCH_ACCESS_TOKEN:-} + TWITCH_REFRESH_TOKEN: ${TWITCH_REFRESH_TOKEN:-} + AI_DEV_TOOLKIT_BOARD_ENABLED: ${AI_DEV_TOOLKIT_BOARD_ENABLED:-false} + AI_DEV_TOOLKIT_CHANNEL_ID: ${AI_DEV_TOOLKIT_CHANNEL_ID:-} + GITHUB_TOKEN: ${GITHUB_TOKEN:-} volumes: - ./downloads:/app/downloads - ./logs:/app/logs @@ -144,37 +151,14 @@ services: redis: condition: service_healthy environment: - - NODE_ENV=production - - DISCORD_TOKEN=${DISCORD_TOKEN} - - LUCKY_NOTIFY_API_KEY=${LUCKY_NOTIFY_API_KEY:-} - - CLIENT_ID=${CLIENT_ID} - - CLIENT_SECRET=${CLIENT_SECRET} - - SENTRY_DSN=${SENTRY_DSN:-} - - SENTRY_ENABLED=${SENTRY_ENABLED:-true} - - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-production} - - SENTRY_RELEASE=${SENTRY_RELEASE:-} - - SENTRY_SERVER_NAME=${SENTRY_SERVER_NAME:-} - - SENTRY_APP_NAME=${SENTRY_APP_NAME:-lucky} - - SENTRY_SERVICE_NAME=${SENTRY_SERVICE_NAME:-backend} - - SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-} - - SENTRY_PROFILES_SAMPLE_RATE=${SENTRY_PROFILES_SAMPLE_RATE:-} - - DATABASE_URL=postgresql://discordbot:${POSTGRES_PASSWORD?POSTGRES_PASSWORD_required}@postgres:5432/discordbot - - REDIS_HOST=redis - - REDIS_PORT=6379 - - REDIS_DB=${REDIS_DB:-0} - - WEBAPP_PORT=3000 - - WEBAPP_REDIRECT_URI=${WEBAPP_REDIRECT_URI} - - WEBAPP_SESSION_SECRET=${WEBAPP_SESSION_SECRET} - - DEVELOPER_USER_IDS=${DEVELOPER_USER_IDS:-} - - LASTFM_API_KEY=${LASTFM_API_KEY:-} - - LASTFM_API_SECRET=${LASTFM_API_SECRET:-} - - LASTFM_LINK_SECRET=${LASTFM_LINK_SECRET:-} - - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID:-} - - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET:-} - - SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI:-} - - SPOTIFY_LINK_SECRET=${SPOTIFY_LINK_SECRET:-} - - WEBAPP_FRONTEND_URL=${WEBAPP_FRONTEND_URL:-} - - WEBAPP_BACKEND_URL=${WEBAPP_BACKEND_URL:-} + <<: *common-app-env + SENTRY_SERVICE_NAME: ${SENTRY_SERVICE_NAME:-backend} + LUCKY_NOTIFY_API_KEY: ${LUCKY_NOTIFY_API_KEY:-} + CLIENT_SECRET: ${CLIENT_SECRET} + WEBAPP_PORT: 3000 + DEVELOPER_USER_IDS: ${DEVELOPER_USER_IDS:-} + WEBAPP_FRONTEND_URL: ${WEBAPP_FRONTEND_URL:-} + WEBAPP_BACKEND_URL: ${WEBAPP_BACKEND_URL:-} networks: - lucky-network logging: @@ -188,7 +172,8 @@ services: image: ${IMAGE_PREFIX:-ghcr.io/lucassantana-dev/lucky}-frontend:${IMAGE_TAG:-latest} build: context: . - dockerfile: Dockerfile.frontend + dockerfile: Dockerfile + target: production-frontend container_name: lucky-frontend networks: - lucky-network diff --git a/docs/DOCKER.md b/docs/DOCKER.md index fbd55fb1a..94c7df926 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -12,7 +12,7 @@ | Bot (production) | `docker build --target production-bot -t lucky-bot:latest .` | | Backend (production) | `docker build --target production-backend -t lucky-backend:latest .` | | Bot (development) | `docker build --target development --build-arg SERVICE=bot -t lucky-bot:dev .` | -| Frontend | `docker build -f Dockerfile.frontend -t lucky-frontend:latest .` | +| Frontend (production)| `docker build --target production-frontend -t lucky-frontend:latest .` | | Nginx | `docker build -f Dockerfile.nginx -t lucky-nginx:latest .` | Compose builds these when you run `docker compose up -d` or `docker compose -f docker-compose.dev.yml up -d`. diff --git a/docs/decisions/2026-05-13-frontend-dockerfile-consolidated.md b/docs/decisions/2026-05-13-frontend-dockerfile-consolidated.md new file mode 100644 index 000000000..4098ab7ee --- /dev/null +++ b/docs/decisions/2026-05-13-frontend-dockerfile-consolidated.md @@ -0,0 +1,100 @@ +# ADR — Consolidate frontend Dockerfile into main multi-stage build + +- **Status:** Accepted +- **Date:** 2026-05-13 +- **Supersedes:** `2026-05-13-frontend-dockerfile-keep-separate.md` (lives on + parked branch `docs/docker-decision-adrs`; that ADR's 12-month-defer is + overridden here for the reason documented below) +- **Related:** PR #846 (the actual incident), PR #848 (Docker overhaul) + +## Context + +Earlier today the `/research-and-decide` composite recommended keeping +`Dockerfile.frontend` separate with a 12-month re-evaluation trigger. The +trigger conditions included: + +> **PR #846-class break recurs**: another native bot dep ships without +> prebuilts for the chosen Node version → frontend Dockerfile must stop +> installing bot deps. + +Re-reading PR #846's commit body confirmed the bug had already happened +once. The "defer 12 months" decision was hedging against future breaks +when the cost of fixing the structural cause now is roughly equal to +shipping the workaround (the standalone Dockerfile). + +The structural cause is unchanged regardless of base-image or Node-major +choice: a separate `Dockerfile.frontend` that runs `npm ci` at the +workspace root will *always* pull in `@discordjs/opus` (and any future +bot-only native dep) and need to either compile or have prebuilts available. + +## Decision + +Consolidate `Dockerfile.frontend` into the main multi-stage `Dockerfile` +as two new stages: + +- `build-frontend` (derives from existing `build` stage) +- `production-frontend` (runtime: `nginxinc/nginx-unprivileged:1.27-alpine`) + +`Dockerfile.frontend` deleted. Compose + CI publish workflow + docs +updated. + +## Why now instead of in 12 months + +1. **PR #846 was a real production blocker** for 4 days. The deferred + ADR's trigger #1 was hypothetical when written — it had already fired. +2. **Cost of the refactor turned out small**: 5 files, 28 insertions, 42 + deletions, one `docker-compose config -q` validation, no behavior + change. +3. **The `build` stage already had `build-base + python3-dev + opus-dev`**. + Sharing it means native compilation works even when prebuilts don't + exist — making the frontend image resilient to future Node-major bumps + that outrun `@discordjs/opus`'s prebuilt cadence. + +## Consequences + +**Positive** +- One Dockerfile to maintain. Frontend evolution can't drift from + bot/backend evolution. +- ~10-12s saved per build (no duplicate `npm ci`). +- Frontend builds inherit the C toolchain → Dependabot Node-major bumps + stop blowing up the frontend image. +- Resolves the open question in the previous ADR's "Revisit when" + trigger #1. + +**Negative** +- Cache invalidation: any change in `packages/bot` or `packages/backend` + source now invalidates the frontend's `build-frontend` stage. In + practice this is acceptable because the `build` stage's `npm ci` and + prisma generate are the slow parts, and those are gated on + package*.json (unchanged by source edits). +- Lock-in: `target: production-frontend` is a docker-compose / `docker + build --target` idiom. Vercel or Cloudflare Pages won't read this; if + the frontend moves off the homelab the consolidation needs to be + unwound. Deploy-target ADR locks homelab so this is hypothetical. + +## Alternatives considered (one more time) + +- **Turborepo prune (`turbo prune --scope=frontend`)** — would minimize + cache invalidation but pulls in `turbo` as a first-class CI primitive + for a 4-package monorepo. Premature. +- **Cross-Dockerfile `COPY --from=`** — keeps two files but adds a + build-order dependency in CI (deps image must publish before frontend + starts). Strictly more complex than this refactor. +- **Status quo** — already rejected by the data; see "Why now". + +## Revisit when + +1. **Frontend ship cadence ≥ 5×/week and bot ≤ 2/month** for 4 + consecutive weeks → invalidation isolation matters more than cache + reuse; revisit Turborepo prune. +2. **Lucky moves off homelab to Vercel / Cloudflare Pages** → frontend + build becomes Vercel's problem; this consolidation is harmless to + unwind. +3. **Annual review on 2027-05-13.** + +## References + +- PR #846 — `fix(docker): revert frontend image to node:22-alpine` +- PR #848 — `chore/docker-overhaul` +- Superseded ADR — `2026-05-13-frontend-dockerfile-keep-separate.md` + (parked branch `docs/docker-decision-adrs`)