# -------------------------------------------------------------------- # docmd : the minimalist, zero-config documentation generator. # # @package @docmd/core (and ecosystem) # @website https://docmd.io # @repository https://github.com/docmd-io/docmd # @license MIT # @copyright Copyright (c) 2025-present docmd.io # # [docmd-source] - Please do not remove this header. # -------------------------------------------------------------------- # # Multi-stage Dockerfile for docmd # Creates a minimal production image with docmd CLI pre-installed # # Usage: # Quick Start (with demo site): # docker run -p 3000:3000 docmd:latest # # Initialize new project in current directory: # docker run -v $(pwd):/workspace docmd:latest init # # Run dev server with your docs: # docker run -v $(pwd)/my-docs:/docs -p 3000:3000 docmd:latest dev # # Build static site: # docker run -v $(pwd)/docs:/docs -v $(pwd)/site:/site docmd:latest build # # -------------------------------------------------------------------- # ----------------------------------------------------------------------------- # Stage 1: Build stage - compiles TypeScript and prepares the application # ----------------------------------------------------------------------------- FROM node:20-alpine AS builder # Install build dependencies for native modules RUN apk add --no-cache \ python3 \ make \ g++ \ git \ curl # Install pnpm directly — avoids corepack version resolution issues in CI. # Bump the pinned version here whenever the monorepo's packageManager field changes. RUN npm install -g pnpm@10.33.2 WORKDIR /app # Copy everything (excluding files via .dockerignore) COPY . . # Install dependencies RUN pnpm install --frozen-lockfile # Stub out packages that can't build in Docker before running the full build: # - engine-rust-binaries: requires cargo; pre-built .node files are in the repo # - _playground: dev sandbox, not part of the production image RUN sh tools/docker-prebuild.sh # Build all packages RUN pnpm -r run build # Resolve workspace:* deps to real version numbers for standalone use RUN node tools/resolve-ws-deps.js # Pack all packages into tarballs (mimics npm publish) RUN pnpm -r pack --pack-destination /tarballs # Generate a package.json listing every external (non-@docmd) runtime dep of # @docmd/core's transitive tree. The production stage installs from this file # instead of a hand-rolled list in the Dockerfile, so adding a dep to any # @docmd/* package automatically propagates to the Docker image. RUN node tools/print-okf-runtime-deps.js --emit-pkg=/tarballs/runtime-deps.json # ----------------------------------------------------------------------------- # Stage 2: Production stage - minimal image with only runtime dependencies # ----------------------------------------------------------------------------- FROM node:20-alpine AS production # Install runtime dependencies RUN apk add --no-cache \ git \ curl \ bash \ su-exec # Create non-root user for security RUN addgroup -g 1001 -S docmd && \ adduser -S -D -H -u 1001 -h /app -s /bin/bash -G docmd -g docmd docmd WORKDIR /app # Copy packed tarballs from builder COPY --from=builder /tarballs /tarballs # Install only @docmd/* packages (the transitive deps of @docmd/core). # We extract each docmd-*.tgz tarball into /usr/local/lib/node_modules/@docmd/. # This bypasses npm/pnpm registry lookups (packages aren't published yet at # build time). Legacy wrappers (doc.md, @mgks/docmd) and the monorepo root # tarball are excluded by only matching the docmd-* prefix. RUN mkdir -p /usr/local/lib/node_modules && \ for tarball in /tarballs/docmd-*.tgz; do \ fname=$(basename "$tarball" .tgz) && \ pkg="${fname%-*}" && \ scoped="@docmd/${pkg#docmd-}" && \ mkdir -p "/usr/local/lib/node_modules/$scoped" && \ tar -xzf "$tarball" -C "/usr/local/lib/node_modules/$scoped" --strip-components=1; \ done # Install external deps from the auto-generated runtime-deps.json produced by # the builder stage. We extract the dep list with node and pass it straight to # `npm install -g`, which puts packages into /usr/local/lib/node_modules — # exactly where Node's global resolution path looks. No hand-rolled list in the # Dockerfile: adding a dep to any @docmd/* package propagates automatically on # the next build because the builder stage regenerates runtime-deps.json fresh. RUN node -e "\ const p=JSON.parse(require('fs').readFileSync('/tarballs/runtime-deps.json','utf8')); \ const args=Object.entries(p.dependencies).map(([k,v])=>k+'@'+v); \ const {execFileSync}=require('child_process'); \ execFileSync('npm',['install','-g','--omit=dev','--no-audit','--no-fund',...args],{stdio:'inherit'}); \ " # Symlink the docmd CLI onto PATH (chmod +x on the script too — some # base images strip the executable bit during COPY). RUN ln -sf /usr/local/lib/node_modules/@docmd/core/dist/bin/docmd.js /usr/local/bin/docmd \ && chmod +x /usr/local/bin/docmd /usr/local/lib/node_modules/@docmd/core/dist/bin/docmd.js # Clean up tarballs to reduce image size RUN rm -rf /tarballs # Create directories for user content RUN mkdir -p /docs /site && chown -R docmd:docmd /docs /site # Seed the default template by running docmd init non-interactively. # This ensures the image ships exactly what a real `docmd init` produces. # # Build does NOT fail on init failure: the image still publishes (so users # who mount their own /docs are unaffected), but the demo experience is # degraded and a clear warning is printed. Full stderr from `docmd init` # is in the build log above this block for diagnosis. RUN mkdir -p /template && \ cd /template && \ if docmd init --yes; then \ if [ ! -f /template/docmd.config.json ]; then \ printf '\n[docmd-docker] WARNING: docmd init exited 0 but /template/docmd.config.json is missing.\n' >&2; \ printf '[docmd-docker] Demo seeding will not work. Mount your own /docs.\n' >&2; \ fi; \ else \ printf '\n[docmd-docker] WARNING: docmd init failed during image build.\n' >&2; \ printf '[docmd-docker] The `docker run` demo experience will not work. Mount your own /docs.\n' >&2; \ printf '[docmd-docker] Full error from docmd init is in the build log above this block.\n' >&2; \ fi # Install entrypoint + healthcheck scripts (see docker/docker-entrypoint.sh # and docker/docker-healthcheck.sh). The entrypoint seeds /docs from # /template when /docs is empty, so `docker run` with no volume mount # still works out of the box. The healthcheck probes a port range because # `docmd dev` may auto-increment past 3000 if the port is already taken. COPY docker/docker-entrypoint.sh /usr/local/bin/docmd-entrypoint.sh COPY docker/docker-healthcheck.sh /usr/local/bin/docmd-healthcheck.sh RUN chmod +x /usr/local/bin/docmd-entrypoint.sh /usr/local/bin/docmd-healthcheck.sh # Set environment variables ENV NODE_ENV=production ENV DOCMD_CONTAINER=true # Run as root so the entrypoint can inspect /docs ownership and use # su-exec to re-exec as the correct uid:gid. The entrypoint never stays # as root — it always drops to the detected uid before exec-ing docmd. # This is the same pattern used by postgres, redis, and other official images. # Set working directory to docs (where user content will be mounted) WORKDIR /docs # Expose default port (dev server may pick a higher one if 3000 is in use) EXPOSE 3000 # Health check — probes 3000-3005 to follow the dev server's actual port HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 CMD ["/usr/local/bin/docmd-healthcheck.sh"] # Default command — entrypoint detects /docs uid, drops privileges, then # seeds /docs if empty and execs the supplied command. ENTRYPOINT ["/usr/local/bin/docmd-entrypoint.sh"] CMD ["dev", "--host", "0.0.0.0"]