# syntax=docker/dockerfile:1.6 # # VYNDR Express backend (port 3001). # # Multi-stage build: # 1. deps — install production deps with a clean lockfile # 2. runner — copy src/, poller/, scripts/, node_modules and start # # The Next.js frontend ships in a separate image (web/Dockerfile). PM2 # pollers run INSIDE this image and are auto-started by # scripts/docker-entrypoint.sh so a Coolify redeploy reseeds them on # every container start. # # Build: docker build -t vyndr-api . # Run: docker run -p 3001:3001 --env-file .env vyndr-api # --- deps stage --- FROM node:20-alpine AS deps WORKDIR /app # package-lock.json is the source of truth — npm ci reproduces it exactly. COPY package.json package-lock.json ./ RUN npm ci --omit=dev --no-audit --no-fund # --- runner stage --- FROM node:20-alpine AS runner WORKDIR /app # curl — /api/health smoke check (Coolify HEALTHCHECK). # postgresql-client (pg_dump/pg_restore) + rsync + bash — the nightly DB backup # (scripts/backup-db.sh) runs INSIDE this container, where SUPABASE_DB_URL and # the Supabase network are available. See docs/BACKUP-RUNBOOK.md. RUN apk add --no-cache curl tini bash postgresql-client rsync # PM2 is installed globally so the entrypoint can call `pm2 start` to # boot all three pollers (NBA / WNBA / MLB) alongside the Express API. RUN npm install -g pm2@latest --no-audit --no-fund ENV NODE_ENV=production \ PORT=3001 \ PM2_HOME=/app/.pm2 # Non-root user — the container should never run as uid 0 even if the # host accidentally maps a privileged port. RUN addgroup -S vyndr && adduser -S vyndr -G vyndr COPY --from=deps /app/node_modules ./node_modules COPY package.json package-lock.json ./ COPY src ./src COPY poller ./poller COPY scripts ./scripts COPY supabase ./supabase # Session 65 (P0) — content/ holds the Stark-line library + seed articles. # It was NOT copied, so mediaEngine's module-load read threw ENOENT in the # image and crashed the API at boot (the deploy silently rolled back for 14 # hours). The read is now hardened to optional too (belt AND suspenders), # but the file belongs in the image. COPY content ./content # Persistent volume for JSONL training data (resolutions survive # redeploys via the Coolify mount). PM2_HOME lives outside it so # supervisor state is local to the container. RUN mkdir -p /app/data/training /app/.pm2 /app/backups \ && chown -R vyndr:vyndr /app/data /app/.pm2 /app/backups \ && chmod +x /app/scripts/docker-entrypoint.sh USER vyndr EXPOSE 3001 # tini reaps zombies — important now that we spawn pm2 as a child of # this entrypoint. ENTRYPOINT ["/sbin/tini", "--"] HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ CMD curl -fsS http://127.0.0.1:3001/api/health || exit 1 CMD ["sh", "scripts/docker-entrypoint.sh"]