# 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"]