Shrink Bun Docker Images

Khimananda Oli 8 min read Programming and Languages
Shrink Bun Docker Images

By Khimananda Oli | Last reviewed: August 2026

Bun’s raw speed often masks a common deployment problem: default Docker images frequently exceed 800MB because they bundle the full runtime, build toolchain, and dev dependencies. When you shrink Bun Docker images correctly using multi-stage builds and minimal base layers, you can drop production artifacts to under 50MB without sacrificing compatibility or startup performance. This reduction directly improves cold-start latency on serverless platforms and cuts bandwidth costs for teams in Nepal and abroad pushing frequent updates over constrained networks.

Before optimizing, understand where the bloat comes from. A naive Dockerfile that simply runs bun install and bun run start carries the entire Bun binary (~40MB), Node.js compatibility shims, system libraries for native modules, and every devDependency listed in your package.json. For teams already practicing multi-stage Docker builds, the pattern is familiar, but Bun-specific flags and base image choices make a measurable difference. Getting this right matters especially when you’re deploying to Fargate or similar managed container services where image size directly affects provisioning time and cost.

Naive Image (~850MB)Dev Dependencies (320MB)Build Tools & Compilers (180MB)Full Bun + Node Compat (120MB)System Libraries (90MB)App Code + Prod Deps (40MB)Optimized Image (~45MB)App Code + Prod Deps OnlyMinimal Bun RuntimeDistroless / Alpine Base~95% Size Reduction
Naive versus optimized Bun Docker image layer composition showing how multi-stage builds eliminate dev dependencies and build tools to shrink Bun Docker images dramatically

How do you write a multi-stage Dockerfile to shrink Bun Docker images?

The most impactful technique is separating build-time and runtime concerns into distinct stages. Bun’s single-binary nature makes this cleaner than Node.js equivalents, but you must still be explicit about what gets copied forward.

Production-ready multi-stage Dockerfile

# Stage 1: Build and install dependencies
FROM oven/bun:1.2-alpine AS builder
WORKDIR /app

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

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

# Stage 2: Production runtime
FROM oven/bun:1.2-distroless AS runner
WORKDIR /app

# Copy only production artifacts
COPY --from=builder /app/package.json ./
COPY --from=builder /app/bun.lockb ./
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

ENV NODE_ENV=production
USER nonroot
EXPOSE 3000
CMD ["bun", "run", "dist/index.js"]

Key details that matter in practice:

  • Lockfile ordering: Copying bun.lockb before source ensures dependency installation is cached independently of code changes. This alone saves minutes in CI when you iterate frequently.
  • --production=false in builder: Counterintuitively, you need devDependencies during build (TypeScript, bundlers, test runners). The --production flag belongs only if you skip a separate build step entirely.
  • Distroless user: The nonroot user exists in Bun’s distroless image by default. Never run as root in production; this satisfies most SOC 2 and ISO 27001 control requirements without extra configuration.
  • Explicit COPY paths: Avoid COPY --from=builder /app /app. That pulls everything including hidden caches and build intermediates. Be surgical.

Which Bun base image gives the smallest production footprint?

Bun provides several official variants, and choosing the wrong one negates your optimization work. Here’s how they compare in real-world usage as of mid-2026:

Base ImageSize (Empty)Shell AccessPackage ManagerBest For
oven/bun:1.2~220MBYes (bash)aptDebugging, native module compilation
oven/bun:1.2-alpine~85MBYes (ash)apkBuild stage with native deps
oven/bun:1.2-distroless~25MBNoNoneProduction runtime (recommended)
oven/bun:1.2-slim~110MBYes (bash)aptWhen distroless breaks native addons

In my experience helping Nepali fintech startups prepare for compliance audits, the distroless variant satisfies security reviewers fastest because it eliminates shell access entirely. However, if your app depends on native modules like sharp or better-sqlite3, test thoroughly — some require glibc and fail silently on musl-based Alpine or stripped distroless environments. When that happens, fall back to slim and accept the 85MB penalty rather than spending days debugging segfaults.

Start: Choose BaseNative modules required?YesNoNeeds glibc?Use distroless ✓YesNo (musl OK)Use slimUse alpineAlways validate with actual app startup, not just build success
Decision flowchart for selecting the optimal Bun Docker base image to shrink Bun Docker images while maintaining compatibility

What Bun install flags prevent unnecessary bloat in production containers?

Bun’s package manager supports flags that Node.js/npm doesn’t, and misusing them is the most frequent cause of oversized images I see during infrastructure reviews.

  1. --frozen-lockfile: Mandatory in CI and Docker builds. Prevents accidental dependency resolution changes and ensures reproducible builds. Without it, your image may silently include newer transitive dependencies that increase size or introduce vulnerabilities.
  2. --production: Excludes devDependencies entirely. Use this only in your final runtime stage if you’ve pre-built assets. In the builder stage, omit it so TypeScript compilers and bundlers remain available.
  3. --no-save: Useful when installing temporary build tools that shouldn’t persist in lockfile. Rarely needed in Docker but prevents drift when experimenting locally.
  4. --ignore-scripts: Skips postinstall hooks. Critical for security (prevents supply-chain attacks via malicious scripts) and reduces build time. Re-enable only if a specific dependency requires native compilation.

