Dockerize a Bun App with Multi-Stage Builds

Khimananda Oli 9 min read Programming and Languages
Dockerize a Bun App with Multi-Stage Builds

By Khimananda Oli | Last reviewed: August 2026

Bun’s blazing-fast runtime is only half the performance story; if your container image is bloated or insecure, you negate those gains at deploy time. To properly dockerize a Bun app with multi-stage builds, you must separate dependency installation from the final runtime artifact, ensuring only compiled production code enters the release stage. This approach dramatically reduces attack surface and cold-start latency compared to naive single-stage Dockerfiles. If you are new to container fundamentals, review my guide on containerizing applications from scratch before proceeding.

Builder Stageoven/bun:1-alpinebun install --frozenbun run buildTypeScript CompileCOPY --from=builderRuntime Stagegcr.io/distroless/cc/app/node_modules/app/distBun Binary OnlyFinal Artifact~85 MB ImageNo Shell / No Package MgrRead-Only Filesystem
Multi-stage architecture isolates build tooling from the final runtime when you dockerize a Bun app

Why should you dockerize a Bun app with multi-stage builds instead of a single stage?

A single-stage Dockerfile copies your entire source tree, development dependencies, and build tools into the production image. For Bun applications, this often results in images exceeding 500MB because the oven/bun base includes package managers, compilers, and debugging utilities that serve no purpose at runtime. Multi-stage builds solve this by treating the build environment as ephemeral. Only the specific files required to execute your application—compiled JavaScript, production node_modules, and static assets—are copied into the final lean runtime.

Security is the second critical driver. Every additional binary in your container expands the attack surface. By stripping away shells, package managers, and C libraries unused at runtime, you eliminate entire classes of vulnerabilities. In regulated environments where I help teams achieve SOC 2 compliance, auditors specifically look for minimal base images as evidence of defense-in-depth. A distroless or Alpine runtime with no interactive shell makes exploitation significantly harder even if an attacker achieves code execution.

Cold start performance in serverless and Kubernetes environments correlates directly with image size. Pulling a 500MB image takes measurably longer than pulling an 85MB image, especially across regions or on nodes without cached layers. When horizontal pod autoscaling triggers during traffic spikes, smaller images mean faster scale-up times. For teams running high-density clusters, this also translates to real cost savings on storage and network egress.

How do you write a production-ready Dockerfile to dockerize a Bun app?

The following Dockerfile implements the pattern I use in production for Bun APIs and SSR applications. It assumes your project uses TypeScript, has a bun.lockb lockfile, and outputs compiled code to a dist directory.

# ---- Builder Stage ----
FROM oven/bun:1-alpine AS builder
WORKDIR /app

# Copy dependency manifests first for layer caching
COPY bun.json bun.lockb ./
RUN bun install --frozen-lockfile --production=false

# Copy source and build
COPY . .
RUN bun run build

# Prune dev dependencies after build completes
RUN bun install --frozen-lockfile --production

# ---- Runtime Stage ----
FROM gcr.io/distroless/cc-debian12 AS runtime
WORKDIR /app

# Copy only what is needed from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/bun.json ./

# Non-root user is implicit in distroless (runs as nonroot:65532)
EXPOSE 3000
CMD ["./dist/index.js"]

Key configuration decisions explained

  • --frozen-lockfile: Prevents Bun from modifying the lockfile during CI. If dependencies drift, the build fails immediately rather than silently introducing untested versions.
  • --production=false in builder: Dev dependencies like TypeScript, linters, and test frameworks are required during the build step but must be excluded from the final image. We install everything first, build, then prune.
  • Manifest-first COPY: Copying bun.json and bun.lockb before source code leverages Docker layer caching. Dependency installation only re-runs when manifests change, not on every code commit.
  • distroless/cc base: Contains only the minimal C runtime libraries Bun needs. No shell, no apt, no curl. If your app requires native modules with additional system libraries, switch to oven/bun:1-alpine for the runtime stage and add RUN addgroup -S app && adduser -S app -G app to run as non-root.

What are common mistakes when attempting to dockerize a Bun app with multi-stage builds?

