# Design Document: official-docker-image ## Overview **Purpose**: Modernize the Dockerfile and entrypoint for the GROWI official Docker image based on 2025-2026 best practices, achieving enhanced security, optimized memory management, and improved build efficiency. **Users**: Infrastructure administrators (build/deploy), GROWI operators (memory tuning), and Docker image end users (usage via docker-compose). **Impact**: Redesign the existing 3-stage Dockerfile into a 5-stage configuration. Migrate the base image to Docker Hardened Images (DHI). Change the entrypoint from a shell script to TypeScript (using Node.js 24 native TypeScript execution), achieving a fully hardened configuration that requires no shell. ### Goals - Up to 95% CVE reduction through DHI base image adoption - **Fully shell-free TypeScript entrypoint** — Node.js 24 native TypeScript execution (type stripping), maintaining the minimized attack surface of the DHI runtime as-is - Memory management via 3-tier fallback: `V8_MAX_HEAP_SIZE` / cgroup auto-calculation / V8 default - Environment variable names aligned with V8 option names (`V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`) - Improved build cache efficiency through the `turbo prune --docker` pattern - Privilege drop via gosu → `process.setuid/setgid` (Node.js native) ### Non-Goals - Changes to Kubernetes manifests / Helm charts (GROWI.cloud `V8_MAX_HEAP_SIZE` configuration is out of scope) - Application code changes (adding gc(), migrating to .pipe(), etc. are separate specs) - Updating docker-compose.yml (documentation updates only) - Support for Node.js versions below 24 - Adding HEALTHCHECK instructions (k8s uses its own probes, Docker Compose users can configure their own) ## Architecture ### Existing Architecture Analysis **Current Dockerfile 3-stage configuration:** | Stage | Base Image | Role | |-------|-----------|------| | `base` | `node:20-slim` | Install pnpm + turbo | | `builder` | `base` | `COPY . .` → install → build → artifacts | | release (unnamed) | `node:20-slim` | gosu install → artifact extraction → execution | **Main issues:** - `COPY . .` includes the entire monorepo in the build layer - pnpm version is hardcoded (`PNPM_VERSION="10.32.1"`) - Typo in `---frozen-lockfile` - Base image is node:20-slim (prone to CVE accumulation) - No memory management flags - No OCI labels - gosu installation requires apt-get (runtime dependency on apt) ### Architecture Pattern & Boundary Map ```mermaid graph TB subgraph BuildPhase base[base stage
DHI dev + pnpm + turbo] pruner[pruner stage
turbo prune --docker] deps[deps stage
dependency install] builder[builder stage
build + artifacts] end subgraph ReleasePhase release[release stage
DHI runtime - no shell] end base --> pruner pruner --> deps deps --> builder builder -->|artifacts| release subgraph RuntimeFiles entrypoint[docker-entrypoint.ts
TypeScript entrypoint] end entrypoint --> release ``` **Architecture Integration:** - Selected pattern: Multi-stage build with dependency caching separation - Domain boundaries: Build concerns (stages 1-4) vs Runtime concerns (stage 5 + entrypoint) - Existing patterns preserved: Production dependency extraction via pnpm deploy, tar.gz artifact transfer - New components: pruner stage (turbo prune), TypeScript entrypoint - **Key change**: gosu + shell script → TypeScript entrypoint (`process.setuid/setgid` + `fs` module + `child_process.execFileSync/spawn`). Eliminates the need for copying busybox/bash, maintaining the minimized attack surface of the DHI runtime as-is. Executes `.ts` directly via Node.js 24 type stripping - Steering compliance: Maintains Debian base (glibc performance), maintains monorepo build pattern ### Technology Stack | Layer | Choice / Version | Role in Feature | Notes | |-------|------------------|-----------------|-------| | Base Image (build) | `dhi.io/node:24-debian13-dev` | Base for build stages | apt/bash/git/util-linux available | | Base Image (runtime) | `dhi.io/node:24-debian13` | Base for release stage | Minimal configuration, 95% CVE reduction, **no shell** | | Entrypoint | Node.js (TypeScript) | Initialization, heap calculation, privilege drop, process startup | Node.js 24 native type stripping, no busybox/bash needed | | Privilege Drop | `process.setuid/setgid` (Node.js) | root → node user switch | No external binaries needed | | Build Tool | `turbo prune --docker` | Monorepo minimization | Official Turborepo recommendation | | Package Manager | pnpm (wget standalone) | Dependency management | corepack not adopted (scheduled for removal in Node.js 25+) | > For the rationale behind adopting the TypeScript entrypoint and comparison with busybox-static/setpriv, see `research.md`. ## System Flows ### Entrypoint Execution Flow ```mermaid flowchart TD Start[Container Start
as root via node entrypoint.ts] --> Setup[Directory Setup
fs.mkdirSync + symlinkSync + chownSync] Setup --> HeapCalc{V8_MAX_HEAP_SIZE
is set?} HeapCalc -->|Yes| UseEnv[Use V8_MAX_HEAP_SIZE] HeapCalc -->|No| CgroupCheck{cgroup limit
detectable?} CgroupCheck -->|Yes| AutoCalc[Auto-calculate
60% of cgroup limit] CgroupCheck -->|No| NoFlag[No heap flag
V8 default] UseEnv --> OptFlags[Check V8_OPTIMIZE_FOR_SIZE
and V8_LITE_MODE] AutoCalc --> OptFlags NoFlag --> OptFlags OptFlags --> LogFlags[console.log applied flags] LogFlags --> DropPriv[Drop privileges
process.setgid + setuid] DropPriv --> Migration[Run migration
execFileSync node migrate-mongo] Migration --> SpawnApp[Spawn app process
node --max-heap-size=X ... app.js] SpawnApp --> SignalFwd[Forward SIGTERM/SIGINT
to child process] ``` **Key Decisions:** - Prioritize cgroup v2 (`/sys/fs/cgroup/memory.max`), fall back to v1 - Treat cgroup v1 unlimited value (very large number) as no flag (threshold: 64GB) - `--max-heap-size` is passed to the spawned child process (the application itself), not the entrypoint process - Migration is invoked directly via `child_process.execFileSync` calling node (no `npm run`, no shell needed) - App startup uses `child_process.spawn` + signal forwarding to fulfill PID 1 responsibilities ### Docker Build Flow ```mermaid flowchart LR subgraph Stage1[base] S1[DHI dev image
+ pnpm + turbo] end subgraph Stage2[pruner] S2A[COPY monorepo] S2B[turbo prune --docker] end subgraph Stage3[deps] S3A[COPY json + lockfile] S3B[pnpm install --frozen-lockfile] end subgraph Stage4[builder] S4A[COPY full source] S4B[turbo run build] S4C[pnpm deploy + tar.gz] end subgraph Stage5[release] S5A[DHI runtime
no additional binaries] S5B[Extract artifacts] S5C[COPY entrypoint.js] end Stage1 --> Stage2 --> Stage3 --> Stage4 Stage4 -->|tar.gz| Stage5 ``` ## Components and Interfaces | Component | Domain/Layer | Intent | Key Dependencies | |-----------|-------------|--------|-----------------| | Dockerfile | Infrastructure | 5-stage Docker image build definition | DHI images, turbo, pnpm | | docker-entrypoint.ts | Infrastructure | Container startup initialization (TypeScript) | Node.js fs/child_process, cgroup fs | | docker-entrypoint.spec.ts | Infrastructure | Unit tests for entrypoint | vitest | | Dockerfile.dockerignore | Infrastructure | Build context filter | — | | README.md | Documentation | Docker Hub image documentation | — | | buildspec.yml | CI/CD | CodeBuild build definition | AWS Secrets Manager, dhi.io | ### Dockerfile **Responsibilities & Constraints** - 5-stage configuration: `base` → `pruner` → `deps` → `builder` → `release` - Use of DHI base images (`dhi.io/node:24-debian13-dev` / `dhi.io/node:24-debian13`) - **No shell or additional binary copying in runtime** (everything is handled by the Node.js entrypoint) **Stage Definitions:** - **base**: DHI dev image + pnpm (wget) + turbo + apt packages (`ca-certificates`, `wget`) - **pruner**: `COPY . .` + `turbo prune @growi/app --docker` - **deps**: COPY json/lockfile from pruner + `pnpm install --frozen-lockfile` + node-gyp - **builder**: COPY full source from pruner + `turbo run build` + `pnpm deploy` + artifact packaging - **release**: DHI runtime (no shell) + `COPY --from=builder` artifacts + entrypoint + OCI labels + EXPOSE/VOLUME ### docker-entrypoint.ts **Responsibilities & Constraints** - Written in TypeScript, executed via Node.js 24 native type stripping (enums not allowed) - Directory setup as root (`/data/uploads` + symlink, `/tmp/page-bulk-export`) - Heap size determination via 3-tier fallback - Privilege drop via `process.setgid()` + `process.setuid()` - Migration execution via `child_process.execFileSync` (direct node invocation, no shell) - App process startup via `child_process.spawn` with signal forwarding (PID 1 responsibilities) - No external binary dependencies **Environment Variable Interface** | Variable | Type | Default | Description | |----------|------|---------|-------------| | `V8_MAX_HEAP_SIZE` | int (MB) | (unset) | Explicitly specify the --max-heap-size value for Node.js | | `V8_OPTIMIZE_FOR_SIZE` | `"true"` / (unset) | (unset) | Enable the --optimize-for-size flag | | `V8_LITE_MODE` | `"true"` / (unset) | (unset) | Enable the --lite-mode flag | > **Naming Convention**: Environment variable names are aligned with their corresponding V8 option names (`--max-heap-size`, `--optimize-for-size`, `--lite-mode`) prefixed with `V8_`. This improves discoverability and self-documentation compared to the previous `GROWI_`-prefixed names. **Batch Contract** - **Trigger**: On container startup (`ENTRYPOINT ["node", "/docker-entrypoint.ts"]`) - **Input validation**: V8_MAX_HEAP_SIZE (positive int, empty = unset), V8_OPTIMIZE_FOR_SIZE/V8_LITE_MODE (only `"true"` is valid), cgroup v2 (`memory.max`: numeric or `"max"`), cgroup v1 (`memory.limit_in_bytes`: numeric, large value = unlimited) - **Output**: Node flags passed directly as arguments to `child_process.spawn` - **Idempotency**: Executed on every restart, safe via `fs.mkdirSync({ recursive: true })` ### README.md **Responsibilities & Constraints** - Docker Hub image documentation (published to hub.docker.com/r/growilabs/growi) - Document the V8 memory management environment variables under Configuration > Environment Variables section - Include variable name, type, default, and description for each: `V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE` ## Error Handling | Error | Category | Response | |-------|----------|----------| | cgroup file read failure | System | Warn and continue with no flag (V8 default) | | V8_MAX_HEAP_SIZE is invalid | User | Warn and continue with no flag (container still starts) | | Directory creation/permission failure | System | `process.exit(1)` — check volume mount configuration | | Migration failure | Business Logic | `execFileSync` throws → `process.exit(1)` — Docker/k8s restarts | | App process abnormal exit | System | Propagate child process exit code | ## Performance & Scalability - **Build cache**: `turbo prune --docker` caches the dependency install layer. Skips dependency installation during rebuilds when only source code changes - **Image size**: No additional binaries in DHI runtime. Base layer is smaller compared to node:24-slim - **Memory efficiency**: Total heap control via `--max-heap-size` avoids the v24 trusted_space overhead issue. Prevents memory pressure in multi-tenant environments