A common mistake is running bun install --production in the builder stage, then wondering why tsc or vite fails. Remember: build stage needs everything; runtime stage needs almost nothing. If you’re also managing database migrations in containers, check the PostgreSQL administration essentials guide for patterns that keep migration tools out of your final app image.

How does Bun’s single-binary architecture affect Docker layer caching compared to Node.js?

Bun ships as a single static binary (~40MB) that includes the runtime, package manager, test runner, and bundler. This fundamentally changes how you should structure Docker layers versus traditional Node.js setups.

With Node.js, you typically cache npm ci separately from copying source because the runtime and package manager are part of the base image. With Bun, the base image itself rarely changes between minor versions, making dependency caching even more effective. However, because Bun handles multiple roles, accidentally invalidating the dependency layer (e.g., by copying package.json after source files) wastes more absolute bytes than with Node.

In practice, I’ve measured 3–5x faster CI rebuilds for Bun projects versus equivalent Node.js services when the Dockerfile respects layer ordering strictly. The trade-off is that upgrading Bun versions requires rebuilding all downstream layers since the binary is monolithic. Pin exact versions (oven/bun:1.2.4-alpine, not 1.2-alpine) to avoid surprise cache misses during routine maintenance windows.

What verification steps confirm your optimized Bun image actually works in production?

Size isn’t everything. A 30MB image that crashes on startup is worse than a 200MB image that runs reliably. Always validate after optimization:

  • Startup smoke test: Run docker run --rm <image> bun --version and hit your health endpoint. Distroless images have no shell, so exec-style testing fails — use HTTP probes instead.
  • Dependency audit: Run bun audit in the builder stage and fail the build on high-severity CVEs. Smaller attack surface means fewer false positives during container image scanning with Trivy.
  • Native module validation: If using SQLite, Sharp, or similar, verify functionality explicitly. Missing shared libraries in distroless manifest as cryptic "cannot open shared object file" errors at runtime, not build time.
  • Size regression guard: Add a CI check that fails if image size exceeds a threshold (e.g., 60MB). Prevents gradual bloat creep from well-intentioned but careless PRs.
Build CompleteMulti-stage DoneSize Check< 60MB ThresholdHealth ProbeHTTP /healthz OKSecurity ScanTrivy + bun auditAll Gates Pass → Push to RegistryAny failure blocks deployment and alerts team✗ Fail Fast Feedback Loop
Verification pipeline ensuring optimized Bun Docker images pass size, health, and security gates before production deployment

Shrink Bun Docker Images Without Sacrificing Reliability

Optimizing Bun containers isn’t just about hitting a size target — it’s about building deployment artifacts that are fast, secure, and predictable under real operational constraints. Start with the multi-stage Dockerfile above, choose your base image deliberately using the decision flowchart, enforce install flags rigorously, and never skip verification. Teams that treat image optimization as a first-class engineering discipline rather than an afterthought consistently see better incident response times, lower cloud bills, and smoother audit experiences. If you’re wrestling with Bun deployments or need a second pair of eyes on your container strategy, reach out directly — I help teams ship leaner, safer infrastructure weekly.

Frequently Asked Questions

Use debian:bookworm-slim or gcr.io/distroless/cc-debian12. Avoid alpine because Bun relies on glibc and requires extra compatibility layers that increase final size and complexity.

Optimized single-binary Bun apps typically reach 45MB to 60MB using distroless bases. Standard multi-stage builds with node_modules usually land between 90MB and 120MB depending on dependencies.

Yes, it bundles source and dependencies into one executable. You only need the base OS libraries, removing node_modules entirely and significantly reducing the final container footprint.

Check for leftover build artifacts, devDependencies in production installs, or missing .dockerignore entries. Run docker history to identify which layer added unexpected bulk to your final image.

Always use COPY. ADD includes tar extraction and URL fetching features that add unnecessary metadata. COPY is predictable, cache-friendly, and results in slightly smaller layer sizes.

Run bun install --frozen-lockfile --production in your final stage. This skips packages listed under devDependencies in package.json, preventing test frameworks and build tools from bloating the image.

No. Bun links against glibc, not musl. Running on Alpine requires installing gcompat or libc6-compat, which adds overhead and potential runtime instability compared to Debian slim variants.

Absolutely. Bun replaces both runtime and package manager. Multi-stage builds should copy only compiled artifacts or production modules, leaving Node.js tooling behind in the build stage.

Partially. While bun build --compile handles JS bundling, system libraries like libc remain dynamic. True static linking requires custom musl builds, which are unsupported and unstable for most Bun applications.

It prevents copying tests, docs, git history, and local configs into the build context. This reduces layer size and avoids accidentally including sensitive or unnecessary files in production images.

Distroless images lack shells and package managers, making debugging harder but attack surfaces smaller. Ensure logging and health checks work before deploying, as interactive troubleshooting becomes impossible without shell access.

Copy package.json and bun.lockb first, run bun install, then copy source code. This caches dependency installation separately, rebuilding only when locks change rather than on every source modification.

Yes. Use bun pm sbom or integrate syft during CI to generate software bills of materials. This maintains compliance and vulnerability tracking even when node_modules are absent from production containers.

Use docker images --format "{{.Size}}" or dive to inspect layer-by-layer composition. Apparent size differs from compressed registry size, so always check both before optimizing further.

Not directly, but BUN_INSTALL_CACHE_DIR misconfiguration can leak cache into layers. Set it explicitly in build stages and ensure no temporary directories persist into the final production image.