From 55e0a3cde65a2fb7cf8e8d10ec5bace2b2a9594e Mon Sep 17 00:00:00 2001 From: hibna Date: Sun, 2 Aug 2026 21:48:51 +0300 Subject: [PATCH] Make the API and web images build and run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image builds were never exercised: the docker job needs lint, and lint was failing, so nothing downstream of it ever ran. Four defects had accumulated behind that gate, each fatal on its own. - pnpm creates no node_modules for a package without dependencies, and @source/shared has none. Three COPY lines named that path and failed. - The production stage copied apps/api/dist, which tsc never wrote: tsconfig.base.json sets noEmit and no package overrides it. Rather than turn emit on — every @source/* package points main at its TypeScript source, and @source/proto derives daemon.proto's location from a /src/index.ts module URL — the stage now runs the sources through tsx, exactly as the migrate stage has always done. - tsx lives in apps/api/node_modules/.bin under pnpm's isolated layout, so the command only resolves from the package directory. - Both healthchecks probed localhost, which musl resolves to ::1 while the servers bind IPv4. Every probe was refused, so the containers sat unhealthy forever — and `docker compose up --wait`, which is how a panel installs this stack, waits for healthy. Also fixed the postgres healthcheck in both compose files. pg_isready without -h asks over the unix socket, which answers during the image's init phase before the server listens on TCP; the migrate container then started and died with ECONNREFUSED against a container Compose had just called healthy. Verified by running the full stack from docker-compose.panel.yml with locally built images: migrations and seed complete, api, web, daemon, postgres and redis all report healthy, and /api/health answers 200 through the web container's proxy. --- apps/api/Dockerfile | 50 +++++++++++++++++++++++++++++----------- apps/web/Dockerfile | 10 ++++++-- docker-compose.panel.yml | 6 ++++- docker-compose.yml | 6 ++++- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 24e21e0..f90ea92 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -7,12 +7,19 @@ FROM base AS deps COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY apps/api/package.json apps/api/ COPY packages/database/package.json packages/database/ +COPY packages/proto/package.json packages/proto/ COPY packages/shared/package.json packages/shared/ COPY packages/ui/package.json packages/ui/ -RUN pnpm install --frozen-lockfile --prod=false +# pnpm creates no node_modules for a workspace package that has no +# dependencies of its own, and @source/shared has none. The COPY lines below +# name that path, so give them an empty directory to find instead of failing +# the build on a path pnpm never made. +RUN pnpm install --frozen-lockfile --prod=false && mkdir -p packages/shared/node_modules -# --- Build --- -FROM base AS build +# --- Type check --- +# Not an artifact producer: tsconfig.base.json sets noEmit, so this stage only +# proves the sources compile. The runtime stages below run TypeScript directly. +FROM base AS typecheck COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules @@ -40,25 +47,40 @@ WORKDIR /app/packages/database CMD ["sh", "-c", "pnpm exec drizzle-kit push --force && pnpm exec tsx src/migrate.ts && pnpm exec tsx src/seed.ts"] # --- Production --- -FROM node:20-alpine AS production -RUN corepack enable && corepack prepare pnpm@9.15.4 --activate +# +# Runs the TypeScript sources through tsx rather than a compiled bundle, the +# same way the migrate stage above already does. +# +# The workspace packages are consumed as TypeScript: every @source/* package +# points `main` at ./src/index.ts, which is what lets `pnpm dev` and Vite read +# them without a build step. A compiled entry point would resolve those bare +# imports to TypeScript files Node cannot load, and @source/proto derives the +# path of daemon.proto from its own module URL — a rule written for +# `/src/index.ts`. Following that decision here keeps one resolution model for +# development and production instead of two that disagree. +FROM base AS production WORKDIR /app ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules -COPY --from=build /app/apps/api/dist ./apps/api/dist -COPY --from=build /app/apps/api/package.json ./apps/api/ -COPY --from=build /app/packages/database/dist ./packages/database/dist -COPY --from=build /app/packages/database/package.json ./packages/database/ -COPY --from=build /app/packages/shared/dist ./packages/shared/dist -COPY --from=build /app/packages/shared/package.json ./packages/shared/ +COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules -COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules COPY pnpm-workspace.yaml package.json ./ +COPY apps/api ./apps/api +COPY packages/database ./packages/database +COPY packages/proto ./packages/proto +COPY packages/shared ./packages/shared EXPOSE 3000 -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD wget -qO- http://localhost:3000/api/health || exit 1 +# 127.0.0.1, not localhost: musl resolves localhost to ::1 first and the +# server binds IPv4, so the probe was refused on every run and the +# container never left the unhealthy state. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1 -CMD ["node", "apps/api/dist/index.js"] +# From the package directory, the same way `pnpm dev` runs it: pnpm's isolated +# node_modules puts tsx in apps/api/node_modules/.bin, not in the workspace +# root, so `pnpm exec` only finds it here. +WORKDIR /app/apps/api +CMD ["pnpm", "exec", "tsx", "src/index.ts"] diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 4c32a5f..b0401de 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -8,7 +8,11 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY apps/web/package.json apps/web/ COPY packages/shared/package.json packages/shared/ COPY packages/ui/package.json packages/ui/ -RUN pnpm install --frozen-lockfile --prod=false +# pnpm creates no node_modules for a workspace package that has no +# dependencies of its own, and @source/shared has none. The COPY lines below +# name that path, so give them an empty directory to find instead of failing +# the build on a path pnpm never made. +RUN pnpm install --frozen-lockfile --prod=false && mkdir -p packages/shared/node_modules # --- Build --- FROM base AS build @@ -32,6 +36,8 @@ COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/apps/web/dist /usr/share/nginx/html EXPOSE 80 -HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost/health || exit 1 +# 127.0.0.1 for the same reason as the API image: localhost resolves to +# ::1, where nginx is not listening. +HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1/health || exit 1 CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-compose.panel.yml b/docker-compose.panel.yml index 31eb0f9..43f87a1 100644 --- a/docker-compose.panel.yml +++ b/docker-compose.panel.yml @@ -32,7 +32,11 @@ services: expose: - "5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"] + # -h forces a TCP probe. Without it pg_isready talks over the unix + # socket, which answers during the image's init phase while the + # server is not listening on 5432 yet — dependents then start and + # get ECONNREFUSED from a container Compose just called healthy. + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U ${DB_USER:-gamepanel}"] interval: 10s timeout: 5s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index 17bc543..efbd17f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,11 @@ services: expose: - "5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"] + # -h forces a TCP probe. Without it pg_isready talks over the unix + # socket, which answers during the image's init phase while the + # server is not listening on 5432 yet — dependents then start and + # get ECONNREFUSED from a container Compose just called healthy. + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U ${DB_USER:-gamepanel}"] interval: 10s timeout: 5s retries: 5