Understanding the Three-Stage Build Process in LunaTV's Dockerfile

The three-stage build process in LunaTV's Dockerfile separates dependency installation, application compilation, and runtime execution into isolated stages to minimize image size, optimize layer caching, and enforce security through least-privilege principles.

The LunaTV repository (MoonTechLab/LunaTV) containerizes its Next.js application using a sophisticated multi-stage Dockerfile defined in the project's root Dockerfile. This architectural pattern ensures that build tools and development dependencies never contaminate the production runtime, resulting in compact, secure container images optimized for deployment.

Breakdown of the Three Build Stages

The Dockerfile implements three distinct stages named deps, builder, and runner. Each stage inherits from node:20-alpine and performs a specific function in the build pipeline to isolate concerns.

Stage 1: Dependency Caching (deps)

The first stage establishes a reproducible dependency layer by installing all Node.js packages—including devDependencies—in an isolated environment defined in lines 2-14.

FROM node:20-alpine AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

By copying only package.json and pnpm-lock.yaml before running pnpm install --frozen-lockfile, Docker creates a cacheable layer that persists across builds when dependencies remain unchanged. This prevents redundant reinstallation of packages during source code modifications, significantly accelerating iterative development.

Stage 2: Application Compilation (builder)

The second stage handles the computationally intensive task of compiling the Next.js application into a production-ready bundle, utilizing lines 15-30 from the source.

FROM node:20-alpine AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV DOCKER_ENV=true
RUN pnpm run build

This stage pulls the pre-installed node_modules directory from the deps stage using COPY --from=deps, then injects the application source code. Setting DOCKER_ENV=true signals build-time optimizations specific to LunaTV, while pnpm run build generates the optimized output. Isolating compilation here ensures that build artifacts and heavy tooling never propagate to the final image.

Stage 3: Minimal Runtime (runner)

The final stage produces a hardened, minimal runtime environment containing only the compiled application and necessary runtime assets, as configured in lines 31-58.

FROM node:20-alpine AS runner
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/public ./public
USER nextjs
CMD ["node", "start.js"]

This stage extracts only the standalone bundle from /app/.next/standalone—generated by the standalone output configuration in next.config.js—along with static assets from the public directory. The image runs under the non-root nextjs user, eliminating development tools, source code, and package managers to achieve a footprint of approximately tens of megabytes.

Performance and Security Benefits

The three-stage architecture delivers measurable advantages across build velocity, security posture, and resource efficiency.

Cache Efficiency
Separating dependency installation from compilation means that changes to application source code trigger only the builder and runner stages. The deps layer remains cached when package.json and pnpm-lock.yaml are stable, reducing build times from minutes to seconds during routine development cycles.

Security Hardening
The final runner image excludes all build-time dependencies, compilers, and package managers that could serve as attack vectors. By enforcing the USER nextjs directive, the container executes with minimal privileges, adhering to the principle of least privilege and preventing privilege escalation attacks.

Image Size Reduction
Copying only the standalone bundle and static assets—rather than the entire source tree and node_modules—dramatically reduces the final image size. This leads to faster deployment times, reduced storage costs, and quicker container startup in orchestration environments.

Build Commands and CI/CD Integration

Execute the multi-stage build locally using standard Docker commands:

docker build -t lunatv:dev -f Dockerfile .
docker run -p 3000:3000 lunatv:dev

For production CI/CD pipelines, leverage build caching across stages using GitHub Actions:

- name: Build and push Docker image
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/moontechlab/lunatv:latest
    cache-from: type=registry,ref=ghcr.io/moontechlab/lunatv:cache
    cache-to: type=registry,ref=ghcr.io/moontechlab/lunatv:cache,mode=max

Verify the resulting image efficiency by inspecting the size:

docker image ls ghcr.io/moontechlab/lunatv:latest

The output should reflect a compact image—typically under 100 MB—containing only the runtime stage artifacts.

Summary

  • Three-stage isolation creates distinct environments for dependencies (deps), compilation (builder), and execution (runner) to prevent build tool contamination in production.
  • Layer caching accelerates rebuilds by preserving the pnpm install layer when package.json and pnpm-lock.yaml remain unchanged across builds.
  • Security hardening removes development dependencies and executes the container as the non-root nextjs user defined in the Dockerfile.
  • Minimal footprint is achieved by copying only the Next.js standalone bundle from .next/standalone and static assets into the final image, excluding source code and build tools.

Frequently Asked Questions

Why does LunaTV use three stages instead of a single-stage build?

Single-stage builds combine dependency installation, compilation, and runtime into one image, unnecessarily including build tools and devDependencies in production. LunaTV's three-stage approach ensures the final runner image contains only the compiled standalone output and runtime necessities, reducing attack surface and storage requirements by excluding Node.js build tools and source code.

How does the deps stage improve build performance?

The deps stage copies only package.json and pnpm-lock.yaml before executing pnpm install --frozen-lockfile. Docker caches this layer independently, meaning subsequent builds that modify source code but not dependencies can skip the installation step entirely. This cache isolation reduces build times significantly during iterative development where only application logic changes.

What security measures are implemented in the runner stage?

The runner stage implements two critical security controls: it uses the USER nextjs directive to run the container as a non-root user, preventing privilege escalation attacks, and it exclusively copies production artifacts from the standalone output directory specified in next.config.js. By omitting the original source code, package manager metadata, and development dependencies, the stage minimizes the attack surface available to potential exploit attempts.

What role does the start.js file play in the runtime stage?

The start.js script serves as the container's entry point, referenced in the CMD ["node", "start.js"] instruction within the runner stage. Located in the repository root as start.js, this file orchestrates the server initialization process for the standalone Next.js application, ensuring proper signal handling and environment configuration when the container executes in production environments.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →