Fix ssh from host not working in docker

This commit is contained in:
khalid@traclabs.com
2026-05-24 08:49:27 -05:00
parent 070c95e254
commit 6d63775800
2 changed files with 125 additions and 39 deletions

View File

@@ -1,5 +1,29 @@
# Multi-stage Dockerfile. # 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 # Why two stages
# ────────────── # ──────────────
# `canvas` is a native-binding npm package. It needs Cairo/Pango/etc. # `canvas` is a native-binding npm package. It needs Cairo/Pango/etc.
@@ -12,8 +36,7 @@
# (a) keep python3+make+g++ in the final image — inflates the image # (a) keep python3+make+g++ in the final image — inflates the image
# by ~200MB of build tools nothing uses at runtime, or # by ~200MB of build tools nothing uses at runtime, or
# (b) try to install --omit=dev in a runtime-only image — fails # (b) try to install --omit=dev in a runtime-only image — fails
# because canvas tries to compile and has no python (this was # because canvas tries to compile and has no python.
# the bug fixed in this revision).
# #
# Two-stage solves both: install + compile + prune dev deps in the # Two-stage solves both: install + compile + prune dev deps in the
# builder (which has the toolchain), then COPY the resulting # builder (which has the toolchain), then COPY the resulting
@@ -28,54 +51,93 @@
# ───────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
# Native-binding build prerequisites: # Build-time packages, grouped by why they're here:
# • python3 / make / g++ — node-gyp toolchain for compiling canvas #
# • cairo-dev / pango-dev / etc. — headers canvas links against # Native-binding compile toolchain (for `canvas` + node-gyp):
# These stay in the builder stage only; the runtime stage carries # python3, make, g++
# the matching runtime libraries (no -dev suffix) without the #
# toolchain. # 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 \ RUN apk add --no-cache \
cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev pixman-dev \ cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev pixman-dev \
python3 make g++ python3 make g++ \
git openssh-client
WORKDIR /app WORKDIR /app
# Copy package manifests first so docker can cache the npm install # Copy package manifests first so docker can cache the npm install
# layer when only application source changes. # layer when only application source changes.
COPY package*.json ./ COPY package*.json ./
RUN npm install
# 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 . . COPY . .
# Pull Google Fonts into ./fonts/ so they end up in the runtime image.
# If the build host has no network this step will produce missing-variant
# warnings; the build still succeeds and the server falls back to system
# fonts.
RUN npm run fetch-fonts || echo "Font fetch had warnings; continuing with whatever was downloaded."
RUN npm run build RUN npm run build
# Drop dev dependencies (vitest, playwright, eslint, etc.) so the # Drop dev dependencies (eslint, etc.) so the node_modules we copy
# node_modules we copy to runtime is production-only. This is the # to runtime is production-only. Without this step, the runtime
# step that was missing before — without it, the runtime stage had # stage would either ship dev deps it doesn't use, or re-run
# to re-run `npm install` from scratch, which would re-trigger # `npm install --omit=dev` in an image without the compile
# canvas's native compile in an image that doesn't have the # toolchain — which would fail when canvas tries to rebuild.
# toolchain. By pruning here, the runtime stage just copies the
# already-compiled directory.
RUN npm prune --production RUN npm prune --production
# ───────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
# Stage 2: runtime # Stage 2: runtime
# Carries only the runtime C libraries canvas links against plus # Carries only the runtime C libraries canvas links against plus
# the fonts the export pipeline needs. node_modules is copied # system fonts. node_modules is copied whole from the builder;
# whole from the builder; no `npm install` happens here. # no `npm install` happens here, so no SSH is needed at runtime.
# ───────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
FROM node:20-alpine FROM node:20-alpine
# Cairo + Pango are required by node-canvas at runtime. # Runtime packages:
# The font packages cover the proprietary template families (Impact, #
# Times, Courier, Arial) via free equivalents that fontconfig will # Native-binding runtime libs (matching the -dev headers in the
# alias, plus emoji rendering for sticker exports. # 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 \ RUN apk add --no-cache \
cairo pango libjpeg-turbo giflib librsvg pixman \ cairo pango libjpeg-turbo giflib librsvg pixman \
ttf-liberation ttf-dejavu font-noto font-noto-emoji \ ttf-liberation ttf-dejavu font-noto font-noto-emoji \
@@ -83,23 +145,26 @@ RUN apk add --no-cache \
WORKDIR /app WORKDIR /app
# Bring the production node_modules over from the builder rather # Bring the production node_modules over from the builder. This
# than installing fresh. This carries the already-compiled canvas # carries the already-compiled canvas .node binary AND the editor
# .node binary without needing python/make/g++ in this stage. # 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/node_modules ./node_modules
COPY --from=builder /app/package*.json ./ COPY --from=builder /app/package*.json ./
# Application files. server.js is the entry point; dist/ is the # Application files. server.js is the entry point; dist/ is the
# Vite build output served as static; fonts/ is the result of # Vite build output served as static.
# fetch-fonts above.
COPY server.js ./ COPY server.js ./
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/fonts ./fonts
RUN mkdir -p /app/uploads /app/exports RUN mkdir -p /app/uploads /app/exports
# Refresh fontconfig's cache so the newly-copied custom fonts are # Refresh fontconfig's cache so the apk-installed system fonts are
# discoverable. # discoverable. The module's own fonts under node_modules are
RUN fc-cache -f /app/fonts || true # 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 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1

View File

@@ -1,6 +1,27 @@
services: services:
apparel-designer: apparel-designer:
build: { context: ., dockerfile: Dockerfile } build:
context: .
dockerfile: Dockerfile
# Forward the host's SSH agent into the build. Needed because
# the `goods-editor` npm dependency uses a git+ssh: URL, so
# `npm install` has to authenticate against git.kadil.dev
# during the build. "default" means "use the SSH_AUTH_SOCK
# environment variable on the host" — matches the build
# command `docker build --ssh default`.
#
# The host running `docker compose build` must have:
# • An ssh-agent running with the right key loaded
# (verify with `ssh-add -l`).
# • Docker 23+ (BuildKit is the default; older versions
# need `DOCKER_BUILDKIT=1` exported).
#
# No SSH material is baked into the image — the agent is only
# available during the single RUN step in the Dockerfile that
# has `--mount=type=ssh`. See the Dockerfile docblock for the
# full mechanism.
ssh:
- default
container_name: apparel-designer container_name: apparel-designer
ports: ["3001:3001"] ports: ["3001:3001"]
volumes: volumes: