From 9c8c0164ea89ce2fcb606936c1da1c41238b7547 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 13 May 2026 20:32:34 -0300 Subject: [PATCH 01/21] fix(docker): exclude worktrees + agent dirs from build context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build context was including `.worktrees/` (3.4GB), `worktrees/` (707MB), `.wt-specs/` (41MB), `.claude/` (115MB), and `.agents/` (183MB) — totalling ~4.5GB of duplicated trees and AI agent state shipped to the Docker daemon on every build. None of this is needed inside any image. Also exclude `archive/` and `downloads/` (host-only state). --- .dockerignore | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.dockerignore b/.dockerignore index 057bab35b..eeb73a071 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,22 @@ .gitignore .gitattributes +# Worktrees (4.5GB+ of duplicated trees — must be excluded) +.worktrees/ +worktrees/ +.wt-specs/ + +# AI agent state / caches (115MB+ — never needed at build time) +.claude/ +.claude-env/ +.claude-mem/ +.claude-server-commander/ +.agents/ + +# Archived branches + downloaded media (host-only) +archive/ +downloads/ + # Documentation docs/ *.md From e542c24bc6a0945b037adce9396abb16aacec687 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 13 May 2026 20:32:49 -0300 Subject: [PATCH 02/21] fix(docker): real bot healthcheck via Redis TCP PING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous HEALTHCHECK was `node -e "console.log('Service is running')"`, which always exits 0 regardless of bot state — orchestrators could never detect a wedged or disconnected bot. New check opens a raw TCP socket to Redis ($REDIS_HOST / $REDIS_PORT) and sends the RESP PING command. Pass = +PONG within 3s; anything else fails. This confirms (1) node can execute inside the container and (2) the bot's critical Redis dependency is reachable from this container. No new deps. Start period bumped 5s → 30s to account for `prisma migrate deploy` running before the bot process starts. --- Dockerfile | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 85e035bee..8f3d57df2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,8 +99,11 @@ RUN mkdir -p downloads logs && \ USER bot -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD node -e "console.log('Service is running')" || exit 1 +# Liveness via Redis TCP PING — confirms node can run AND the bot's +# Redis dependency is reachable. A wedged node process or broken Redis +# link both fail this; the old `console.log` check did neither. +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD node -e "const net=require('net');const s=net.createConnection({host:process.env.REDIS_HOST||'redis',port:+(process.env.REDIS_PORT||6379)},()=>s.write('*1\r\n\$4\r\nPING\r\n'));s.on('data',d=>process.exit(d.toString().startsWith('+PONG')?0:1));s.on('error',()=>process.exit(1));setTimeout(()=>process.exit(1),3000);" || exit 1 CMD ["sh", "-c", "npx prisma migrate deploy --config prisma/prisma.config.ts && node packages/bot/dist/index.js"] From 004a69e297eac0d689110fa28aed23b9efb28d8c Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 13 May 2026 20:33:04 -0300 Subject: [PATCH 03/21] fix(docker): add development stage for dev compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker-compose.dev.yml` referenced `target: development` but no such stage existed in `Dockerfile` — `docker compose -f docker-compose.dev.yml up --build` would fail with 'failed to find target development'. New `development` stage derives from `base-runtime` (already has ffmpeg / opus / yt-dlp), adds native build tools, and runs `tsx watch` via `npm run dev --workspace=packages/bot`. Compose still bind-mounts host source over `/app`; node_modules installed on first run to populate the anonymous volume. --- Dockerfile | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Dockerfile b/Dockerfile index 8f3d57df2..8f9bf550f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,19 @@ WORKDIR /app FROM node:${NODE_VERSION} AS base-runtime-backend WORKDIR /app +# Development stage — full deps + native build tools + media binaries. +# Source is bind-mounted by docker-compose.dev.yml (`.:/app`), so this +# image only needs the runtime + global tooling. node_modules is preserved +# inside the container via an anonymous volume. +FROM base-runtime AS development +RUN apk add --no-cache git build-base python3-dev opus-dev && rm -rf /var/cache/apk/* +WORKDIR /app +ENV NODE_ENV=development \ + NPM_CONFIG_LOGLEVEL=warn +# Compose mounts host source over /app; node_modules is installed at first +# run via the entrypoint to populate the anonymous volume. +CMD ["sh", "-c", "npm ci --legacy-peer-deps --no-audit --no-fund && npx prisma generate && npm run dev --workspace=packages/bot"] + # Build stage — installs all deps, generates prisma, builds shared + target FROM node:${NODE_VERSION} AS build From 60a2b42924de3085f62e40473dc493d5c1bbe80a Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 13 May 2026 20:33:48 -0300 Subject: [PATCH 04/21] chore(docker): non-root nginx (UID 101) + healthchecks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch `Dockerfile.nginx` and `Dockerfile.frontend` from `nginx:alpine` (runs as root to bind port 80) to `nginxinc/nginx-unprivileged:1.27-alpine` (UID 101, listens on 8080 by default — no NET_BIND_SERVICE capability needed). Changes: - nginx confs (frontend + reverse proxy): `listen 80` → `listen 8080` - nginx reverse-proxy upstream: `http://frontend:80` → `http://frontend:8080` - Dockerfile.frontend + Dockerfile.nginx: pinned image, EXPOSE 8080, added HEALTHCHECK via `wget --spider` (busybox wget ships in the base image). - docker-compose.yml: port mapping `${NGINX_PORT:-8080}:80` → `:8080`. DEPLOY ACTION REQUIRED: update `cloudflared/config-lucky.yml` on the homelab so the tunnel ingress points at `http://nginx:8080` instead of `http://nginx:80` before merging this PR. Host-side `NGINX_PORT` default is unchanged (8080). --- Dockerfile.frontend | 9 ++++++--- Dockerfile.nginx | 10 +++++++--- docker-compose.yml | 5 ++++- nginx/frontend.conf | 2 +- nginx/nginx.conf | 4 ++-- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/Dockerfile.frontend b/Dockerfile.frontend index daf57f592..35073cc0c 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -24,12 +24,15 @@ RUN npx prisma generate RUN npm run build --workspace=packages/shared RUN npm run build --workspace=packages/frontend -# NOSONAR: nginx requires root to bind port 80 -FROM nginx:alpine +# 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 80 +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.nginx b/Dockerfile.nginx index db77cf3bf..03c088b54 100644 --- a/Dockerfile.nginx +++ b/Dockerfile.nginx @@ -1,8 +1,12 @@ -# NOSONAR: nginx requires root to bind port 80 -FROM nginx:alpine +# Non-root nginx listening on 8080 (UID 101). Cloudflared / docker port +# publishing maps host 8080 → container 8080. +FROM nginxinc/nginx-unprivileged:1.27-alpine COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 +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 5b1aa62d2..aaa095b85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -189,7 +189,10 @@ services: - backend - frontend ports: - - "${NGINX_PORT:-8080}:80" + # Container now listens on 8080 (non-root nginx). + # NOTE: update cloudflared config-lucky.yml service entry to + # `http://nginx:8080` after deploying this change. + - "${NGINX_PORT:-8080}:8080" networks: - lucky-network logging: diff --git a/nginx/frontend.conf b/nginx/frontend.conf index 3c25a987b..cfb23c856 100644 --- a/nginx/frontend.conf +++ b/nginx/frontend.conf @@ -1,5 +1,5 @@ server { - listen 80; + listen 8080; server_name _; root /usr/share/nginx/html; index index.html; diff --git a/nginx/nginx.conf b/nginx/nginx.conf index cc8b2de45..cffe7b614 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -7,7 +7,7 @@ map $http_x_forwarded_proto $proxy_x_forwarded_proto { } server { - listen 80; + listen 8080; server_name _; client_max_body_size 50M; @@ -38,7 +38,7 @@ server { } location / { - set $frontend_upstream http://frontend:80; + set $frontend_upstream http://frontend:8080; proxy_pass $frontend_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; From a0bce94d92d178c44a0c161ec6f54602e25dc080 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 13 May 2026 20:35:13 -0300 Subject: [PATCH 05/21] chore(docker): add resource limits + env_file fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose previously had no memory or CPU limits on any service — a runaway bot or backend process could OOM-kill the homelab host. Adds tiered defaults via YAML anchors: small-svc (frontend, nginx, webhook, cloudflared) 128m / 0.25 cpu medium-svc (redis, backend) 512m / 0.5 cpu large-svc (postgres, bot) 1g / 1.0 cpu Bot + backend also get `env_file: .env` so any var missing from the explicit `environment:` block falls back to .env at startup. Explicit entries still take precedence, so behavior is unchanged for vars already listed. Cloudflared `user: root` removed — the official image's nonroot default is sufficient for `tunnel run` with a mounted config dir. `docker-compose config -q` passes. --- docker-compose.yml | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index aaa095b85..3e631caa3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,24 @@ +# Shared resource defaults — applied per service via `<<: *small-svc` etc. +x-small-svc: &small-svc + mem_limit: 128m + cpus: 0.25 + restart: unless-stopped + +x-medium-svc: &medium-svc + mem_limit: 512m + cpus: 0.5 + restart: unless-stopped + +x-large-svc: &large-svc + mem_limit: 1g + cpus: 1.0 + restart: unless-stopped + services: postgres: + <<: *large-svc image: postgres:18-alpine container_name: lucky-postgres - restart: unless-stopped environment: POSTGRES_DB: discordbot POSTGRES_USER: discordbot @@ -24,9 +40,9 @@ services: max-file: "3" redis: + <<: *medium-svc image: redis:8-alpine container_name: lucky-redis - restart: unless-stopped command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data @@ -44,6 +60,7 @@ services: max-file: "3" bot: + <<: *large-svc image: ${IMAGE_PREFIX:-ghcr.io/lucassantana-dev/lucky}-bot:${IMAGE_TAG:-latest} build: context: . @@ -53,7 +70,8 @@ services: SERVICE: bot NODE_ENV: production container_name: lucky-bot - restart: unless-stopped + env_file: + - .env depends_on: postgres: condition: service_healthy @@ -108,6 +126,7 @@ services: max-file: "3" backend: + <<: *medium-svc image: ${IMAGE_PREFIX:-ghcr.io/lucassantana-dev/lucky}-backend:${IMAGE_TAG:-latest} build: context: . @@ -117,7 +136,8 @@ services: SERVICE: backend NODE_ENV: production container_name: lucky-backend - restart: unless-stopped + env_file: + - .env depends_on: postgres: condition: service_healthy @@ -164,12 +184,12 @@ services: max-file: "3" frontend: + <<: *small-svc image: ${IMAGE_PREFIX:-ghcr.io/lucassantana-dev/lucky}-frontend:${IMAGE_TAG:-latest} build: context: . dockerfile: Dockerfile.frontend container_name: lucky-frontend - restart: unless-stopped networks: - lucky-network logging: @@ -179,12 +199,12 @@ services: max-file: "3" nginx: + <<: *small-svc image: ${IMAGE_PREFIX:-ghcr.io/lucassantana-dev/lucky}-nginx:${IMAGE_TAG:-latest} build: context: . dockerfile: Dockerfile.nginx container_name: lucky-nginx - restart: unless-stopped depends_on: - backend - frontend @@ -202,11 +222,11 @@ services: max-file: "3" webhook: + <<: *small-svc build: context: ./deploy dockerfile: Dockerfile container_name: lucky-webhook - restart: unless-stopped command: > -hooks /hooks/hooks.json -verbose @@ -231,11 +251,10 @@ services: # Cloudflare Tunnel — exposes nginx to lucky.lucassantana.tech cloudflared: + <<: *small-svc image: cloudflare/cloudflared:latest container_name: lucky-tunnel - restart: unless-stopped command: tunnel --config /etc/cloudflared/config-lucky.yml run - user: root volumes: - ${CLOUDFLARED_CONFIG_DIR:-/home/luk-server/.cloudflared}:/etc/cloudflared:ro depends_on: From b048a8538ebcc87599f7f97f29f66aac5a2398c1 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 13 May 2026 20:35:46 -0300 Subject: [PATCH 06/21] chore(docker): pin webhook base to almir/webhook:2.8.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy webhook container has `/var/run/docker.sock` bind-mounted in `docker-compose.yml`, which gives anything inside it effective root on the host. Pulling `almir/webhook:latest` (last published 2026-02-12) every rebuild made that surface vulnerable to silent upstream changes. Pin to `2.8.3` (current latest, identical digest as `latest` at time of this change). Also fix the inline-comment placement on `USER root` — it was on the same line which is parsed differently across Docker versions. --- deploy/Dockerfile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 4161bc28a..75070f125 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,6 +1,10 @@ -FROM almir/webhook:latest +# Pinned to almir/webhook 2.8.3 (released 2026-02-12). Avoid :latest because +# this service has /var/run/docker.sock mounted — any silent base-image change +# is a supply-chain incident waiting to happen. +FROM almir/webhook:2.8.3 -USER root # NOSONAR: privileged operations required for apk and docker socket +# NOSONAR: privileged operations required for apk and docker socket mount +USER root RUN apk add --no-cache git docker-cli docker-cli-compose bash # Copy hooks config to /hooks/ — a path NOT declared as VOLUME by the From 76b54b05d32046eedd4ce08c92de632c3fe36412 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 13 May 2026 20:36:25 -0300 Subject: [PATCH 07/21] chore(docker): consolidate stages, venv yt-dlp, align frontend dev to node 22 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small but durable cleanups: 1. Drop the no-op `base-runtime-backend` stage. `production-backend` now derives directly from `node:${NODE_VERSION}` — the intermediate stage only set WORKDIR, which the production stage already does. 2. Replace `pip3 install --break-system-packages` for yt-dlp with a proper PEP-668-compliant venv at `/opt/ytdlp`, symlinked into `/usr/local/bin`. Same behavior, no warning suppression, easier to audit and upgrade. /root/.cache is cleaned in the same layer. 3. `packages/frontend/Dockerfile.dev` was using `node:24-alpine` while production frontend is pinned to `node:22-alpine` (PR #846). Aligned to 22 to avoid silent native-module drift. Also removed `npm cache clean --force` which defeated the BuildKit cache mount. --- Dockerfile | 22 ++++++++++++---------- packages/frontend/Dockerfile.dev | 9 ++++++--- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8f9bf550f..f0a6e32b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,19 +6,20 @@ ARG NODE_VERSION=22-alpine FROM node:${NODE_VERSION} AS base-runtime +# yt-dlp is installed into a dedicated venv at /opt/ytdlp so we avoid +# `--break-system-packages` (Alpine's PEP 668 marker). The venv binary +# is symlinked into /usr/local/bin so callers don't need to know the path. RUN apk add --no-cache \ python3 \ py3-pip \ ffmpeg \ opus \ opus-tools \ - && rm -rf /var/cache/apk/* + && python3 -m venv /opt/ytdlp \ + && /opt/ytdlp/bin/pip install --no-cache-dir --upgrade pip yt-dlp \ + && ln -s /opt/ytdlp/bin/yt-dlp /usr/local/bin/yt-dlp \ + && rm -rf /var/cache/apk/* /root/.cache -RUN pip3 install --break-system-packages --no-cache-dir yt-dlp - -WORKDIR /app - -FROM node:${NODE_VERSION} AS base-runtime-backend WORKDIR /app # Development stage — full deps + native build tools + media binaries. @@ -120,16 +121,17 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD ["sh", "-c", "npx prisma migrate deploy --config prisma/prisma.config.ts && node packages/bot/dist/index.js"] -# Production stage — backend (slim runtime, no media tools) -FROM base-runtime-backend AS production-backend +# Production stage — backend (slim runtime, no media tools). +# Derives directly from node:${NODE_VERSION} instead of a no-op intermediate +# `base-runtime-backend` stage. +FROM node:${NODE_VERSION} AS production-backend +WORKDIR /app ARG COMMIT_SHA ENV NODE_ENV=production \ NPM_CONFIG_LOGLEVEL=silent \ COMMIT_SHA=$COMMIT_SHA -WORKDIR /app - COPY --from=deps-production /app/node_modules ./node_modules COPY --from=deps-production /app/package*.json ./ COPY --from=deps-production /app/packages/shared/package*.json ./packages/shared/ diff --git a/packages/frontend/Dockerfile.dev b/packages/frontend/Dockerfile.dev index eea44934c..f2c5ffa3c 100644 --- a/packages/frontend/Dockerfile.dev +++ b/packages/frontend/Dockerfile.dev @@ -1,13 +1,16 @@ # syntax=docker/dockerfile:1 -FROM node:24-alpine +# Pinned to node:22-alpine to match the production frontend (PR #846 reverted +# from node:24). Drift between dev + prod silently breaks native modules. +FROM node:22-alpine WORKDIR /app COPY package*.json ./ +# Keep BuildKit's cache mount — DO NOT run `npm cache clean --force` here, +# it defeats the mount and slows every rebuild. RUN --mount=type=cache,target=/root/.npm \ - npm ci --no-audit --no-fund && \ - npm cache clean --force + npm ci --no-audit --no-fund USER node From 6cd45331da31ffcc5844d50af8b05d6e79ad5e0c Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 13 May 2026 20:37:00 -0300 Subject: [PATCH 08/21] docs(docker): ADR for chore/docker-overhaul Captures the 8-commit Docker surface overhaul: motivation, decisions per commit, consequences (including the cloudflared config-lucky.yml port edit required on the homelab before merge), out-of-scope items, and revisit triggers. --- docs/decisions/2026-05-13-docker-overhaul.md | 91 ++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/decisions/2026-05-13-docker-overhaul.md diff --git a/docs/decisions/2026-05-13-docker-overhaul.md b/docs/decisions/2026-05-13-docker-overhaul.md new file mode 100644 index 000000000..e08d1b308 --- /dev/null +++ b/docs/decisions/2026-05-13-docker-overhaul.md @@ -0,0 +1,91 @@ +# ADR — Docker Surface Overhaul (chore/docker-overhaul) + +- **Status:** Proposed +- **Date:** 2026-05-13 +- **Branch:** `chore/docker-overhaul` +- **Base:** `release/v2.11.0` + +## Context + +The Lucky container surface accumulated friction: + +- `0eb13d0f` (PR #846) reverted the frontend image to `node:22-alpine`, signalling + Docker drift between dev and prod was already biting. +- Build context was shipping the entire repo, including `.worktrees/` (3.4GB), + `worktrees/` (707MB), `.wt-specs/` (41MB), `.claude/` (115MB), and `.agents/` + (183MB) — ~4.5GB of redundant data sent to the daemon on every build. +- `docker-compose.dev.yml` referenced a `target: development` stage that did + not exist in `Dockerfile`, so dev compose was broken. +- The bot `HEALTHCHECK` was `node -e "console.log('Service is running')"` — + always exit 0, completely useless as a liveness signal. +- `Dockerfile.nginx` and `Dockerfile.frontend` ran as root to bind port 80 + with `nginx:alpine`. +- No memory or CPU limits on any compose service. +- `almir/webhook:latest` was unpinned despite the service having + `/var/run/docker.sock` mounted (effective host root). +- `cloudflared` ran as `user: root` unnecessarily. +- A no-op `base-runtime-backend` intermediate stage existed. +- yt-dlp installed via `pip3 install --break-system-packages`. + +## Decision + +Single PR (`chore/docker-overhaul`) ships eight focused commits: + +1. `.dockerignore` excludes worktrees, agent state, archive, downloads. +2. Real bot HEALTHCHECK via raw RESP `PING` over TCP to `${REDIS_HOST}`. +3. `development` stage added to `Dockerfile`, wired by dev compose. +4. nginx + frontend switched to `nginxinc/nginx-unprivileged:1.27-alpine` + (UID 101, port 8080). Compose port mapping + nginx confs adjusted. + Cloudflared config on the homelab must be updated to point at `:8080` + before merge. +5. Resource limits via three YAML anchors (`small-svc` / `medium-svc` / + `large-svc`) applied to every service. `env_file: .env` added as + fallback for bot + backend. Cloudflared `user: root` removed. +6. `almir/webhook` pinned to `2.8.3`. +7. Stage consolidation, venv-based yt-dlp, frontend dev image aligned to + node 22, BuildKit cache mount preserved. +8. This ADR + audit doc. + +## Consequences + +**Positive** + +- Build context shrinks by ~4.5GB → faster local + CI builds and lower + daemon memory pressure. +- Dev compose actually works (`docker compose -f docker-compose.dev.yml up`). +- Orchestrators can detect a wedged bot (Redis-unreachable or node-stuck). +- nginx no longer needs root; minor but durable hardening win. +- No service can OOM the homelab host. +- Supply-chain risk on the webhook container (which holds docker.sock) + drops to "Almir's account stays uncompromised" only. + +**Negative / Required follow-ups** + +- The homelab `cloudflared/config-lucky.yml` ingress entry must be edited + from `http://nginx:80` to `http://nginx:8080` BEFORE merging this PR. +- `env_file: .env` introduces a precedence layer; verified explicit + `environment:` still wins, so behavior is preserved. +- Resource limits are best-guess; revisit if any service hits OOM under + steady-state traffic. + +## Out of scope + +- Migrating from compose to k8s / nomad. +- Switching to a distroless or wolfi base. +- BuildKit `--secret` for build-time credentials (not currently needed). +- Frontend Dockerfile sharing the main multi-stage build (worth doing, + but would couple frontend builds to the bot/backend build cache and + inflate PR scope). + +## Revisit triggers + +- Container OOM events in homelab journalctl. +- Cloudflared / nginx 502s after merge → first check tunnel config port. +- `almir/webhook` reaches end-of-life or upstream publishes a CVE fix + newer than 2.8.3. + +## References + +- PR #846 (`fix(docker): revert frontend image to node:22-alpine`) +- Audit memory: `audit_deep_lucky_2026-05-13` +- TBD policy memory: `feedback_tbd_release_branches` From 334fcb8720a957c5eef153a38e8b7f6bf2abea32 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Wed, 13 May 2026 21:11:37 -0300 Subject: [PATCH 09/21] fix(docker): address CodeRabbit findings on #848 - deploy/Dockerfile: pin almir/webhook:2.8.3 by manifest digest sha256:f77cc91c91d1527b48052280af38b50791e842ad8dd291e9b360a0c13c9ca991. Docker Hub tags are mutable by default; the digest makes the supply-chain story (this container holds docker.sock) actually defensible. - docs/decisions/2026-05-13-docker-overhaul.md: remove dangling reference to a separate audit doc that was never committed; ADR contains audit findings inline. --- deploy/Dockerfile | 8 ++++---- docs/decisions/2026-05-13-docker-overhaul.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 75070f125..71739907d 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,7 +1,7 @@ -# Pinned to almir/webhook 2.8.3 (released 2026-02-12). Avoid :latest because -# this service has /var/run/docker.sock mounted — any silent base-image change -# is a supply-chain incident waiting to happen. -FROM almir/webhook:2.8.3 +# Pinned to almir/webhook 2.8.3 by manifest digest (released 2026-02-12). The +# tag alone is mutable on Docker Hub — the digest guarantees immutability since +# this service has /var/run/docker.sock mounted (supply-chain risk). +FROM almir/webhook:2.8.3@sha256:f77cc91c91d1527b48052280af38b50791e842ad8dd291e9b360a0c13c9ca991 # NOSONAR: privileged operations required for apk and docker socket mount USER root diff --git a/docs/decisions/2026-05-13-docker-overhaul.md b/docs/decisions/2026-05-13-docker-overhaul.md index e08d1b308..c6d54913f 100644 --- a/docs/decisions/2026-05-13-docker-overhaul.md +++ b/docs/decisions/2026-05-13-docker-overhaul.md @@ -44,7 +44,7 @@ Single PR (`chore/docker-overhaul`) ships eight focused commits: 6. `almir/webhook` pinned to `2.8.3`. 7. Stage consolidation, venv-based yt-dlp, frontend dev image aligned to node 22, BuildKit cache mount preserved. -8. This ADR + audit doc. +8. This ADR (audit findings inline above; no separate audit doc). ## Consequences From 1e67c48fe34cf2f709e6049c44237eaac1649856 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 14 May 2026 15:27:31 -0300 Subject: [PATCH 10/21] ci(docker): add yt-dlp smoke test to bot image build --- .github/workflows/docker-publish.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 923bcb367..a4882edb7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -80,3 +80,8 @@ jobs: build-args: COMMIT_SHA=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max + + - name: Smoke test yt-dlp + if: matrix.service == 'bot' + run: | + docker run --rm ${{ env.IMAGE_PREFIX }}-bot:${{ github.sha }} yt-dlp --version From 36077049af31a9f98bdc0888edf11f59cea8082a Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Thu, 14 May 2026 23:25:12 -0300 Subject: [PATCH 11/21] feat(frontend): redesign landing page with ui-expert four-gate workflow Register: consumer-saas (Discord server admins + community managers) Anchors: Raycast hero (dark, single dominant), Linear typography (weight contrast, -0.03em tracking), Vercel atmosphere (blueprint dot grid) - Replace purple/blue gradient with solid brand-pink on dark canvas - Remove gradient text, replace with solid lucky-text-strong / lucky-brand - Fix identical card grid: FEATURE_SPANS=[2,1,1,2,1,2] asymmetric bento - Remove glassmorphism, neon glow on all cards and hero elements - Stats section: deliberate size hierarchy (5xl/4xl/badge) with vertical divider - FAQ accordion: solid surface-sidebar bg, brand-border on open item, aria-expanded - CTAs are specific: 'Add to Discord' / 'Open Dashboard' (not generic) - Framer-motion entrance with custom ease [0.16, 1, 0.3, 1] - ai-slop-audit result: PASS (0 critical, 0 major, 0 minor) --- packages/frontend/src/pages/Landing.tsx | 469 ++++++++++++------------ 1 file changed, 226 insertions(+), 243 deletions(-) diff --git a/packages/frontend/src/pages/Landing.tsx b/packages/frontend/src/pages/Landing.tsx index 722a337d5..f1f908cdb 100644 --- a/packages/frontend/src/pages/Landing.tsx +++ b/packages/frontend/src/pages/Landing.tsx @@ -12,8 +12,8 @@ import { Music, Shield, Zap, BarChart3, Palette, Sparkles, ChevronDown, Server, const CLIENT_ID = '962198089161134131' const BOT_INVITE_URL = `https://discord.com/oauth2/authorize?client_id=${CLIENT_ID}&scope=bot%20applications.commands&permissions=8` -// Neon glow backdrop gradients -const NEON_BACKDROP = 'bg-gradient-to-br from-slate-950 via-purple-950 to-slate-950' +// Asymmetric bento: alternating 2/1 col spans in 3-col grid (avoids identical card grid slop) +const FEATURE_SPANS = [2, 1, 1, 2, 1, 2] as const export default function Landing() { const login = useAuthStore((state) => state.login) @@ -27,44 +27,27 @@ export default function Landing() { } | null>(null) const [statsLoading, setStatsLoading] = useState(true) - const { value: guildCount } = useCountUp(stats?.totalGuilds ?? 0, { - duration: 1500, - delay: 300, - }) - const { value: userCount } = useCountUp(stats?.totalUsers ?? 0, { - duration: 1500, - delay: 500, - }) + const { value: guildCount } = useCountUp(stats?.totalGuilds ?? 0, { duration: 1500, delay: 300 }) + const { value: userCount } = useCountUp(stats?.totalUsers ?? 0, { duration: 1500, delay: 500 }) - const displayGuildCount = prefersReducedMotion ? stats?.totalGuilds ?? 0 : guildCount - const displayUserCount = prefersReducedMotion ? stats?.totalUsers ?? 0 : userCount + const displayGuildCount = prefersReducedMotion ? (stats?.totalGuilds ?? 0) : guildCount + const displayUserCount = prefersReducedMotion ? (stats?.totalUsers ?? 0) : userCount useEffect(() => { let isActive = true - const fetchStats = async () => { try { const response = await api.stats.getPublic() - if (!isActive) { - return - } - + if (!isActive) return setStats(response.data) } catch (error) { - if (!isActive) { - return - } - + if (!isActive) return console.error('Failed to fetch stats:', error) } finally { - if (isActive) { - setStatsLoading(false) - } + if (isActive) setStatsLoading(false) } } - fetchStats() - return () => { isActive = false } @@ -88,103 +71,97 @@ export default function Landing() { return (
- {/* Hero Section with Neon Logo */} - - {/* Feature Grid */} - - {/* Stats Strip */} - - {/* FAQ */} - - {/* Footer */}
) } -// Hero Section with Animated Logo type HeroSectionProps = { logoAnimation: Record onLogin: () => void } function HeroSection({ logoAnimation, onLogin }: HeroSectionProps) { - const prefersReducedMotion = useReducedMotion() const { t } = useTranslation() return ( -
+
+ {/* Blueprint dot grid — Vercel atmosphere anchor */} +
+ {/* Radial vignette fades grid toward edges */} +
+
- {/* Animated neon glow blobs */} - {!prefersReducedMotion && ( - <> -
-
- - )} - - {/* Logo */} - + + Lucky Bot - {/* Headline with gradient text */} -
-

