# syntax=docker/dockerfile:1.7 # # Polaris Task Force — production image for Coolify. # # Stack: Next.js 16 (output: "standalone") + Payload CMS 3.79. # Bun 1.3.11 is build-time ONLY (install deps + run the Next build on the # build server). The runner image is Node-only: the production host has an # older CPU without AVX/AVX2 — which Bun >= 1.2 requires — so `bun` can # never execute there. Runtime uses plain Node for everything: # - web server: node server.js (Next standalone) # - payload bins: node node_modules/payload/bin.js (game-tick / # market-tick / migrate; the payload CLI transpiles the TS config itself) # - Discord bot: node --import tsx src/bot/index.ts # # See DEPLOYMENT.md for the full Coolify workflow (per-env Postgres, # env vars, scheduled jobs, persistent media volume, rollback). # ───────────────────────────── base ───────────────────────────── # Node 22 alpine + Bun, shared by all three stages. FROM node:22-alpine AS base RUN apk add --no-cache libc6-compat curl wget bash SHELL ["/bin/bash", "-c"] # Bun 1.3.11, installed to /usr/local/bin so it is on PATH for the BUILD # stages only (base -> prod-deps -> builder). The runner stage is NOT built # from this base — it uses plain node:22-alpine because the production host # CPU lacks AVX/AVX2 (required by Bun >= 1.2) and Bun must never execute # there. Two gotchas this setup avoids: # - `ARG PATH=...` does NOT set PATH in RUN environments — ARG values are # only substituted into build instructions, never injected as env vars # (that produced "bun: command not found" in the prod-deps stage). # - `$HOME` is empty during `docker build`, so the installer's default # `$HOME/.bun` would resolve to `/.bun` — off-PATH and root-owned. ENV BUN_INSTALL=/usr/local RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.11" \ && bun --version # ────────────────────────── prod-deps ──────────────────────────── # Production-only install. Used at runtime alongside the standalone # server so the Payload CLI / migrations / bin scripts have every # package they need (the Next standalone prunes node_modules to only # what the web server imports — pruned tree does NOT include `payload`, # `@payloadcms/*` admin libs, drizzle, etc.). FROM base AS prod-deps WORKDIR /app COPY package.json bun.lock ./ # Retry with a fresh cache dir per attempt: a corrupt tarball download # (flaky registry/proxy → "Integrity check failed for tarball: drizzle-kit") # must never be reused from a bun cache, across attempts or cached layers. RUN for i in 1 2 3; do \ rm -rf /tmp/bun-cache; \ if bun install --production --frozen-lockfile --cache-dir=/tmp/bun-cache; then \ exit 0; \ fi; \ echo "bun install (production) attempt $i/3 failed, retrying in 5s..."; \ sleep 5; \ done; \ echo "bun install (production) failed after 3 attempts"; \ exit 1 # ─────────────────────────── builder ───────────────────────────── # Full install (incl. devDeps) + Next.js standalone build. FROM base AS builder # `git` is needed only at build time so `generate:version-info` can populate # commitHash / gitDescribe / commitDate in src/generated/versionInfo.json # (the script gracefully no-ops without it, but we want the metadata in the # admin VersionOverlay). Not carried into the runner image. RUN apk add --no-cache git WORKDIR /app COPY package.json bun.lock ./ # Same retry + fresh-cache pattern as prod-deps (corrupt tarball downloads # fail bun's integrity check; a fresh --cache-dir per attempt prevents reuse). RUN for i in 1 2 3; do \ rm -rf /tmp/bun-cache; \ if bun install --frozen-lockfile --cache-dir=/tmp/bun-cache; then \ exit 0; \ fi; \ echo "bun install attempt $i/3 failed, retrying in 5s..."; \ sleep 5; \ done; \ echo "bun install failed after 3 attempts"; \ exit 1 COPY . . # next.config.mjs requires `output: "standalone"` (already set). # `bun run build` == `bun run generate:version-info && next build --webpack`. # We deliberately DO NOT run `payload generate:importmap` / `generate:types` # here: both bootstrap the Payload config, which connects to the database # (none is available at build time). The committed artifacts in the repo # are the source of truth and ship as-is. # # Build-time-only env shim: Next.js prerenders /login and other pages that # import `@payload-config` (Payload.init runs at import time during static # generation). Payload refuses to init without PAYLOAD_SECRET, and Nodemailer # emits a (non-fatal) warning without EMAIL_* — both come from real env vars # at runtime, NOT baked into the image. We pass stand-in values via ARGs to # let the build's static-gen step complete. These values never ship: ENV in # later stages and the runtime container's env (set by Coolify per env) win. ARG PAYLOAD_SECRET_BUILD=dummy-build-secret-not-used-at-runtime ARG EMAIL_HOST_BUILD=localhost ARG EMAIL_PORT_BUILD=587 ENV PAYLOAD_SECRET=$PAYLOAD_SECRET_BUILD \ EMAIL_HOST=$EMAIL_HOST_BUILD \ EMAIL_PORT=$EMAIL_PORT_BUILD RUN bun run build # ─────────────────────────── runner ────────────────────────────── # Final production image: Next standalone runtime + full prod deps + # src/migrations. Node-only (see header): the production host CPU has no # AVX/AVX2, so Bun cannot run there. Payload bins run via # `node node_modules/payload/bin.js `; the Discord bot via # `node --import tsx src/bot/index.ts` (tsx resolves the @/ + @payload-config # tsconfig path aliases that plain Node type-stripping cannot). FROM node:22-alpine AS runner RUN apk add --no-cache libc6-compat wget WORKDIR /app ENV NODE_ENV=production \ NEXT_TELEMETRY_DISABLED=1 \ PORT=3000 \ HOSTNAME=0.0.0.0 # The `node` user already exists in `node:*-alpine` images. RUN mkdir -p /app/media \ && chown -R node:node /app # 1) Next.js standalone runtime (server.js + traced node_modules + .next). # `.next/standalone` is laid out flat at /app, so server.js ends up at # /app/server.js and its traced node_modules at /app/node_modules. COPY --from=builder --chown=node:node /app/.next/standalone ./ # 2) Static assets (standalone does NOT include these). COPY --from=builder --chown=node:node /app/.next/static ./.next/static # 3) Public assets (standalone does NOT include these either). COPY --from=builder --chown=node:node /app/public ./public # 4) Overlay the FULL production node_modules on top of the pruned # standalone one. Docker COPY merges directories file-by-file # (existing files overwritten, others preserved), so the result # is the union — effectively the full prod install — while keeping # any Next-internal files the standalone tree brought along. COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules # 5) Source + top-level config so `bun run payload ` and # `payload migrate` work via `docker exec`. These read # @payload-config (alias in tsconfig.json) and the Payload # migration files under src/migrations/*. COPY --from=builder --chown=node:node /app/src ./src COPY --from=builder --chown=node:node /app/package.json ./package.json COPY --from=builder --chown=node:node /app/tsconfig.json ./tsconfig.json COPY --from=builder --chown=node:node /app/bun.lock ./bun.lock COPY --from=builder --chown=node:node /app/next.config.mjs ./next.config.mjs COPY --from=builder --chown=node:node /app/postcss.config.mjs ./postcss.config.mjs COPY --from=builder --chown=node:node /app/components.json ./components.json COPY --from=builder --chown=node:node /app/drizzle.config.ts ./drizzle.config.ts # Persistent storage mount point for locally-stored Payload uploads. # In Coolify: attach a Persistent Storage Volume here so uploaded media # survives container restarts and rollbacks. (See DEPLOYMENT.md.) VOLUME ["/app/media"] EXPOSE 3000 # Liveness probe hooked into our `/api/health` route (no DB dependency, # so a transient DB outage does NOT cycle the container). HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD wget -qO- "http://127.0.0.1:${PORT:-3000}/api/health" >/dev/null 2>&1 || exit 1 COPY --chmod=755 docker-entrypoint.sh /docker-entrypoint.sh USER node # Next.js standalone server (node process). Plain `node server.js` — Bun is # installed for the `docker exec` one-shot bins, not for the web runtime # (Next is shipped and tested on Node). ENTRYPOINT ["/bin/sh", "/docker-entrypoint.sh"] CMD ["node", "server.js"]