Build Fails or Image Bloated?Check Lockfile ConsistencyMissing bun.lockbRun bun install locallyCommit lockfile to GitLockfile PresentVerify .dockerignoreexcludes node_modulesStill Failing?Check Bun version matchlocal vs Docker base tagImage Too Large?Verify --production flagin final install step
Troubleshooting decision tree when you dockerize a Bun app and encounter build or size issues

The most frequent error I see in code reviews is omitting the .dockerignore file. Without it, Docker copies your local node_modules directory into the builder context before bun install runs. This wastes build time transferring hundreds of megabytes and can introduce platform-specific binaries compiled for your host OS that fail inside the Alpine container. Always include this in your .dockerignore:

node_modules
.git
.env*
*.md
tests/
coverage/
.dockerignore
Dockerfile

Version mismatch between your local Bun and the Docker base image causes subtle runtime failures. Pin your base image to a specific minor version (oven/bun:1.1-alpine) rather than the floating latest or major-only 1 tag. Document the expected Bun version in your README.md and enforce it via CI checks. If your team uses GitHub Actions or GitLab CI, add a step that validates the lockfile was generated with the same Bun version specified in the Dockerfile.

Another pitfall is copying the entire /app directory from the builder instead of selecting specific paths. This accidentally includes source maps, test fixtures, and TypeScript declaration files in the runtime image. Be explicit: copy dist/, node_modules/, and bun.json individually. If your app serves static assets, add a dedicated COPY --from=builder /app/public ./public line rather than relying on wildcard patterns.

How does the Bun multi-stage build compare to Node.js equivalents?

CriteriaBun Multi-StageNode.js Multi-Stage
Base Image Size (Builder)~200 MB (oven/bun:1-alpine)~350 MB (node:22-alpine)
Dependency Install Speed2–5 seconds typical15–45 seconds typical
Final Image Size (Distroless)70–95 MB120–180 MB
Native Module CompatibilityGrowing; some packages need rebuildMature ecosystem support
TypeScript ExecutionNative, no tsc required at runtimeRequires compilation or ts-node
Cold Start Latency<50ms typical100–300ms typical

Bun’s integrated bundler and TypeScript support eliminate an entire build toolchain that Node.js projects typically carry. Where a Node.js Dockerfile might require separate stages for npm ci, tsc, and prune, Bun consolidates these into fewer commands with faster execution. The trade-off is ecosystem maturity: if your application depends on native addons that haven’t been tested against Bun’s runtime, you may need to fall back to Node.js or maintain a hybrid approach.

For teams evaluating whether to migrate existing Node.js services, benchmark your specific workload before committing. Synthetic benchmarks favor Bun, but real-world applications with complex dependency trees sometimes tell a different story. I recommend running both containers through identical load tests using k6 or Artillery before making infrastructure changes. Understanding resource limits and requests becomes particularly important when comparing runtimes, as Bun’s memory profile differs from V8-based Node.js.

How do you optimize layer caching and security when you dockerize a Bun app with multi-stage builds?

Naive ApproachCOPY . . (Invalidates All)bun install (Rebuilds)bun run build (Rebuilds)Every Code Change =Full Rebuild (~45s)Cache Miss Rate: ~95%Optimized ApproachCOPY bun.json bun.lockbbun install (Cached ✓)COPY . . (Source Only)bun run build (Runs)Code Change =Partial Rebuild (~8s)Cache Hit Rate: ~85%Security Hardening✓ Distroless Base Image✓ Non-Root User (65532)✓ Read-Only Root FS✓ No Shell / No Package Mgr✓ Minimal Attack Surface
Layer caching comparison and security checklist when you dockerize a Bun app for production

Docker caches each instruction as a discrete layer. The moment a layer changes, all subsequent layers are invalidated. This is why copying manifests before source code matters enormously. In CI pipelines processing dozens of commits daily, optimized caching reduces average build time from 45 seconds to under 10 seconds for code-only changes. Over a month, this saves hours of developer wait time and CI runner costs.

