12k
All articles

How to Dockerize a Bun Application

Dockerize a Bun app with a production-ready Dockerfile, .dockerignore, 0.0.0.0 binding, healthchecks, Compose, and graceful SIGTERM shutdown.

OpenReplay Team
OpenReplay Team
How to Dockerize a Bun Application

A production-credible Dockerfile for a Bun service fits in a handful of lines, but the difference between a Dockerfile that runs locally and one that survives a deploy comes down to four things: a pinned base image, a non-root user, a service bound to 0.0.0.0, and a SIGTERM handler that drains in-flight requests. This article gives you the working Dockerfile, then closes each of those gaps with copy-paste code.

You have a Bun app — an API, a worker, or a backend-for-frontend — that runs on your machine. You need to containerize it without learning Bun’s Docker quirks the hard way. Below: the minimal Dockerfile, layer caching, a multi-stage compiled binary, production hardening, Docker Compose with Postgres, a graceful-shutdown handler for Bun’s built-in server, and a table of the four most common silent failures with their fixes.

Key Takeaways

  • A Bun service listening only on 127.0.0.1 inside a container is unreachable through a published port; bind to 0.0.0.0 (all IPv4 interfaces) so Docker’s port publishing can forward traffic to it.
  • Bun 1.2, released January 22, 2025, changed the default lockfile format to text-based bun.lock; migrate from bun.lockb with bun install --save-text-lockfile --frozen-lockfile --lockfile-only.
  • Docker image references default to latest when no tag is specified, which can implicitly change your Bun runtime between deploys; pin an explicit tag such as oven/bun:1.
  • Copy package.json and bun.lock before application source so Docker’s layer cache skips bun install on rebuilds where only source changed.
  • Without a SIGTERM handler, Docker sends SIGTERM, waits up to its default stop timeout (10 seconds for Linux containers), and then sends SIGKILL if the process has not exited, potentially dropping in-flight requests.

The Minimal Dockerfile

Four lines get a Bun app into a container. This is the fastest path from working-locally to a built image.

FROM oven/bun
COPY . .
RUN bun install
CMD ["bun", "run", "src/index.ts"]

Build it:

docker build -t bun-app .
docker run -p 3000:3000 bun-app

Bun executes TypeScript directly, so there is no tsc build stage — the entrypoint is your .ts file. Swap src/index.ts for your actual entry point. The base image is the official oven/bun image on Docker Hub.

Ship this to a scratchpad, not to production. The FROM oven/bun line pulls latest implicitly, copies your whole directory (including node_modules and .env), runs as root, and rebuilds dependencies on every code change. The rest of this article fixes each of those.

Add a .dockerignore

A .dockerignore keeps your build context lean and stops secrets and local artifacts from being copied into the image. Create it before anything else.

node_modules
.git
.gitignore
.env
.env.*
dist
*.log
.vscode
README.md

Two entries matter most. node_modules is excluded because bun install regenerates it inside the image from your lockfile — copying your host’s node_modules ships platform-specific binaries that may not match the container. .env and .env.* are excluded so local secrets never land in an image layer, where they persist even if a later layer deletes the file.

Should I Use bun.lock or bun.lockb in My Dockerfile?

Default to bun.lock. Bun 1.2, released January 22, 2025, changed the default lockfile format to a text-based bun.lock file, replacing the older binary bun.lockb. Older tutorials still reference bun.lockb, and copying the wrong filename in your Dockerfile produces a confusing --frozen-lockfile failure.

If your repo still has a binary lockfile, migrate it once:

bun install --save-text-lockfile --frozen-lockfile --lockfile-only
# then delete bun.lockb and commit bun.lock

This command is documented in the Bun lockfile docs. After migrating, reference bun.lock everywhere in your Dockerfile.

Layer Caching: Split Dependencies From Source

Copy package.json and bun.lock before your application source so Docker’s layer cache skips bun install on every rebuild where only source files changed — the single highest-impact change for local iteration speed.

