# 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 a base64-encoded SSH private key as a BuildKit # secret named `ssh_key`, then decode it inside the build. # # Why base64-encoded rather than raw PEM # ─────────────────────────────────────── # The primary deploy target is Dokploy, whose Build-time Secrets UI # is a dotenv-parsed textarea (one KEY=VALUE per line). PEM keys are # multi-line and break dotenv parsing. Base64 collapses the key to a # single line of safe ASCII that any dotenv parser accepts. We use # base64 everywhere (Dokploy and local builds) so the Dockerfile has # exactly one input format — no branching logic for raw vs encoded. # # Register a deploy key first # ───────────────────────────── # Generate a read-only deploy key dedicated to this build and add # its public half to Gitea (repo Settings → Deploy Keys): # ssh-keygen -t ed25519 -f ~/.ssh/goods-editor-deploy -N "" -C "deploy" # cat ~/.ssh/goods-editor-deploy.pub # → paste into Gitea # # Then base64-encode the PRIVATE half for build-time use: # base64 < ~/.ssh/goods-editor-deploy | tr -d '\n' | pbcopy # The `tr -d '\n'` strips line wrapping so the output is a single # physical line, which the Dokploy textarea (and dotenv parsers in # general) require. # # Dokploy # ─────── # Paste into the application's Environment → Build-time Secrets # textarea as a single line: # ssh_key= # Then redeploy. # # Gitea SSH port note # ─────────────────── # Our self-hosted Gitea is configured with SSH on port 222, not the # default 22. Port 22 on git.kadil.dev hits the Docker host's # OpenSSH (not Gitea), so deploy keys added through Gitea's UI # wouldn't authorize anything if we connected on 22. The package.json # git+ssh URL specifies `:222` in the host portion to route through # Gitea's own SSH server, and the `ssh-keyscan -p 222` below scans # the matching port so the known_hosts entry uses the same # [host]:port form that the URL resolves to. # # Local build with the same approach # ─────────────────────────────────── # base64 < ~/.ssh/goods-editor-deploy | tr -d '\n' > /tmp/key.b64 # docker build --secret id=ssh_key,src=/tmp/key.b64 -t apparel-designer . # rm /tmp/key.b64 # # Or via env var: # SSH_KEY_B64="$(base64 < ~/.ssh/goods-editor-deploy | tr -d '\n')" \ # docker build --secret id=ssh_key,env=SSH_KEY_B64 -t apparel-designer . # # 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. # # Dokploy-specific note: Dokploy's Build-time Secrets UI is a single # textarea that gets parsed as dotenv (one KEY=VALUE per line), # which doesn't tolerate multi-line values. PEM-formatted SSH keys # ARE multi-line. The workaround is to base64-encode the key before # pasting into Dokploy and decode here at use-time. Base64 produces # a single line of ASCII with no characters that confuse dotenv # parsers. # # Encode locally: # base64 < ~/.ssh/goods-editor-deploy | tr -d '\n' | pbcopy # Then paste in Dokploy as: # ssh_key= # # Steps in this RUN: # 1. mkdir ~/.ssh with 0700 (ssh refuses world-readable config dirs) # 2. ssh-keyscan git.kadil.dev so we don't hang on the # "continue connecting?" prompt # 3. base64-decode /run/secrets/ssh_key into /tmp/ssh_key. The # decoded file holds the original PEM private key. # 4. chmod 0600 the decoded key so ssh accepts it. # 5. Run npm install with GIT_SSH_COMMAND pointing at the decoded # key. IdentitiesOnly=yes prevents ssh from trying any other # key it might find. # 6. rm the decoded key. Since we created it in the same RUN, it # never gets committed to a layer. # # If the build aborts here: # • "Permission denied (publickey)" → the key in Dokploy doesn't # match the deploy key registered in Gitea. Re-base64-encode and # re-paste, OR re-check the Gitea deploy key. # • "Identity file /tmp/ssh_key not accessible: No such file or # directory" → the secret wasn't mounted. Verify the Dokploy # Build-time Secrets textarea contains exactly `ssh_key=...` as # a single line. # • "base64: invalid input" → the Dokploy textarea has trailing # whitespace, line breaks inside the base64, or extra content. # Re-paste from a fresh `base64 < key | tr -d '\n' | pbcopy`. RUN --mount=type=secret,id=ssh_key \ mkdir -p -m 0700 ~/.ssh && \ ssh-keyscan -p 222 git.kadil.dev >> ~/.ssh/known_hosts 2>/dev/null && \ base64 -d /run/secrets/ssh_key > /tmp/ssh_key && \ chmod 0600 /tmp/ssh_key && \ GIT_SSH_COMMAND="ssh -i /tmp/ssh_key -o IdentitiesOnly=yes -o UserKnownHostsFile=/root/.ssh/known_hosts" \ npm install && \ rm -f /tmp/ssh_key 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"]