- {t('landing.hero.headlineLine1')} - {t('landing.hero.headlineLine2')} -

-

- {t('landing.hero.subtitle')} -

-
+ {/* Display headline — Sora via --font-lucky-display global base rule */} +

+ {t('landing.hero.headlineLine1')} + {t('landing.hero.headlineLine2')} +

+ +

+ {t('landing.hero.subtitle')} +

- {/* CTA Buttons */} {t('landing.hero.ctaPrimary')} @@ -194,90 +171,59 @@ function HeroSection({ logoAnimation, onLogin }: HeroSectionProps) { ) } -// Feature Grid with Neon Icon Orbs function FeatureSection() { const { t } = useTranslation() const features = useMemo( () => [ - { - icon: Music, - titleKey: 'landing.features.music.title', - descKey: 'landing.features.music.description', - color: 'from-pink-500/20 to-pink-600/10', - iconColor: 'text-pink-400', - }, - { - icon: Shield, - titleKey: 'landing.features.autoMod.title', - descKey: 'landing.features.autoMod.description', - color: 'from-orange-500/20 to-orange-600/10', - iconColor: 'text-orange-400', - }, - { - icon: Zap, - titleKey: 'landing.features.customCommands.title', - descKey: 'landing.features.customCommands.description', - color: 'from-purple-500/20 to-purple-600/10', - iconColor: 'text-purple-400', - }, - { - icon: BarChart3, - titleKey: 'landing.features.webDashboard.title', - descKey: 'landing.features.webDashboard.description', - color: 'from-blue-500/20 to-blue-600/10', - iconColor: 'text-blue-400', - }, - { - icon: Palette, - titleKey: 'landing.features.embedBuilder.title', - descKey: 'landing.features.embedBuilder.description', - color: 'from-cyan-500/20 to-cyan-600/10', - iconColor: 'text-cyan-400', - }, - { - icon: Sparkles, - titleKey: 'landing.features.artistPreferences.title', - descKey: 'landing.features.artistPreferences.description', - color: 'from-amber-500/20 to-amber-600/10', - iconColor: 'text-amber-400', - }, + { icon: Music, titleKey: 'landing.features.music.title', descKey: 'landing.features.music.description' }, + { icon: Shield, titleKey: 'landing.features.autoMod.title', descKey: 'landing.features.autoMod.description' }, + { icon: Zap, titleKey: 'landing.features.customCommands.title', descKey: 'landing.features.customCommands.description' }, + { icon: BarChart3, titleKey: 'landing.features.webDashboard.title', descKey: 'landing.features.webDashboard.description' }, + { icon: Palette, titleKey: 'landing.features.embedBuilder.title', descKey: 'landing.features.embedBuilder.description' }, + { icon: Sparkles, titleKey: 'landing.features.artistPreferences.title', descKey: 'landing.features.artistPreferences.description' }, ], [], ) return ( -
- {/* Subtle grid pattern background */} -
- -
- -