FROM oven/bun:1

WORKDIR /app

# Copy lockfile and manifest first — this layer only invalidates
# when dependencies change
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

# Copy source last — editing code only rebuilds from here down
COPY src ./src
COPY tsconfig.json ./

CMD ["bun", "run", "src/index.ts"]

Docker caches each instruction as a layer and reuses cached layers when their inputs are unchanged. Because COPY package.json bun.lock precedes COPY src, editing a source file leaves the dependency layer untouched and bun install is skipped entirely. bun install --frozen-lockfile fails the build if bun.lock is out of sync with package.json — the correct behavior in a Dockerfile, because it surfaces lockfile drift at build time rather than at runtime. The flag’s behavior is documented in the Bun install CLI docs.

Note the COPY tsconfig.json line. Bun reads tsconfig.json for path aliases and compiler options; if your app uses path mapping and you forget to copy it, module resolution fails at startup inside the container even though it works locally.

Multi-Stage Build: Compile to a Standalone Binary

bun build --compile creates a self-contained executable that bundles your code, npm packages, assets, and the Bun runtime, so the final image does not need Bun installed separately. Use it in a multi-stage build to copy only the binary into a minimal base for a smaller final image.

# --- Build stage ---
FROM oven/bun:1 AS build
WORKDIR /app

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY src ./src
COPY tsconfig.json ./

# Compile to a single executable that includes the Bun runtime
RUN bun build ./src/index.ts --compile --outfile server

# --- Runtime stage ---
FROM debian:12-slim
WORKDIR /app

COPY --from=build /app/server /app/server

USER nobody
EXPOSE 3000
CMD ["/app/server"]

The build stage installs dependencies and compiles. The runtime stage starts from debian:12-slim and copies in only the compiled server binary — no Bun image, no node_modules, no source. The binary includes the runtime, so it runs without Bun present. Measure the size delta on your own app before quoting a number; it depends on dependency count and asset size.

Production Hardening

Pin an explicit Bun tag, run as a non-root user, and install production dependencies only. These three changes turn a working Dockerfile into a deployable one.

FROM oven/bun:1-alpine

WORKDIR /app

# Create a non-root user (Alpine syntax)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production

COPY src ./src
COPY tsconfig.json ./

RUN chown -R appuser:appgroup /app
USER appuser

ENV NODE_ENV=production
EXPOSE 3000

CMD ["bun", "run", "src/index.ts"]

Pin the tag. Docker image references default to latest when no tag is specified, which means a docker pull can implicitly change your Bun runtime between deploys. Pin an explicit Bun tag to keep runtime upgrades deliberate and auditable — Bun’s official Docker guide uses oven/bun:1. The 1-alpine variant exists on Docker Hub for a smaller footprint; verify it suits your dependencies before adopting it, since Alpine uses musl libc rather than glibc.

Run as non-root. The default container user is root. Creating appuser and switching to it with USER appuser limits blast radius if the process is compromised — a baseline of Docker’s security guidance.

Install production dependencies only. bun install --frozen-lockfile --production skips devDependencies, keeping the image smaller and the attack surface narrower.

Why Isn’t My Bun Container Reachable on Its Published Port?

A Bun service that listens only on 127.0.0.1 inside a Docker container will not be reachable through a published port. Bind to 0.0.0.0 (all IPv4 interfaces) so Docker’s port publishing can forward traffic to it.

This is the most common silent failure in a Dockerized Bun service. The container starts, the logs look healthy, docker ps shows the port mapped — and every request from the host hangs or returns a connection error. The cause is the listen address.

For raw Bun.serve(), set hostname explicitly:

// src/index.ts
const port = Number(process.env.PORT) || 3000;

Bun.serve({
  port,
  hostname: "0.0.0.0", // required inside Docker
  fetch(req) {
    return new Response(JSON.stringify({ status: "ok" }), {
      headers: { "Content-Type": "application/json" },
    });
  },
});

console.log(`Listening on http://0.0.0.0:${port}`);

