From 4f3ad3a9b8873acbc3d84368bcb5e4c9aa19eefc Mon Sep 17 00:00:00 2001 From: nbkdoesntknowcoding Date: Thu, 9 Jul 2026 14:09:29 +0530 Subject: [PATCH] perf(docker): kill the cold-start chown bottleneck + warn about first-build time Cold `docker compose up -d --build` spent ~7 of ~9.7 min in three recursive `chown -R` steps that rewrite every inode over huge node_modules / Chromium trees (api 169.9s, workers 128.7s Chromium-layer export, web 123.6s). Root cause: files were COPYed as root then chowned to node in a separate RUN layer. The runtime never actually writes under /app (uploads to object storage, web sessions are sealed iron-session cookies, not Astro's fs session store), so the whole-tree chown was unnecessary. - api / collab / web: COPY --chown=node:node the app code + build output; drop `RUN chown -R node:node /app`. Prod node_modules stays root-owned and world-readable (read-only at runtime). - workers: drop `chown -R node:node /app /ms-playwright` - the browsers path is already world-readable/executable via the existing `chmod -R a+rX`, and Chromium's writable profile lives under HOME=/home/node. Stops rewriting the entire Chromium tree into a second image layer. - All four images still run as the non-root `node` user (unchanged). Base images, versions, and tsc/build config untouched. Also: self-host-init.sh now warns that the first build takes several minutes and looks idle while images build (not a hang), and points at `docker compose logs -f`. Co-Authored-By: Claude Opus 4.8 Signed-off-by: nbkdoesntknowcoding --- apps/api/Dockerfile | 26 ++++++++++++++------------ apps/api/Dockerfile.collab | 15 ++++++++------- apps/api/Dockerfile.workers | 18 +++++++++++------- apps/web/Dockerfile | 19 +++++++++++-------- scripts/self-host-init.sh | 9 ++++++++- 5 files changed, 52 insertions(+), 35 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 44372ae7c..885015886 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -40,27 +40,29 @@ COPY apps/api/package.json ./apps/api/ # Production deps only RUN pnpm install --frozen-lockfile --prod -# Copy compiled output from builder -COPY --from=builder /app/apps/api/dist ./apps/api/dist -COPY --from=builder /app/packages/shared ./packages/shared -COPY --from=builder /app/packages/schema/dist ./packages/schema/dist +# Copy compiled output from builder — owned by `node` at copy time (near-free), +# instead of a separate recursive `chown -R /app` layer. +COPY --chown=node:node --from=builder /app/apps/api/dist ./apps/api/dist +COPY --chown=node:node --from=builder /app/packages/shared ./packages/shared +COPY --chown=node:node --from=builder /app/packages/schema/dist ./packages/schema/dist # Migrations folder needed at runtime for drizzle -COPY apps/api/drizzle ./apps/api/drizzle +COPY --chown=node:node apps/api/drizzle ./apps/api/drizzle # Static templates (not emitted by tsc — copy from source) -COPY apps/api/src/templates ./apps/api/dist/templates +COPY --chown=node:node apps/api/src/templates ./apps/api/dist/templates # Email HTML templates (inliner reads dist/emails/html/*.html at runtime) -COPY apps/api/src/emails/html ./apps/api/dist/emails/html +COPY --chown=node:node apps/api/src/emails/html ./apps/api/dist/emails/html HEALTHCHECK --interval=10s --timeout=5s --retries=12 --start-period=40s \ CMD node -e "fetch('http://localhost:8080/health').then(r => process.exit(r.ok?0:1)).catch(()=>process.exit(1))" -# Run as the image's built-in unprivileged `node` user (uid 1000). /app is owned -# by root after the COPYs above; hand it (and node's home for any transient -# writes) to `node` so the runtime can write caches/tmp without root. Port 8080 -# is >1024, so the non-root bind is fine. -RUN chown -R node:node /app +# Run as the image's built-in unprivileged `node` user (uid 1000). App code and +# build output are COPYed --chown=node:node above; the prod node_modules stays +# root-owned and world-readable, which is all the runtime needs — nothing is +# written under /app at runtime (uploads → object storage, sessions → sealed +# cookies). This drops the ~170s recursive `chown -R /app`. Port 8080 is >1024, +# so the non-root bind is fine. USER node EXPOSE 8080 diff --git a/apps/api/Dockerfile.collab b/apps/api/Dockerfile.collab index e543d0e6f..2e0670105 100644 --- a/apps/api/Dockerfile.collab +++ b/apps/api/Dockerfile.collab @@ -33,13 +33,14 @@ COPY apps/api/package.json ./apps/api/ RUN pnpm install --frozen-lockfile --prod -COPY --from=builder /app/apps/api/dist ./apps/api/dist -COPY --from=builder /app/packages/shared ./packages/shared -COPY --from=builder /app/packages/schema/dist ./packages/schema/dist - -# Run as the image's built-in unprivileged `node` user (uid 1000). Port 1234 is -# >1024, so binding as non-root is fine. -RUN chown -R node:node /app +COPY --chown=node:node --from=builder /app/apps/api/dist ./apps/api/dist +COPY --chown=node:node --from=builder /app/packages/shared ./packages/shared +COPY --chown=node:node --from=builder /app/packages/schema/dist ./packages/schema/dist + +# Run as the image's built-in unprivileged `node` user (uid 1000). Build output is +# COPYed --chown=node:node above; the prod node_modules stays root-owned and +# world-readable (read-only at runtime — nothing is written under /app). This +# drops the recursive `chown -R /app`. Port 1234 is >1024, so non-root binds fine. USER node EXPOSE 1234 diff --git a/apps/api/Dockerfile.workers b/apps/api/Dockerfile.workers index 63f70af05..ab6c29349 100644 --- a/apps/api/Dockerfile.workers +++ b/apps/api/Dockerfile.workers @@ -45,16 +45,20 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright RUN cd apps/api && pnpm exec playwright install --with-deps chromium \ && chmod -R a+rX /ms-playwright -COPY --from=builder /app/apps/api/dist ./apps/api/dist -COPY --from=builder /app/packages/shared ./packages/shared -COPY --from=builder /app/packages/schema/dist ./packages/schema/dist +COPY --chown=node:node --from=builder /app/apps/api/dist ./apps/api/dist +COPY --chown=node:node --from=builder /app/packages/shared ./packages/shared +COPY --chown=node:node --from=builder /app/packages/schema/dist ./packages/schema/dist # Email HTML templates (inliner reads dist/emails/html/*.html at runtime) -COPY apps/api/src/emails/html ./apps/api/dist/emails/html +COPY --chown=node:node apps/api/src/emails/html ./apps/api/dist/emails/html # Drop to the image's built-in unprivileged `node` user (uid 1000). Chromium runs fine -# non-root inside the container's default seccomp profile. /app and the browser cache -# are handed to `node`; chromium also needs a writable HOME for its singleton profile. -RUN chown -R node:node /app /ms-playwright +# non-root inside the container's default seccomp profile. Build output is COPYed +# --chown=node:node above; /ms-playwright is already world-readable/executable via the +# `chmod -R a+rX` at install time above, and Chromium's writable singleton profile lives +# under HOME=/home/node (not the browsers path) — so it needs no ownership change. +# Dropping the recursive `chown -R /app /ms-playwright` avoids rewriting the entire +# Chromium tree into a second image layer (the ~130s layer-export cost). node_modules +# stays root-owned/read-only, which is all the runtime needs. USER node ENV HOME=/home/node diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 5c904a17a..88c5bd881 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -51,15 +51,18 @@ COPY apps/web/package.json ./apps/web/ RUN pnpm install --frozen-lockfile --prod -# Compiled output + workspace packages -COPY --from=builder /app/apps/web/dist ./apps/web/dist -COPY --from=builder /app/packages/shared ./packages/shared -COPY --from=builder /app/packages/schema/dist ./packages/schema/dist +# Compiled output + workspace packages — owned by `node` at copy time (near-free), +# instead of a separate recursive `chown -R /app` layer. +COPY --chown=node:node --from=builder /app/apps/web/dist ./apps/web/dist +COPY --chown=node:node --from=builder /app/packages/shared ./packages/shared +COPY --chown=node:node --from=builder /app/packages/schema/dist ./packages/schema/dist -# Run as the image's built-in unprivileged `node` user (uid 1000). Hand /app to -# `node` for any runtime cache writes. Port 4321 is >1024, so binding as non-root -# is fine. -RUN chown -R node:node /app +# Run as the image's built-in unprivileged `node` user (uid 1000). Build output is +# COPYed --chown=node:node above; the prod node_modules stays root-owned and +# world-readable. Nothing is written under /app at runtime — sessions are sealed +# iron-session cookies, not Astro's filesystem session store — so read-only +# node_modules is all the runtime needs. This drops the ~124s recursive +# `chown -R /app`. Port 4321 is >1024, so binding as non-root is fine. USER node EXPOSE 4321 diff --git a/scripts/self-host-init.sh b/scripts/self-host-init.sh index 4e11e2048..e2722b60a 100755 --- a/scripts/self-host-init.sh +++ b/scripts/self-host-init.sh @@ -126,5 +126,12 @@ echo "" echo "Next:" echo " docker compose up -d --build # (public repo: docker-compose.yml is the self-host stack)" echo "" -echo "Then open ${WEB_URL} and sign up at ${WEB_URL}/auth/local" +echo " [!] First build compiles the api, web, collab and workers images (the workers" +echo " image also downloads Chromium), so a cold first build takes several minutes" +echo " - much faster on later builds. Postgres and Redis go healthy in seconds," +echo " then the terminal will look idle for a few minutes while the images build." +echo " That is normal - it is NOT a hang, so don't Ctrl-C. Watch progress with:" +echo " docker compose logs -f" +echo "" +echo "When ready, open ${WEB_URL} and sign up at ${WEB_URL}/auth/local" echo "Connect an MCP client (Claude/Cursor) to: ${API_URL}/mcp"