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
4 changes: 2 additions & 2 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''
Expand Down
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;"]
38 changes: 0 additions & 38 deletions Dockerfile.frontend

This file was deleted.

121 changes: 53 additions & 68 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
100 changes: 100 additions & 0 deletions docs/decisions/2026-05-13-frontend-dockerfile-consolidated.md
Original file line number Diff line number Diff line change
@@ -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=<image>`** — 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`)
Loading