The Bun.serve() options are documented in the Bun HTTP API docs. For Elysia, configure serve.hostname: "0.0.0.0". For other frameworks running on Bun, ensure the underlying Bun server is configured to listen on 0.0.0.0.

Session replays of frontends talking to a Bun service can surface this failure one layer down, in the Dockerfile. A service bound to localhost presents in the browser as a cascade of fetch/XHR timeouts immediately after a deploy — the kind of symptom that looks like a frontend bug but originates in the container’s listen address. Tools like OpenReplay capture that client-side failure pattern, which is where this misconfiguration usually gets noticed first.

Docker Compose With Postgres

A Docker Compose file runs your Bun app alongside PostgreSQL, waits for the database to pass a healthcheck before starting the app, and restarts the app on failure.

# compose.yaml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    env_file:
      - .env.production
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

depends_on with condition: service_healthy holds the api service until Postgres reports healthy, which requires a healthcheck on the dependency — without one, service_healthy never resolves. restart: unless-stopped brings the app back after a crash but respects a manual stop.

Note there is no top-level version: key. The Compose Specification marks the top-level version property as obsolete; Compose ignores it for schema selection and warns if it is present. Omit it.

Environment Variables: Don’t Bake Secrets In

Pass configuration through env_file at runtime rather than copying secrets into an image layer. Image layers are persistent and inspectable, so a secret written into one stays recoverable even if a later instruction deletes it.

The Compose service above already references .env.production via env_file. Read those values in code from process.env:

// src/config.ts
export const config = {
  port: Number(process.env.PORT) || 3000,
  databaseUrl: process.env.DATABASE_URL ?? "",
  jwtSecret: process.env.JWT_SECRET ?? "",
};

Keep .env.production out of the image with the .env.* entry in .dockerignore, and out of version control with a matching .gitignore entry. The image stays generic; secrets are injected at run time.

Development Workflow: Hot Reload With a Bind Mount

For local development, use a separate Dockerfile that runs bun --hot and mount your source as a volume so saves reload instantly inside the container. Keep this distinct from your production Dockerfile.

# Dockerfile.dev
FROM oven/bun:1-alpine
WORKDIR /app

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .
EXPOSE 3000

CMD ["bun", "--hot", "src/index.ts"]
# compose.dev.yaml
services:
  api-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    env_file:
      - .env.development

The --hot flag enables Bun’s hot reloading: saving a file updates the running server without a full process restart. The bind mount .:/app makes host edits visible inside the container, and the anonymous /app/node_modules volume prevents the host directory from shadowing the dependencies installed during the build. Run it with docker compose -f compose.dev.yaml up --build.

Graceful Shutdown: Handle SIGTERM

Add a SIGTERM handler so in-flight requests finish before the process exits. Without one, Docker sends SIGTERM, waits up to its default stop timeout (10 seconds for Linux containers), and then sends SIGKILL if the process has not exited, potentially dropping requests still being served.

Bun.serve() returns a server object with a .stop() method. Await it on SIGTERM to let in-flight requests finish before exiting:

// src/index.ts
const server = Bun.serve({
  port: Number(process.env.PORT) || 3000,
  hostname: "0.0.0.0",
  fetch() {
    return new Response("ok");
  },
});

const shutdown = async (signal: string) => {
  console.log(`Received ${signal}, draining connections`);
  await server.stop();
  process.exit(0);
};

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

The server handle and its .stop() method are part of the Bun HTTP API. The core pattern is to capture the server instance returned by Bun.serve() and stop it when a termination signal arrives.

Image Size Expectations

The base image you choose drives most of the final image size. Docker Hub publishes compressed sizes for each oven/bun variant; the figures below are from the Docker Hub tags page for oven/bun:1.3.14 (linux/amd64).

Base imageVariantCompressed size
oven/bun:1.3.14Debian-based81.93 MB
oven/bun:1.3.14-slimDebian slim63.32 MB
oven/bun:1.3.14-alpineAlpine40.87 MB
oven/bun:1.3.14-distrolessDistroless40.52 MB

