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"