Everything needed to build and run this on Dokploy at linkdr.serfaty.site, plus the two things that turned out to be broken the moment it left a laptop. The build did not work in a container at all. `lib/auth.ts` throws when AUTH_SECRET or NEXT_PUBLIC_APP_URL is missing — correct at boot, wrong during `next build`, which imports every route module with NODE_ENV=production and none of the runtime secrets. The only way past it was baking a session key into an image layer, which is worse than the problem the guard exists to prevent. Both checks now skip NEXT_PHASE=phase-production-build and still fire on a real boot. Corepack in node:22.12-alpine ships expired npm registry signing keys and dies before it can download pnpm, so the image installs corepack first and prepares the pinned version explicitly. The image is the standalone trace, which needs outputFileTracingRoot at the REPO root: pnpm hoists to a root .pnpm store and tracing from apps/web silently omits every workspace package. 427MB, runs as non-root, and its healthcheck talks to Postgres — a container that cannot reach its database must never enter rotation, because a deploy that goes green and then 500s does not roll back. DEMO_LOGIN is a login bypass under NODE_ENV=production and there is no honest way to describe it otherwise. It is a separate variable from ALLOW_DEV_LOGIN so that copying a dev .env into a real environment cannot enable it by accident, it still only affects the one seeded number, and it prints a boot warning every single start so it cannot be forgotten. That deployment holds nothing but fixtures. It comes out before the platform sees a real signup. Also: /api/health, and next/image hosts corrected to the Spaces bucket rather than the R2 one this stopped using. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
132 lines
6.3 KiB
Docker
132 lines
6.3 KiB
Docker
# syntax=docker/dockerfile:1.7
|
|
|
|
###############################################################################
|
|
# Linkdr — production image
|
|
#
|
|
# Four stages so that a code change does not reinstall the dependency tree:
|
|
# `deps` is keyed on the lockfile alone, and Docker reuses it until that file
|
|
# changes. Installing inside the same layer as the source would rebuild ~1GB of
|
|
# node_modules on every commit.
|
|
#
|
|
# Node 22 rather than 23: 22 is the active LTS line, 23 is not and stops getting
|
|
# fixes. package.json says >=20; this pins the version we actually ship.
|
|
###############################################################################
|
|
|
|
ARG NODE_VERSION=22.12.0-alpine
|
|
|
|
# ─────────────────────────────── base ───────────────────────────────
|
|
FROM node:${NODE_VERSION} AS base
|
|
# libc6-compat: several native-ish npm packages ship glibc builds and fail on
|
|
# musl without it. Cheap, and the failure it prevents is an obscure one.
|
|
RUN apk add --no-cache libc6-compat
|
|
|
|
# pnpm, pinned to the version in `packageManager` so the image builds with the
|
|
# same resolver the lockfile was written by.
|
|
#
|
|
# `corepack enable` alone is not enough: the corepack bundled with Node 22.12
|
|
# carries expired npm registry signing keys and dies with "Cannot find matching
|
|
# keyid" before it ever downloads pnpm. Updating corepack first refreshes those
|
|
# keys, and `prepare --activate` fetches the exact version rather than asking
|
|
# the registry what "latest" is at build time.
|
|
ARG PNPM_VERSION=9.15.4
|
|
RUN npm install -g corepack@latest && corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
|
WORKDIR /app
|
|
|
|
# ─────────────────────────────── deps ───────────────────────────────
|
|
# Every package.json in the workspace, and nothing else. Adding a source file
|
|
# here would defeat the layer cache this stage exists for.
|
|
FROM base AS deps
|
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc* ./
|
|
COPY apps/web/package.json apps/web/
|
|
COPY packages/api/package.json packages/api/
|
|
COPY packages/db/package.json packages/db/
|
|
COPY packages/geocode/package.json packages/geocode/
|
|
COPY packages/notify/package.json packages/notify/
|
|
COPY packages/shared/package.json packages/shared/
|
|
COPY packages/storage/package.json packages/storage/
|
|
|
|
# --frozen-lockfile: a lockfile that does not match package.json is a build
|
|
# failure, not something to silently resolve differently than developers did.
|
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
|
pnpm config set store-dir /pnpm/store && \
|
|
pnpm install --frozen-lockfile
|
|
|
|
# ─────────────────────────────── build ──────────────────────────────
|
|
FROM base AS build
|
|
WORKDIR /app
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
|
COPY --from=deps /app/packages ./packages
|
|
COPY . .
|
|
|
|
# NEXT_PUBLIC_* is inlined into the client bundle at BUILD time — reading it
|
|
# from the container's environment at runtime is too late. Anything the browser
|
|
# must know therefore has to arrive here as a build argument.
|
|
ARG NEXT_PUBLIC_APP_URL
|
|
ARG NEXT_PUBLIC_CITY_NAME
|
|
ARG NEXT_PUBLIC_CITY_LAT
|
|
ARG NEXT_PUBLIC_CITY_LNG
|
|
ARG NEXT_PUBLIC_SENTRY_DSN
|
|
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
|
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \
|
|
NEXT_PUBLIC_CITY_NAME=$NEXT_PUBLIC_CITY_NAME \
|
|
NEXT_PUBLIC_CITY_LAT=$NEXT_PUBLIC_CITY_LAT \
|
|
NEXT_PUBLIC_CITY_LNG=$NEXT_PUBLIC_CITY_LNG \
|
|
NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN \
|
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
|
|
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
|
NODE_ENV=production
|
|
|
|
# Every page that touches the database is `force-dynamic`, so no database is
|
|
# needed to build. If that ever stops being true this line is where it breaks,
|
|
# loudly, rather than at deploy time.
|
|
RUN pnpm --filter @linkdr/web build
|
|
|
|
# ─────────────────────────────── tools ──────────────────────────────
|
|
# Migrations, seeding and the asset migration. These need tsx, drizzle-kit and
|
|
# the drizzle/ SQL folder, none of which belong in the image that serves
|
|
# traffic — so they get their own target rather than bloating the runtime.
|
|
#
|
|
# Run as a one-shot alongside the app (see docker-compose.dokploy.yml), not as
|
|
# a long-lived service:
|
|
# docker compose run --rm migrate
|
|
FROM base AS tools
|
|
WORKDIR /app
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY --from=deps /app/packages ./packages
|
|
COPY . .
|
|
ENV NODE_ENV=production
|
|
# Idempotent: drizzle records applied migrations, so re-running is a no-op and
|
|
# a restarted container cannot double-apply anything.
|
|
CMD ["pnpm", "--filter", "@linkdr/db", "migrate"]
|
|
|
|
# ────────────────────────────── runtime ─────────────────────────────
|
|
FROM base AS runtime
|
|
WORKDIR /app
|
|
|
|
ENV NODE_ENV=production \
|
|
NEXT_TELEMETRY_DISABLED=1 \
|
|
PORT=3000 \
|
|
HOSTNAME=0.0.0.0
|
|
|
|
RUN addgroup --system --gid 1001 nodejs && \
|
|
adduser --system --uid 1001 nextjs
|
|
|
|
# The standalone bundle ships its own minimal node_modules and server.js.
|
|
# `public` and `.next/static` are NOT included in it and must be copied
|
|
# separately, or the site renders with no CSS and no images.
|
|
COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
|
COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
|
COPY --from=build --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
|
|
|
USER nextjs
|
|
EXPOSE 3000
|
|
|
|
# Talks to Postgres, so a container that cannot reach its database never enters
|
|
# rotation. start-period covers first boot; see app/api/health/route.ts.
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
|
CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
|
|
CMD ["node", "apps/web/server.js"]
|