For security hardening beyond the base image choice, apply these practices consistently:

  1. Pin image digests, not just tags. Replace oven/bun:1-alpine with the SHA256 digest from Docker Hub. Tags are mutable; digests guarantee immutability and protect against supply chain attacks.
  2. Scan every built image. Integrate Trivy or Grype into your CI pipeline. Fail builds on HIGH or CRITICAL vulnerabilities. For teams working toward compliance, automated scanning provides audit evidence. See my post on container image scanning with Trivy for implementation details.
  3. Set resource constraints in Kubernetes. Even with a minimal image, misconfigured pods can consume excessive resources. Define CPU and memory requests based on actual profiling, not guesses.
  4. Use read-only root filesystems. Add securityContext.readOnlyRootFilesystem: true in your pod spec. Applications needing write access should use emptyDir volumes mounted at specific paths.

Monitor your production containers with the same rigor you apply to the build process. Structured logging and metrics collection should be configured identically in development and production. If you’re building observability into your Bun application, review structured logging best practices to ensure your containerized app emits parseable, actionable log data.

Shipping Secure Bun Containers Confidently

When you dockerize a Bun app with multi-stage builds correctly, you gain speed, security, and reliability without sacrificing developer experience. The pattern outlined here has proven effective across multiple production deployments in 2026, delivering sub-100MB images that pass security audits and scale efficiently under load. Start with the provided Dockerfile, adapt the copy steps to your project structure, and integrate image scanning into your CI pipeline from day one. If your team needs help establishing container security baselines or optimizing deployment pipelines for Bun workloads, reach out to discuss your infrastructure.

Frequently Asked Questions

Multi-stage builds separate dependency installation from the runtime environment, resulting in significantly smaller final Docker images by excluding build tools, source maps, and development dependencies that are unnecessary for production execution.

Use oven/bun:1-alpine as your primary base image because it includes glibc compatibility layers while maintaining a minimal footprint under 50MB, ensuring both security patches and optimal layer caching for CI pipelines.

Always copy bun.lockb before package.json to leverage Docker layer caching effectively. This ensures dependency installation only reruns when lockfile contents change, dramatically reducing rebuild times during iterative development cycles.

Yes. Create a non-root user with USER bun:bun after installing dependencies. The official Bun Alpine image includes this user by default, preventing privilege escalation attacks and meeting CIS Docker benchmark requirements.

Alpine uses musl instead of glibc. Install required native modules via apk add --no-cache libc6-compat or switch to oven/bun:1-debian if your dependencies require full glibc compatibility for database drivers or cryptography packages.

Typically 30-45MB using alpine variants with multi-stage builds. Final size depends on application complexity, but removing devDependencies and using --production flag during install keeps images lean compared to Node.js equivalents.

Use COPY exclusively unless extracting tar archives. ADD has implicit behaviors that complicate debugging and increase attack surface. Explicit COPY commands provide predictable file transfers and better integration with BuildKit cache mounts.

Copy bun.lockb first, then run bun install --frozen-lockfile --production before copying source code. This ordering ensures dependency layers remain cached across deployments unless lockfile changes occur.

No. Configure HEALTHCHECK CMD curl -f http://localhost:3000/health || exit 1 in your Dockerfile. Bun itself lacks built-in probe endpoints, so implement a lightweight HTTP handler returning 200 status codes.

Default to port 3000 unless specified otherwise. Use EXPOSE 3000 for documentation and map externally via docker run -p 8080:3000. Avoid privileged ports below 1024 to maintain non-root container security posture.

Never bake secrets into images. Use docker run --env-file .env.production or orchestration secret managers like Kubernetes Secrets. Reference variables via process.env at runtime, keeping sensitive data out of version control and image layers.

Yes. Run containers with --inspect=0.0.0.0:6499 flag and forward the debugger port. Attach VS Code or Chrome DevTools remotely. Ensure source maps are included in development builds but excluded from production multi-stage outputs.

Precompile TypeScript with bun build --compile during the build stage. Binary compilation eliminates runtime transpilation overhead, cutting startup latency by 60-80% compared to interpreted execution in serverless or auto-scaling environments.

Ownership mismatches between stages. Use COPY --chown=bun:bun when transferring artifacts from build to runtime stage. Running chown commands separately creates unnecessary layers and increases final image size unnecessarily.

Not recommended currently. Bun requires shell access for certain runtime operations and native module loading. Stick with alpine or debian slim variants until official distroless support matures in future Bun releases beyond 2026.