BACK TO ENGINEERING
Runtime 9 min read

From 1.2GB to 89MB: The Docker Multi-Stage Build That Changed How I Ship Bun Applications

Article Hero

Your Docker images are embarrassingly large.

I can say that with confidence because mine were too. 1.2 gigabytes. Per service. Six services on an 80-gigabyte VPS meant almost 10% of my disk consumed by Docker images alone.

Every deployment pulled a new image. Every image filled up more disk. And eventually — inevitably — the 3 AM alert: disk full, services down.

I got my Bun production images down to 89 megabytes. A 93% reduction. Here's every decision that got me there.


I – Why Your First Dockerfile Is Always Wrong

Everyone writes the same naive Dockerfile the first time.

You start from the default Bun image. You copy everything. You install all dependencies. You expose a port and run the app. It works. You ship it. You move on.

Then you check the image size and discover 1.2 gigabytes of dead weight.

The default Bun image is based on Debian, which alone is around 800 megabytes. Copying everything means your entire Git history, your test files, your README, your local node_modules — all of it gets baked into the image. Installing without flags pulls every dev dependency. There's no .dockerignore, so the Docker build context is enormous.

And you're running as root. Which means if an attacker exploits your application, they own the entire container.

Every one of these is a problem. And every one has a specific, measurable fix.


II – The First Line of Defense You're Probably Missing

Before touching the Dockerfile, create a .dockerignore file.

This is the most underappreciated optimization in Docker. The .dockerignore tells Docker what not to send to the build daemon. Without it, Docker ships your entire project directory — including node_modules, .git, test files, coverage reports, and every markdown file you've ever written — to the daemon before building even starts.

A proper .dockerignore can reduce your build context from 500 megabytes to 5 megabytes. That's not just a disk saving. It's a speed saving. Every build starts by transferring the context, and transferring half a gigabyte on every build adds up fast.

Exclude your dependencies directory because you'll reinstall inside Docker. Exclude .git because version history has no business in production. Exclude IDE configs, environment files, build artifacts, test files, documentation, Docker-related files, and OS artifacts.

This one file, written once, saves megabytes on every single build for the rest of the project's life.


III – Three Stages, Each With a Purpose

Multi-stage builds are the core technique. The idea is simple: use multiple FROM statements in one Dockerfile, each creating a disposable stage. Only the final stage becomes your production image. Everything else is thrown away.

Stage one is dependencies. Start from the Alpine variant of the Bun image — that's 50 megabytes instead of 800 for Debian, a 93% reduction in base image size alone. Copy only your package manifest and lockfile. Install with the frozen lockfile flag so the lockfile is the source of truth. If someone added a dependency without committing the lockfile, the build fails. That's a feature, not a bug.

Why a separate stage just for dependencies? Docker layer caching. If your package manifest hasn't changed since the last build, Docker reuses the cached install result. A 30-second install becomes a zero-second cache hit. This matters enormously when you're deploying multiple times a day.

Stage two is the build. Copy the node_modules from stage one, then copy your source code. This is where you generate your Prisma client, compile your SPA bundles, or run any other build step that needs both dependencies and source code.

Why not generate Prisma in the dependencies stage? Because Prisma generation needs your schema file, which is source code, not a dependency manifest. Why not generate it in the production stage? Because the Prisma CLI is a dev dependency. The generated client is what production needs. By generating in the build stage, you get the client without shipping the CLI.

Stage three is production. Start fresh from Alpine again. Install security patches. Create a non-root user. Then copy — selectively, explicitly — only the files the application needs to run.


IV – The Art of Selective Copying

This is the most important optimization and the one most people skip.

In the production stage, you don't copy the entire build directory. You list exactly what the runtime needs. Your source directory. Your package manifest for module resolution. Your node_modules. Your Prisma schema and generated client.

What you deliberately don't copy: your Git history, test files, build configs, TypeScript configuration, Docker Compose files, documentation. Your container doesn't need a README. It needs to run your application.

You can go further. Instead of copying all of node_modules — which includes every dev dependency — you can copy only the generated Prisma client from its specific location within node_modules. This avoids shipping the Prisma CLI, type generation tools, and everything else that has no business in production.

For a typical project, this cuts node_modules from around 200 megabytes to about 80 megabytes. Combined with the Alpine base image and selective copying, the total image drops below 100 megabytes.


V – Never Run Containers as Root

This is a security principle, not an optimization. But I'm including it because I see it violated in almost every Dockerfile I review.

If an attacker exploits a vulnerability in your application, they get the privileges of the user running the process. As root, they can install packages, modify system files, access other containers through shared volumes, and pivot to the host.

As a restricted application user, they're confined to the application directory with limited system access.

Create a system group and a system user with a UID above 1000 to avoid conflicts with system users. Set ownership on the application directory. Switch to the non-root user before the application starts.

This adds two lines to your Dockerfile and eliminates an entire class of container escape vulnerabilities. There's no excuse for skipping it.


VI – The Entrypoint Pattern That Changed My Deployments

Here's one of the most underappreciated patterns for containerized applications: run your database migrations at container startup, not as a separate CI/CD step.

