# syntax=docker/dockerfile:1.28 # # Inkweld Backend Dockerfile # Uses Bun's --compile flag to create a single-file executable with embedded native modules. # This produces a ~200MB image instead of ~1.5GB. # # The key challenge is native module embedding: node-gyp-build dynamically resolves .node # files at runtime, but Bun's compiler needs static requires to embed them. We patch # classic-level/binding.js to directly require the N-API prebuild before compilation. # # Multi-arch build strategy: # - When FRONTEND_PREBUILT build arg is set, skip frontend build and use pre-built dist # - This allows CI to build frontend once on AMD64 and share with ARM64 builds # Frontend builder stage (Angular) - only used when not using pre-built frontend FROM oven/bun:1.4.4 AS frontend-builder WORKDIR /app # Angular CLI 22 requires Node ^22.22.3 || ^24.15.0 || >=26; the bun image's # `node` shim reports itself as Node 24.3.0 and fails the CLI's version check, # so overwrite it with a real Node binary for `ng build`. COPY --from=node:24-bookworm-slim /usr/local/bin/node /usr/local/bin/node # Check if we should skip building (pre-built frontend provided) ARG FRONTEND_PREBUILT=false # Install git for dependencies that need to be cloned RUN if [ "$FRONTEND_PREBUILT" = "false" ]; then \ apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*; \ fi # Workspace-aware install: the frontend resolves `@inkweld/prosemirror/*` # via tsconfig path aliases that point at `../packages/inkweld-prosemirror/src/*`. # That package in turn imports `prosemirror-model` and `yjs`, so the workspace # package needs its own `node_modules`. We install at the workspace root first # (which links the `packages/*` workspace deps), then per-app inside `frontend/`. COPY package.json bun.lock* ./ COPY packages ./packages COPY frontend/bun.lock frontend/package.json ./frontend/ # copy-changelog.mjs ships the repo-root changelog as an in-app asset COPY CHANGELOG.md ./ # Install workspace deps so `packages/inkweld-prosemirror/node_modules/{prosemirror-model,yjs}` # get linked. The root `postinstall` script chains into `frontend && backend && docs/site` # installs that we don't want here, so override it. RUN if [ "$FRONTEND_PREBUILT" = "false" ]; then \ bun install --frozen-lockfile --ignore-scripts; \ fi WORKDIR /app/frontend # Install with --ignore-scripts to disable arbitrary postinstall execution, # then explicitly run esbuild's binary installer (the only postinstall the # Angular build genuinely needs). RUN if [ "$FRONTEND_PREBUILT" = "false" ]; then \ bun install --frozen-lockfile --ignore-scripts \ && node node_modules/esbuild/install.js; \ fi COPY frontend ./ # `--ignore-scripts` above skipped the frontend postinstall. Run its steps # explicitly (the same ones a developer's `bun install` runs), because the # build relies on their output: # fetch-publish-fonts -> public/assets/fonts/*.ttf, embedded into PDFs by # Typst; verified against scripts/font-digests.json # generate-font-licenses-> public/assets/fonts/LICENSE.txt, the OFL notice # the redistributed woff2/ttf faces require # copy-changelog -> public/assets/CHANGELOG.md for the in-app changelog # Then build, verify output exists, and compress WASM files with Brotli # (they're served with Content-Encoding: br). RUN if [ "$FRONTEND_PREBUILT" = "false" ]; then \ node scripts/fetch-publish-fonts.mjs \ && node scripts/generate-font-licenses.mjs \ && node scripts/copy-changelog.mjs \ && bun run build \ && if [ ! -d /app/frontend/dist ]; then \ echo "ERROR: frontend build did not produce /app/frontend/dist"; \ ls -la /app/frontend || true; \ exit 1; \ fi \ && node scripts/compress-wasm.js; \ fi # Backend builder stage - produces a single compiled binary FROM oven/bun:1.4.4 AS backend-builder WORKDIR /app/backend # Install git for dependencies that need to be cloned RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* # No build tools needed - we use bun:sqlite (native to Bun), not better-sqlite3 # better-sqlite3 is only needed for the Node.js runner (node-runner.ts) # Native modules (classic-level, bcrypt) ship with prebuilds and are patched later # Install dependencies with --ignore-scripts to skip better-sqlite3's node-gyp build # (Bun doesn't support prebuild-install, causing it to fall back to node-gyp which fails) # Then run postinstall for packages that need platform-specific binaries: # - esbuild: downloads platform-specific binary # - sharp: downloads prebuilt libvips binaries COPY backend/bun.lock backend/package.json ./ # bun.lock references patchedDependencies; the patch files must be present # before `bun install --frozen-lockfile` or the install fails. COPY backend/patches ./patches RUN bun install --frozen-lockfile --ignore-scripts && \ node node_modules/esbuild/install.js && \ { cd node_modules/sharp && node install/check.js || true; } # Copy source and build scripts COPY backend . # Copy the workspace `@inkweld/prosemirror` package source plus root manifests: # backend imports it via tsconfig path aliases (`../packages/inkweld-prosemirror/src/*`) # which Bun resolves at build/compile time. The package itself depends on # `prosemirror-model` and `yjs`, which Bun resolves through the workspace's # `packages/inkweld-prosemirror/node_modules` (linked by a workspace-root install). COPY package.json bun.lock* /app/ COPY packages /app/packages WORKDIR /app RUN bun install --frozen-lockfile --ignore-scripts WORKDIR /app/backend # Determine target architecture for native module patching and Bun compilation ARG TARGETARCH # Patch native modules for Bun binary compilation: # This modifies classic-level/binding.js to directly require the Linux glibc prebuild # instead of using node-gyp-build's runtime resolution. This allows Bun to statically # analyze and embed the .node file in the compiled binary. RUN PATCH_TARGET=$([ "${TARGETARCH}" = "arm64" ] && echo "linux-arm64-glibc" || echo "linux-x64-glibc") && \ echo "Patching native modules for: $PATCH_TARGET" && \ bun scripts/patch-native-modules.ts patch "$PATCH_TARGET" # Copy frontend dist for binary embedding # When FRONTEND_PREBUILT=true, the dist was already in the build context and copied into frontend-builder # When FRONTEND_PREBUILT=false, frontend-builder built it fresh # Either way, it's in /app/frontend/dist in the frontend-builder stage COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist # Generate frontend imports file for binary embedding # This scans the frontend dist and creates TypeScript imports with { type: 'file' } # so Bun can embed all frontend assets in the compiled binary RUN bun scripts/generate-frontend-imports.ts # Build TypeScript and compile to single binary # The --compile flag creates a standalone executable with embedded Bun runtime # The patched native module is embedded because it's now a static require # Bun's bundler replaces `process.env.NODE_ENV` with its value at build time, # so the compile runs with the NODE_ENV the image runs with. It is set on that # command only, not as a stage ENV, so the `bun install` steps above keep # devDependencies. Inkweld's own code reads NODE_ENV at run time # (src/config/runtime-env.ts); this keeps bundled dependencies in step. RUN BUN_TARGET=$([ "${TARGETARCH}" = "arm64" ] && echo "bun-linux-arm64" || echo "bun-linux-x64") && \ echo "Compiling with target: $BUN_TARGET" && \ bun run build && \ NODE_ENV=production bun build --compile --minify --sourcemap \ --target="$BUN_TARGET" \ ./src/bun-runner.ts \ --outfile ./inkweld-server # Runtime image - minimal with just the binary FROM debian:bookworm-slim AS backend-runtime WORKDIR /app ENV NODE_ENV=production \ PORT=8333 \ HOST=0.0.0.0 \ DB_TYPE=sqlite \ DB_PATH=/data/sqlite.db \ DATA_PATH=/data/yjs \ DB_LOGGING=false \ FRONTEND_DIST=/app/frontend/browser # Install only runtime dependencies (curl for healthcheck, ca-certs for HTTPS) RUN apt-get update && \ apt-get install -y --no-install-recommends curl ca-certificates && \ rm -rf /var/lib/apt/lists/* && \ adduser --disabled-password --gecos "" inkweld && \ mkdir -p /data && chown -R inkweld:inkweld /data /app # Copy only the compiled binary and drizzle migrations (required for DB setup) COPY --from=backend-builder --chown=inkweld:inkweld --chmod=555 /app/backend/inkweld-server ./ COPY --from=backend-builder --chown=inkweld:inkweld --chmod=555 /app/backend/drizzle ./drizzle # Generates and keeps a key in /data when no DATABASE_KEY or SESSION_SECRET # is set (see the script), so an install that never set one still starts. # Owned by root, so the server's own user cannot change it. COPY --from=backend-builder --chmod=555 /app/backend/scripts/docker-entrypoint.sh ./docker-entrypoint.sh # Copy frontend assets from dist directory # Angular outputs to dist/browser/ in production mode. We copy the entire dist/ directory # to /app/frontend/, which creates /app/frontend/browser/ containing index.html and assets. # FRONTEND_DIST env var points to /app/frontend/browser for serving. COPY --from=frontend-builder --chown=inkweld:inkweld --chmod=555 /app/frontend/dist /app/frontend USER inkweld VOLUME ["/data"] EXPOSE 8333 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8333/api/v1/health || exit 1 ENTRYPOINT ["./docker-entrypoint.sh"] CMD ["./inkweld-server"]