# Multi-stage Dockerfile. # # How to build # ──────────── # This Dockerfile needs SSH access to git.kadil.dev during the # `npm install` step (the `goods-editor` dependency is a git+ssh: # URL). The build forwards your host's SSH agent into the build # context via BuildKit's --ssh flag, so no keys are ever baked into # the image. # # Prerequisites on the build host: # • Docker 23+ (BuildKit is the default; older Docker needs # `DOCKER_BUILDKIT=1` exported in the environment). # • An ssh-agent running with the key that has read access to # git.kadil.dev loaded into it: # ssh-add ~/.ssh/id_ed25519 # or whichever key # ssh-add -l # verify # # Build command: # docker build --ssh default -t apparel-designer . # # With docker-compose, the corresponding wiring is in # docker-compose.yml (build.ssh: ["default"]). Compose forwards # the agent the same way; just run: # docker compose build # # Why two stages # ────────────── # `canvas` is a native-binding npm package. It needs Cairo/Pango/etc. # at runtime, and python3+make+g++ at install-time to compile its # native bindings (no prebuilt binary covers the alpine musl+napi+linux # combination at this canvas version; prebuild-install falls back to # node-gyp, which needs the compile toolchain). # # The naive single-stage approach would either: # (a) keep python3+make+g++ in the final image — inflates the image # by ~200MB of build tools nothing uses at runtime, or # (b) try to install --omit=dev in a runtime-only image — fails # because canvas tries to compile and has no python. # # Two-stage solves both: install + compile + prune dev deps in the # builder (which has the toolchain), then COPY the resulting # node_modules into a slim runtime that only carries the C runtime # libs canvas needs at execution time. # ───────────────────────────────────────────────────────────────── # Stage 1: builder # Installs all deps (including dev), compiles native bindings, # builds the Vite bundle, then prunes dev deps so node_modules is # production-ready before we copy it. # ───────────────────────────────────────────────────────────────── FROM node:20-alpine AS builder # Build-time packages, grouped by why they're here: # # Native-binding compile toolchain (for `canvas` + node-gyp): # python3, make, g++ # # Native-binding headers (canvas links against these at compile # time; the runtime stage carries the matching shared libs): # cairo-dev, pango-dev, libjpeg-turbo-dev, giflib-dev, # librsvg-dev, pixman-dev # # Git over SSH (for the `goods-editor` git+ssh: dependency): # git, openssh-client # # git is npm's transport for git+ssh URLs; openssh-client provides # the ssh binary that git invokes plus the ssh-keyscan utility we # use below to populate known_hosts. RUN apk add --no-cache \ cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev pixman-dev \ python3 make g++ \ git openssh-client WORKDIR /app # Copy package manifests first so docker can cache the npm install # layer when only application source changes. COPY package*.json ./ # Install dependencies with SSH agent forwarded from the host. # # `--mount=type=ssh` is a BuildKit feature that exposes the host's # SSH agent socket inside this single RUN step. The agent is NOT # baked into the image — it's only available for the duration of # this command. After the layer is committed, no SSH material # remains. # # Steps: # 1. Create ~/.ssh with 0700 perms (ssh refuses to read keys from # world-readable directories). # 2. ssh-keyscan git.kadil.dev — adds the server's host key to # known_hosts so ssh doesn't prompt "Are you sure you want to # continue connecting?" during the npm fetch. Without this, # git's ssh invocation hangs forever (no stdin to answer the # prompt from inside a Docker build). # 3. npm install — for the git+ssh dep, npm shells out to git, # which shells out to ssh, which uses the forwarded agent. # # If the build aborts here with "Could not read from remote # repository" or "Permission denied (publickey)", the agent isn't # forwarding correctly. Verify with `ssh-add -l` on the host (the # key should be listed) and that you're invoking with `--ssh default`. RUN --mount=type=ssh \ mkdir -p -m 0700 ~/.ssh && \ ssh-keyscan git.kadil.dev >> ~/.ssh/known_hosts 2>/dev/null && \ npm install COPY . . RUN npm run build # Drop dev dependencies (eslint, etc.) so the node_modules we copy # to runtime is production-only. Without this step, the runtime # stage would either ship dev deps it doesn't use, or re-run # `npm install --omit=dev` in an image without the compile # toolchain — which would fail when canvas tries to rebuild. RUN npm prune --production # ───────────────────────────────────────────────────────────────── # Stage 2: runtime # Carries only the runtime C libraries canvas links against plus # system fonts. node_modules is copied whole from the builder; # no `npm install` happens here, so no SSH is needed at runtime. # ───────────────────────────────────────────────────────────────── FROM node:20-alpine # Runtime packages: # # Native-binding runtime libs (matching the -dev headers in the # builder stage): # cairo, pango, libjpeg-turbo, giflib, librsvg, pixman # # System fonts for the export pipeline. node-canvas's text rendering # uses fontconfig to resolve family names; these provide free # equivalents of the proprietary template families (Impact, Times, # Courier, Arial) plus emoji support for sticker exports. The # editor module's own bundled fonts (under node_modules/goods-editor/ # fonts/) are registered explicitly via registerFont() — they don't # go through fontconfig and don't need to live in /usr/share/fonts. RUN apk add --no-cache \ cairo pango libjpeg-turbo giflib librsvg pixman \ ttf-liberation ttf-dejavu font-noto font-noto-emoji \ fontconfig WORKDIR /app # Bring the production node_modules over from the builder. This # carries the already-compiled canvas .node binary AND the editor # module's own fonts (under node_modules/goods-editor/fonts/), so # no separate font-copy step is needed. COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package*.json ./ # Application files. server.js is the entry point; dist/ is the # Vite build output served as static. COPY server.js ./ COPY --from=builder /app/dist ./dist RUN mkdir -p /app/uploads /app/exports # Refresh fontconfig's cache so the apk-installed system fonts are # discoverable. The module's own fonts under node_modules are # registered explicitly by the module's server code (via # registerFont() — see the host's server.js comment about font # registration moving to the module), so they don't need to be # in fontconfig's index. RUN fc-cache -f || true HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1 EXPOSE 3001 ENV NODE_ENV=production CMD ["node", "server.js"]