The entrypoint script does three things. First, it waits for the database to be ready. In Docker environments, containers start in parallel. Your application might start before PostgreSQL is accepting connections. The script retries with a delay, checking database connectivity on each attempt.

Second, it runs a schema synchronization. For Prisma, this means comparing the schema definition to the actual database and applying any differences. It's declarative — you define what the schema should look like, and the tool figures out how to get there.

Third, it hands off to the actual application command using exec, which replaces the shell process with the Bun process. This is critical. Without exec, the shell stays alive as PID 1 and your application runs as a child process. SIGTERM from Docker stop goes to the shell, not to Bun. With exec, Bun receives signals directly and can shut down gracefully.

This collapses three deployment steps — wait for database, run migrations, start application — into a single container startup. No separate migration job. No ordering issues. No race conditions between the migration step and the application step. The container is self-contained.


VII – Health Checks That Save You at 3 AM

A Dockerfile without a HEALTHCHECK instruction is a container that your orchestrator can't monitor. It runs, but nobody knows if it's working.

The health check should ping your application's health endpoint at a reasonable interval — every thirty seconds is a good balance between detection speed and resource usage. Set a timeout shorter than the interval. Give a startup grace period so the entrypoint script can finish migrations before health checks start counting. Require multiple consecutive failures before marking unhealthy to avoid false positives from momentary load spikes.

The health endpoint itself should do more than return 200. It should check database connectivity. A service that's running but can't reach its database isn't healthy. Returning a 503 triggers the orchestrator to restart the container, which re-runs the entrypoint script and re-checks the database.

For Bun specifically, you can use an inline eval command for the health check instead of installing curl or wget. Alpine doesn't ship with curl, and adding it would increase your image by about five megabytes. The Bun runtime is already in the image — use it.


VIII – The Size Reduction, Step by Step

Here's the actual impact of each optimization, measured on a real production service.

Starting with the naive Dockerfile on Debian with everything included: 1,247 megabytes.

Switching to Alpine base: 412 megabytes. That's a 67% reduction from one change.

Adding the .dockerignore: 380 megabytes. Another 8% off.

Multi-stage build with no dev dependencies in production: 156 megabytes. A 59% cut.

Selective file copying instead of copying everything: 124 megabytes. Another 21%.

Copying only the used Prisma engine binary instead of all platform variants: 89 megabytes. A final 28% reduction.

Total: 1,247 megabytes to 89 megabytes. Ninety-three percent smaller.

At 89 megabytes per service, six services use 534 megabytes of disk for Docker images. Compare to 7.5 gigabytes with the naive approach. That's 7 gigabytes of disk saved — meaningful on a VPS where every gigabyte counts.


IX – Prisma Engine Optimization (The Hidden Win)

Prisma bundles query engine binaries for multiple platforms by default. macOS, Windows, Debian Linux, Alpine Linux. In a Docker image, you only need the one that matches your container's operating system and architecture.

You can tell Prisma exactly which engine to include by specifying the binary target in your schema's generator block. For Alpine on AMD64 architecture, that's the musl OpenSSL variant. For ARM64, it's the ARM musl variant.

This prevents Prisma from bundling engines for platforms you'll never run. Each unused engine is 15 to 30 megabytes. Cutting three of them saves up to 90 megabytes — which can be the difference between an 89-megabyte image and a 180-megabyte one.


X – Build Cache Acceleration

Modern Docker uses BuildKit, which supports persistent cache mounts. Instead of downloading every package from the registry on every build, Bun can reuse cached packages from previous builds.

This turns a 30-second install into a 5-second cache hit when only a few dependencies have changed. Over dozens of daily deployments, that time adds up.

Coolify uses BuildKit by default. If you're deploying with any modern Docker version, you already have access to this. The syntax is a mount directive on the install command that points to Bun's internal cache directory.

Combined with layer caching from the separate dependencies stage, most builds that only change application code complete in under 15 seconds. That's the difference between deploying with confidence and deploying with anxiety.


Want to Optimize Your Docker Setup? Let's Do It Together.

I've reviewed dozens of Dockerfiles and helped developers go from bloated multi-gigabyte images to lean, secure, production-ready containers.

Whether you're running on a single VPS or preparing for scale, your Docker patterns are foundation-level decisions that compound over time.

Book a session at mentoring.oakoliver.com and let's audit your Dockerfile together. Or see these patterns running in production across six services at oakoliver.com.


XI – The Principles That Survive

Multi-stage builds are mandatory. Three stages: dependencies, build, production. The production image contains exactly what it needs to run and nothing more.

Alpine saves 700 megabytes. Non-root users prevent container escapes. Pinned versions make builds reproducible. Health checks make containers observable. Entrypoint scripts make deployments self-contained. Build caches make iteration fast.

From 1.2 gigabytes to 89 megabytes. From root to non-root. From "hope it works" to health-checked, migration-ready, security-scanned production containers.

Small images deploy faster, use less disk, transfer less bandwidth, and give attackers less surface area.

What's your production Docker image size — and have you ever measured what's actually inside it?

– Antonio

"Simplicity is the ultimate sophistication."