These are base-image sizes, not your final app image. Your image adds dependencies and source on top. To measure your own, run:

docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"

The compiled-binary multi-stage variant on debian:12-slim can shrink the final image further because it omits the full Bun image and node_modules, but the exact delta depends on your dependency footprint — measure it rather than quoting a generic percentage.

What Are the Most Common Bun Docker Container Failures?

The four most common reasons a Dockerized Bun container fails silently are a wrong entrypoint path, localhost binding, a stale lockfile, and a missing tsconfig.json copy step. Each has a distinct symptom and a one-line fix.

SymptomRoot causeFix
Container exits immediately, code 1Wrong entrypoint path in CMD, or entry file not copiedVerify the path in CMD ["bun", "run", "src/index.ts"] matches the copied file; check COPY src ./src ran
Port mapped but unreachableService bound to 127.0.0.1/localhostBind to 0.0.0.0 in Bun.serve({ hostname: "0.0.0.0" })
Build aborts during bun installbun.lock out of sync with package.jsonRun bun install locally, commit the updated bun.lock; --frozen-lockfile then passes
Module resolution fails at startuptsconfig.json path aliases unresolved — file not copiedAdd COPY tsconfig.json ./ before the CMD

When a container exits before you can inspect it, drop into a shell on the image to check what was actually copied:

docker run -it  --rm  --entrypoint  sh bun-app
# inside: ls /app, cat package.json, etc.

The lockfile-mismatch case is the one most often misread as a Docker problem. It is a Bun problem surfaced by Docker — bun install --frozen-lockfile is doing its job by refusing to build against a drifted lockfile, exactly as the Bun install docs describe.

Conclusion

You now have every piece: a pinned, non-root, production Dockerfile; a multi-stage compiled-binary variant; a Compose stack with a healthchecked Postgres; hot-reload dev tooling; and a SIGTERM handler that drains requests cleanly. Start by replacing oven/bun with oven/bun:1 and adding the 0.0.0.0 bind — those two edits eliminate the two failures most likely to bite you on your first deploy, then layer in the rest as your service grows.

FAQs

Do I need a separate build step to run TypeScript in a Bun Docker container?

No. Bun executes TypeScript directly, so there is no tsc or tsx compilation stage in the Docker build. Your CMD points straight at the .ts entry file, for example CMD ['bun', 'run', 'src/index.ts']. The only caveat is path aliases: if your app uses tsconfig.json path mapping, you must copy tsconfig.json into the image, or module resolution fails at startup even though it works locally.

When should I use a compiled binary with bun build --compile instead of running bun run in the container?

Use the compiled-binary multi-stage build when you want a smaller final image and no Bun installation in the runtime stage. bun build --compile produces a self-contained executable bundling your code, npm packages, assets, and the Bun runtime, so you can copy just that binary onto a minimal base like debian:12-slim with no node_modules or Bun image. Use plain bun run for simpler builds or when you rely on runtime features like hot reload during development.

Does the alpine variant of the oven/bun image work with every dependency?

Not always. The oven/bun:1-alpine variant uses musl libc instead of glibc, while the default Debian-based oven/bun:1 image uses glibc. Native dependencies compiled against glibc, or packages distributing glibc-only prebuilt binaries, can fail on Alpine. The Alpine image is smaller, but verify your dependency tree builds and runs there before adopting it in production rather than assuming a drop-in swap.

Why does my container build fail during bun install with a frozen-lockfile error?

The build aborts because bun.lock is out of sync with package.json and bun install --frozen-lockfile refuses to build against a drifted lockfile. This is correct behavior in a Dockerfile: it surfaces lockfile drift at build time rather than at runtime. Fix it by running bun install locally to regenerate bun.lock, then commit the updated file. If your Dockerfile still references the older binary bun.lockb, switch it to bun.lock, since Bun 1.2 made the text-based format the default.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.