{t('landing.features.heading')}

-

{t('landing.features.subheading')}

+
+
+ +

+ {t('landing.features.heading')} +

+

{t('landing.features.subheading')}

-
    +
      {features.map((feature, idx) => { const Icon = feature.icon + const span = FEATURE_SPANS[idx] ?? 1 + const isLarge = span === 2 return ( -
    • +
    • -
      - +
      +
      -

      {t(feature.titleKey)}

      -

      {t(feature.descKey)}

      +

      + {t(feature.titleKey)} +

      +

      {t(feature.descKey)}

    • @@ -289,7 +235,6 @@ function FeatureSection() { ) } -// Stats Strip with Neon Numbers type StatsSectionProps = { statsLoading: boolean guildCount: number @@ -300,63 +245,80 @@ type StatsSectionProps = { function StatsSection({ statsLoading, guildCount, userCount, serversOnline }: StatsSectionProps) { const { t, i18n } = useTranslation() const locale = i18n.resolvedLanguage ?? i18n.language - const stats = [ + const isOnline = Boolean(serversOnline) + + const scaleStats = [ { - label: t('landing.stats.servers'), - value: statsLoading ? '---' : `${guildCount.toLocaleString(locale)}${guildCount > 0 ? '+' : ''}`, icon: Server, + value: statsLoading ? '—' : `${guildCount.toLocaleString(locale)}${guildCount > 0 ? '+' : ''}`, + label: t('landing.stats.servers'), + size: 'text-5xl md:text-6xl' as const, }, { - label: t('landing.stats.users'), - value: statsLoading ? '---' : `${userCount.toLocaleString(locale)}+`, icon: Users, - }, - { - label: t('landing.stats.status'), - value: serversOnline ? t('landing.stats.statusOnline') : statsLoading ? '---' : t('landing.stats.statusOffline'), - icon: Radio, - isStatus: true, + value: statsLoading ? '—' : `${userCount.toLocaleString(locale)}+`, + label: t('landing.stats.users'), + size: 'text-4xl md:text-5xl' as const, }, ] return ( -
      -
      -
      - {stats.map((stat, idx) => { - const Icon = stat.icon - return ( - -
      - -
      - - {stat.value} - -

      {stat.label}

      -
      - ) - })} +
      +
      +
      + {scaleStats.map(({ icon: Icon, value, label, size }, idx) => ( + +

      + {value} +

      +
      + + {label} +
      +
      + ))} + +
      + + {/* Status — distinct from scale metrics: badge-style, no large number */} + + {statsLoading ? ( +

      —

      + ) : ( +
      + + + {isOnline ? t('landing.stats.statusOnline') : t('landing.stats.statusOffline')} + +
      + )} +
      + + {t('landing.stats.status')} +
      +
      ) } -// FAQ Section with Smooth Accordions function FAQSection() { const { t } = useTranslation() const [openIdx, setOpenIdx] = useState(null) @@ -370,90 +332,111 @@ function FAQSection() { }, [t]) return ( -
      -
      - -

      {t('landing.faq.heading')}

      +
      +
      + +

      {t('landing.faq.heading')}

      -
      - {faqs.map(({ q, a }, idx) => ( - - - +

      + {a} +

      - - -

      {a}

      -
      - ))} + ) + })}
      ) } -// Footer with Logo Tile function FooterSection() { const { t } = useTranslation() + const footerLinks = [ + { href: '/terms', key: 'landing.footer.terms', external: false }, + { href: '/privacy', key: 'landing.footer.privacy', external: false }, + { href: 'https://github.com/LucasSantana-Dev/Lucky', key: 'landing.footer.github', external: true }, + ] + return ( -