# 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). We pass an SSH private key in as a BuildKit secret named # `ssh_key`. The secret is mounted into the build for just that one # RUN step, used to authenticate the git fetch, then unmounted — # nothing about the key ends up in any image layer or in # `docker history`. # # Get an SSH key with read access to the goods-editor-module repo # on Gitea registered first (the same key you use for git push works, # OR a dedicated deploy key with read-only access — recommended for # CI / servers). The public half goes in Gitea Settings → SSH Keys, # or as a deploy key on the repo itself. # # Then build, picking whichever invocation matches where the key is: # # 1. From a file on disk (typical local dev / deployment server): # docker build \ # --secret id=ssh_key,src=$HOME/.ssh/id_ed25519 \ # -t apparel-designer . # # 2. From an environment variable (typical CI — secret store # injects the key as an env var, no file ever touches disk): # docker build \ # --secret id=ssh_key,env=SSH_PRIVATE_KEY \ # -t apparel-designer . # (BuildKit 1.5+ supports the `env=` source.) # # 3. Via docker-compose: see docker-compose.yml — the `secrets:` # block there wires the same secret. Then: # SSH_KEY_FILE=$HOME/.ssh/id_ed25519 docker compose build # # Requires Docker 23+ (BuildKit is the default; older Docker needs # `DOCKER_BUILDKIT=1` exported in the environment). # # 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 using an SSH key passed in as a build secret. # # `--mount=type=secret,id=ssh_key` is a BuildKit feature that exposes # a host-provided secret at /run/secrets/ssh_key inside this single # RUN step. The secret is NEVER copied into any image layer; once # the RUN command finishes, the mount disappears and nothing about # the key remains in the built image. `docker history` won't show # it either. # # Steps: # 1. Create ~/.ssh with 0700 perms (ssh refuses to use a config # directory that's world-readable). # 2. ssh-keyscan git.kadil.dev → ~/.ssh/known_hosts. Without this, # ssh would prompt "Are you sure you want to continue connecting?" # with no stdin to answer it, and the install would hang forever. # 3. Run npm install with GIT_SSH_COMMAND pointing at the mounted # secret. npm shells out to git for the git+ssh: dependency, git # respects GIT_SSH_COMMAND, ssh uses the key at /run/secrets/ssh_key. # `IdentitiesOnly=yes` prevents ssh from trying any other key # it might find (none in this container, but defensive). # # Why use GIT_SSH_COMMAND rather than copying the key to ~/.ssh/id_ed25519: # the secret mount is a clean way to use a file without it ever # touching the filesystem proper. If we copied, we'd have to be # careful to `rm` it in the same RUN step before layer commit; with # GIT_SSH_COMMAND, the file is only ever at /run/secrets/, which # BuildKit clears automatically. # # If the build aborts here: # • "Permission denied (publickey)" → the secret was provided but # the key doesn't have access to the repo. Verify the public # half is registered in Gitea (account settings or deploy key). # • "required secret 'ssh_key' not provided" → forgot the `--secret` # flag on the build command (see docblock at top of file). # • Hangs at "Cloning into …" → ssh-keyscan didn't populate # known_hosts (network issue inside the build). Try again with # `--no-cache`. RUN --mount=type=secret,id=ssh_key \ mkdir -p -m 0700 ~/.ssh && \ ssh-keyscan git.kadil.dev >> ~/.ssh/known_hosts 2>/dev/null && \ GIT_SSH_COMMAND="ssh -i /run/secrets/ssh_key -o IdentitiesOnly=yes -o UserKnownHostsFile=/root/.ssh/known_hosts" \ 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"]