How FreeLLMAPI's Multi-Stage Dockerfile Optimizes the Production Build
The FreeLLMAPI repository employs a three-stage Docker build (deps, build, and runtime) that isolates build toolchains, prunes development dependencies, and executes as a non-root user to produce a minimal, secure production image.
FreeLLMAPI leverages Docker's multi-stage build capabilities to separate dependency installation, compilation, and runtime execution into distinct layers. This architecture ensures that heavy build tools and native module compilers never reach the final production container, significantly reducing image size and attack surface. By strategically ordering build instructions and pruning unnecessary packages, the Dockerfile creates a lean deployment artifact optimized for production environments.
The Three-Stage Docker Build Architecture
The multi-stage Dockerfile divides the build process into three specialized stages: deps, build, and runtime. Each stage focuses on a specific task, allowing Docker to cache intermediate layers while excluding unnecessary files from the final image.
Stage 1: Dependency Caching (deps)
The deps stage installs only the build-time dependencies required to compile native modules such as better-sqlite3. It copies all package.json files—including those in server/package.json and client/package.json—before running npm ci.
This stage isolates the heavy toolchain—including Python, make, and g++—ensuring these compilers never appear in the runtime image. By copying dependency manifests early in the build process, Docker can reuse the layer cache for subsequent builds unless the package.json files themselves change.
Stage 2: Compilation and Optimization (build)
The build stage inherits the prepared node_modules from the deps stage and copies the entire repository. It executes npm run build to generate compiled TypeScript output and then runs npm prune --omit=dev to remove development packages.
This pruning step is critical for multi-stage Dockerfile optimization because it strips testing frameworks, TypeScript compilers, and other devDependencies before files reach the runtime stage. Only production code and compiled assets persist, ensuring the subsequent runtime stage receives a minimal set of dependencies.
Stage 3: Production Runtime (runtime)
The final runtime stage starts from a clean Node.js image defined by the ${NODE_IMAGE} argument and copies only essential files: root package.json, the trimmed node_modules, compiled server and client assets, and select static files such as desktop/package.json.
The Dockerfile places ARG FREELLMAPI_COMMIT_SHA after heavy COPY instructions (lines 64‑65) to maximize layer caching. When source code changes occur, earlier layers containing node_modules remain cached because the commit SHA argument appears in later instructions. The container then switches to a non-root node user to enhance security.
Security and Runtime Optimizations
Beyond size reduction, the production build implements security best practices through its entrypoint script and user privilege management.
Non-Root User Execution
The runtime stage configures the container to run as a dedicated node user rather than root. However, the docker-entrypoint.sh script initially executes with root privileges to perform a chown operation on persistent data directories—a requirement for many PaaS platforms—before dropping privileges to the node user prior to starting the application (as seen in lines 55‑60 of the entrypoint script).
Health Checks and Volume Handling
The Dockerfile defines a HEALTHCHECK instruction to verify service availability without requiring external monitoring tools. Notably, the configuration omits the VOLUME declaration for the data directory, leaving volume management to deployment orchestration rather than creating unwanted anonymous volumes during the build process.
Building the Production Image
To leverage these optimizations, build the image with specific arguments corresponding to the ARG statements defined at lines 3‑4 and 64‑65 of the Dockerfile:
docker build \
--build-arg NODE_IMAGE=node:20-bookworm-slim \
--build-arg FREELLMAPI_COMMIT_SHA=$(git rev-parse HEAD) \
-t freellmapi:latest .
Run the container with environment variables matching the production configuration defined at line 86:
docker run -d \
-p 3001:3001 \
-e PORT=3001 \
-e NODE_ENV=production \
--name freellmapi \
freellmapi:latest
For persistent SQLite storage across container restarts, mount a named volume that the entrypoint script will properly permission:
docker volume create freellmapi-data
docker run -d \
-p 3001:3001 \
-v freellmapi-data:/app/server/data \
freellmapi:latest
The docker-entrypoint.sh script referenced at line 71 automatically adjusts ownership of mounted directories before the application starts.
Summary
- Three-stage isolation (
deps,build,runtime) ensures build tools and native compilers never reach the production image, reducing both size and attack surface. - Layer caching optimization via strategic placement of
ARG FREELLMAPI_COMMIT_SHAprevents code changes from invalidating expensivenpm cioperations. - Dependency pruning using
npm prune --omit=devin the build stage eliminates development packages before they reach the runtime container. - Privilege separation in the entrypoint script balances PaaS compatibility (temporary root for
chown) with security (non-rootnodeuser for application execution).
Frequently Asked Questions
Why does FreeLLMAPI use three stages instead of a single stage?
Single-stage builds include Python compilers, make, g++, and development dependencies in the final image. The FreeLLMAPI multi-stage Dockerfile optimization separates these concerns, ensuring the production image contains only compiled application code and production node_modules. This approach typically reduces image size by hundreds of megabytes and eliminates security vulnerabilities associated with unused build tools.
How does the Dockerfile handle native module compilation?
Native modules such as better-sqlite3 require Python and build toolchains installed in the deps stage. The compiled binaries are copied to the build and runtime stages, but the heavy toolchains themselves are excluded from the final image. This allows FreeLLMAPI to use performant native bindings while maintaining a minimal production footprint.
What is the purpose of the FREELLMAPI_COMMIT_SHA build argument?
This argument injects version metadata into the image at lines 64‑65 without disrupting layer caching. By placing it after the heavy COPY operations for node_modules and compiled assets, Docker can reuse cached layers for dependency installation even when the Git SHA changes, significantly speeding up CI/CD pipelines during code-only updates.
How does the entrypoint script improve container security?
The docker-entrypoint.sh executes with root privileges only long enough to set ownership permissions on persistent data directories using chown. It then immediately drops to the unprivileged node user before executing the main application process. This pattern satisfies PaaS platform requirements for volume permissions while maintaining the security benefits of non-root container execution.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →