12k
All articles

Cómo Dockerizar una Aplicación Bun

Dockeriza una app Bun con un Dockerfile listo para producción, .dockerignore, enlace 0.0.0.0, healthchecks, Compose y apagado SIGTERM.

OpenReplay Team
OpenReplay Team
Cómo Dockerizar una Aplicación Bun

Un Dockerfile listo para producción de un servicio Bun cabe en unas pocas líneas, pero la diferencia entre un Dockerfile que funciona localmente y uno que sobrevive a un despliegue se reduce a cuatro aspectos: una imagen base fijada a una versión específica, un usuario no root, un servicio vinculado a 0.0.0.0, y un manejador de SIGTERM que drena las solicitudes en vuelo. Este artículo te proporciona el Dockerfile funcional y luego cierra cada una de esas brechas con código listo para copiar y pegar.

Tienes una aplicación Bun —una API, un worker o un backend-for-frontend— que funciona en tu máquina. Necesitas contenerizarla sin aprender los detalles de Bun con Docker de la manera difícil. A continuación encontrarás: el Dockerfile mínimo, caché de capas, una compilación multi-stage a binario, hardening para producción, Docker Compose con Postgres, un manejador de graceful shutdown para el servidor integrado de Bun, y una tabla con los cuatro fallos silenciosos más comunes junto con sus soluciones.

Puntos Clave

  • Un servicio Bun que escucha únicamente en 127.0.0.1 dentro de un contenedor no es accesible a través de un puerto publicado; vincula a 0.0.0.0 (todas las interfaces IPv4) para que el port publishing de Docker pueda reenviarle tráfico.
  • Bun 1.2, lanzado el 22 de enero de 2025, cambió el formato predeterminado del lockfile a bun.lock basado en texto; migra desde bun.lockb con bun install --save-text-lockfile --frozen-lockfile --lockfile-only.
  • Las referencias a imágenes Docker usan latest por defecto cuando no se especifica ninguna etiqueta, lo que puede cambiar implícitamente tu runtime de Bun entre despliegues; fija una etiqueta explícita como oven/bun:1.
  • Copia package.json y bun.lock antes que el código fuente de la aplicación para que la caché de capas de Docker omita bun install en las reconstrucciones donde solo cambió el código fuente.
  • Sin un manejador de SIGTERM, Docker envía SIGTERM, espera hasta su timeout de parada predeterminado (10 segundos para contenedores Linux) y luego envía SIGKILL si el proceso no ha terminado, lo que puede provocar la pérdida de solicitudes en vuelo.

El Dockerfile Mínimo

Cuatro líneas son suficientes para meter una aplicación Bun en un contenedor. Este es el camino más rápido desde funcionando-localmente hasta una imagen construida.

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

Constrúyela:

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

Bun ejecuta TypeScript directamente, por lo que no hay una etapa de compilación con tsc — el entrypoint es tu archivo .ts. Sustituye src/index.ts por tu punto de entrada real. La imagen base es la imagen oficial oven/bun en Docker Hub.

Utiliza esto en un entorno de pruebas, no en producción. La línea FROM oven/bun descarga latest implícitamente, copia todo tu directorio (incluyendo node_modules y .env), se ejecuta como root y reconstruye las dependencias en cada cambio de código. El resto de este artículo soluciona cada uno de esos problemas.

Añadir un .dockerignore

Un .dockerignore mantiene el contexto de construcción liviano e impide que secretos y artefactos locales sean copiados en la imagen. Créalo antes que cualquier otra cosa.

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

Dos entradas son especialmente importantes. node_modules se excluye porque bun install lo regenera dentro de la imagen a partir de tu lockfile — copiar el node_modules de tu host incluye binarios específicos de la plataforma que pueden no ser compatibles con el contenedor. .env y .env.* se excluyen para que los secretos locales nunca terminen en una capa de la imagen, donde persisten aunque una capa posterior elimine el archivo.

¿Debo Usar bun.lock o bun.lockb en Mi Dockerfile?

Usa bun.lock por defecto. Bun 1.2, lanzado el 22 de enero de 2025, cambió el formato predeterminado del lockfile a un archivo bun.lock basado en texto, reemplazando el antiguo bun.lockb binario. Los tutoriales más antiguos todavía hacen referencia a bun.lockb, y copiar el nombre de archivo incorrecto en tu Dockerfile produce un confuso error de --frozen-lockfile.

Si tu repositorio todavía tiene un lockfile binario, migralo una sola vez:

bun install --save-text-lockfile --frozen-lockfile --lockfile-only
# luego elimina bun.lockb y haz commit de bun.lock

Este comando está documentado en la documentación del lockfile de Bun. Tras la migración, referencia bun.lock en todos los lugares de tu Dockerfile.

Caché de Capas: Separar Dependencias del Código Fuente

Copia package.json y bun.lock antes que el código fuente de tu aplicación para que la caché de capas de Docker omita bun install en cada reconstrucción donde solo cambiaron archivos fuente — el cambio con mayor impacto individual en la velocidad de iteración local.

FROM oven/bun:1

WORKDIR /app

# Copia el lockfile y el manifiesto primero — esta capa solo se invalida
# cuando cambian las dependencias
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

# Copia el código fuente al final — editar código solo reconstruye desde aquí
COPY src ./src
COPY tsconfig.json ./

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

Docker almacena en caché cada instrucción como una capa y reutiliza las capas cacheadas cuando sus entradas no han cambiado. Como COPY package.json bun.lock precede a COPY src, editar un archivo fuente deja intacta la capa de dependencias y bun install se omite por completo. bun install --frozen-lockfile falla la construcción si bun.lock no está sincronizado con package.json — el comportamiento correcto en un Dockerfile, ya que expone la desincronización del lockfile en tiempo de construcción en lugar de en tiempo de ejecución. El comportamiento de este flag está documentado en la documentación de la CLI de bun install.

Nota la línea COPY tsconfig.json. Bun lee tsconfig.json para los path aliases y las opciones del compilador; si tu aplicación usa path mapping y olvidas copiarlo, la resolución de módulos falla al arrancar dentro del contenedor aunque funcione localmente.

Compilación Multi-Stage: Compilar a un Binario Standalone

bun build --compile crea un ejecutable autocontenido que empaqueta tu código, los paquetes npm, los assets y el runtime de Bun, por lo que la imagen final no necesita Bun instalado por separado. Úsalo en una compilación multi-stage para copiar únicamente el binario en una base mínima y obtener una imagen final más pequeña.

# --- Etapa de compilación ---
FROM oven/bun:1 AS build
WORKDIR /app

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

COPY src ./src
COPY tsconfig.json ./

# Compila a un único ejecutable que incluye el runtime de Bun
RUN bun build ./src/index.ts --compile --outfile server

# --- Etapa de ejecución ---
FROM debian:12-slim
WORKDIR /app

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

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

La etapa de compilación instala las dependencias y compila. La etapa de ejecución parte de debian:12-slim y copia únicamente el binario server compilado — sin imagen de Bun, sin node_modules, sin código fuente. El binario incluye el runtime, por lo que se ejecuta sin necesidad de tener Bun instalado. Mide la diferencia de tamaño en tu propia aplicación antes de citar una cifra; depende del número de dependencias y del tamaño de los assets.

Hardening para Producción

Fija una etiqueta explícita de Bun, ejecuta con un usuario no root e instala únicamente las dependencias de producción. Estos tres cambios convierten un Dockerfile funcional en uno desplegable.

FROM oven/bun:1-alpine

WORKDIR /app

# Crea un usuario no root (sintaxis de Alpine)
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"]

Fija la etiqueta. Las referencias a imágenes Docker usan latest por defecto cuando no se especifica ninguna etiqueta, lo que significa que un docker pull puede cambiar implícitamente tu runtime de Bun entre despliegues. Fija una etiqueta explícita de Bun para mantener las actualizaciones del runtime de forma deliberada y auditable — la guía oficial de Docker de Bun usa oven/bun:1. La variante 1-alpine existe en Docker Hub para un footprint más pequeño; verifica que es compatible con tus dependencias antes de adoptarla, ya que Alpine usa musl libc en lugar de glibc.

Ejecuta como usuario no root. El usuario predeterminado del contenedor es root. Crear appuser y cambiar a él con USER appuser limita el radio de impacto si el proceso se ve comprometido — una práctica base de la guía de seguridad de Docker.

Instala solo las dependencias de producción. bun install --frozen-lockfile --production omite las devDependencies, manteniendo la imagen más pequeña y la superficie de ataque más reducida.

¿Por Qué Mi Contenedor Bun No Es Accesible en Su Puerto Publicado?

Un servicio Bun que escucha únicamente en 127.0.0.1 dentro de un contenedor Docker no será accesible a través de un puerto publicado. Vincula a 0.0.0.0 (todas las interfaces IPv4) para que el port publishing de Docker pueda reenviarle tráfico.

Este es el fallo silencioso más común en un servicio Bun dockerizado. El contenedor arranca, los logs parecen correctos, docker ps muestra el puerto mapeado — y cada solicitud desde el host se queda colgada o devuelve un error de conexión. La causa es la dirección de escucha.

Para Bun.serve() directamente, establece hostname de forma explícita:

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

Bun.serve({
  port,
  hostname: "0.0.0.0", // obligatorio dentro de 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}`);

Las opciones de Bun.serve() están documentadas en la documentación de la API HTTP de Bun. Para Elysia, configura serve.hostname: "0.0.0.0". Para otros frameworks que se ejecutan sobre Bun, asegúrate de que el servidor Bun subyacente esté configurado para escuchar en 0.0.0.0.

Las session replays de frontends que se comunican con un servicio Bun pueden exponer este fallo una capa más abajo, en el Dockerfile. Un servicio vinculado a localhost se manifiesta en el navegador como una cascada de timeouts en fetch/XHR inmediatamente después de un despliegue — el tipo de síntoma que parece un bug de frontend pero que tiene su origen en la dirección de escucha del contenedor. Herramientas como OpenReplay capturan ese patrón de fallo en el lado del cliente, que es donde esta mala configuración suele detectarse por primera vez.

Docker Compose con Postgres

Un archivo Docker Compose ejecuta tu aplicación Bun junto a PostgreSQL, espera a que la base de datos pase un healthcheck antes de arrancar la aplicación, y reinicia la aplicación en caso de fallo.

# 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 con condition: service_healthy retiene el servicio api hasta que Postgres reporta que está sano, lo cual requiere un healthcheck en la dependencia — sin él, service_healthy nunca se resuelve. restart: unless-stopped reinicia la aplicación tras un crash pero respeta una parada manual.

Observa que no hay clave version: de nivel superior. La Especificación de Compose marca la propiedad version de nivel superior como obsoleta; Compose la ignora para la selección de esquema y emite una advertencia si está presente. Omítela.

Variables de Entorno: No Hornees Secretos en la Imagen

Pasa la configuración a través de env_file en tiempo de ejecución en lugar de copiar secretos en una capa de la imagen. Las capas de imagen son persistentes e inspeccionables, por lo que un secreto escrito en una de ellas permanece recuperable aunque una instrucción posterior lo elimine.

El servicio de Compose anterior ya referencia .env.production mediante env_file. Lee esos valores en el código desde process.env:

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

Mantén .env.production fuera de la imagen con la entrada .env.* en .dockerignore, y fuera del control de versiones con una entrada equivalente en .gitignore. La imagen permanece genérica; los secretos se inyectan en tiempo de ejecución.

Flujo de Trabajo de Desarrollo: Hot Reload con Bind Mount

Para el desarrollo local, usa un Dockerfile separado que ejecute bun --hot y monta tu código fuente como un volumen para que los cambios guardados recarguen instantáneamente dentro del contenedor. Mantén esto separado de tu Dockerfile de producción.

# 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

El flag --hot habilita el hot reloading de Bun: guardar un archivo actualiza el servidor en ejecución sin reiniciar completamente el proceso. El bind mount .:/app hace que las ediciones del host sean visibles dentro del contenedor, y el volumen anónimo /app/node_modules evita que el directorio del host oculte las dependencias instaladas durante la construcción. Ejecútalo con docker compose -f compose.dev.yaml up --build.

Graceful Shutdown: Manejar SIGTERM

Añade un manejador de SIGTERM para que las solicitudes en vuelo terminen antes de que el proceso salga. Sin él, Docker envía SIGTERM, espera hasta su timeout de parada predeterminado (10 segundos para contenedores Linux) y luego envía SIGKILL si el proceso no ha terminado, lo que puede provocar la pérdida de solicitudes que aún se están sirviendo.

Bun.serve() devuelve un objeto servidor con un método .stop(). Espéralo en SIGTERM para que las solicitudes en vuelo terminen antes de salir:

// 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"));

El objeto server y su método .stop() forman parte de la API HTTP de Bun. El patrón central consiste en capturar la instancia del servidor devuelta por Bun.serve() y detenerla cuando llega una señal de terminación.

Tamaños de Imagen Esperados

La imagen base que elijas determina en gran medida el tamaño final de la imagen. Docker Hub publica los tamaños comprimidos para cada variante de oven/bun; las cifras a continuación provienen de la página de tags de Docker Hub para oven/bun:1.3.14 (linux/amd64).

Imagen baseVarianteTamaño comprimido
oven/bun:1.3.14Basada en Debian81.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

Estos son tamaños de imagen base, no de tu imagen final. Tu imagen añade dependencias y código fuente encima. Para medir la tuya, ejecuta:

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

La variante multi-stage con binario compilado sobre debian:12-slim puede reducir aún más la imagen final porque omite la imagen completa de Bun y node_modules, pero la diferencia exacta depende del footprint de tus dependencias — mídela en lugar de citar un porcentaje genérico.

¿Cuáles Son los Fallos Más Comunes en Contenedores Bun con Docker?

Las cuatro razones más comunes por las que un contenedor Bun dockerizado falla silenciosamente son: una ruta de entrypoint incorrecta, vinculación a localhost, un lockfile desactualizado y un paso de copia de tsconfig.json faltante. Cada uno tiene un síntoma distinto y una solución de una línea.

SíntomaCausa raízSolución
El contenedor sale inmediatamente, código 1Ruta de entrypoint incorrecta en CMD, o el archivo de entrada no fue copiadoVerifica que la ruta en CMD ["bun", "run", "src/index.ts"] coincide con el archivo copiado; comprueba que COPY src ./src se ejecutó
Puerto mapeado pero inaccesibleServicio vinculado a 127.0.0.1/localhostVincula a 0.0.0.0 en Bun.serve({ hostname: "0.0.0.0" })
La construcción se interrumpe durante bun installbun.lock desincronizado con package.jsonEjecuta bun install localmente, haz commit del bun.lock actualizado; --frozen-lockfile pasará entonces
La resolución de módulos falla al arrancarPath aliases de tsconfig.json sin resolver — archivo no copiadoAñade COPY tsconfig.json ./ antes del CMD

Cuando un contenedor sale antes de que puedas inspeccionarlo, abre una shell en la imagen para comprobar qué se copió realmente:

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

El caso de desincronización del lockfile es el que más frecuentemente se interpreta erróneamente como un problema de Docker. Es un problema de Bun que Docker expone — bun install --frozen-lockfile está haciendo su trabajo al negarse a construir contra un lockfile desincronizado, exactamente como describen la documentación de bun install.

Conclusión

Ahora tienes todas las piezas: un Dockerfile fijado, no root y listo para producción; una variante multi-stage con binario compilado; un stack de Compose con Postgres con healthcheck; herramientas de desarrollo con hot reload; y un manejador de SIGTERM que drena las solicitudes de forma limpia. Empieza reemplazando oven/bun por oven/bun:1 y añadiendo el bind a 0.0.0.0 — esas dos ediciones eliminan los dos fallos que con mayor probabilidad te afectarán en tu primer despliegue; luego incorpora el resto a medida que tu servicio crezca.

Preguntas Frecuentes

¿Necesito un paso de compilación separado para ejecutar TypeScript en un contenedor Docker con Bun?

No. Bun ejecuta TypeScript directamente, por lo que no hay una etapa de compilación con tsc o tsx en la construcción Docker. Tu CMD apunta directamente al archivo de entrada .ts, por ejemplo CMD ['bun', 'run', 'src/index.ts']. La única advertencia son los path aliases: si tu aplicación usa path mapping de tsconfig.json, debes copiar tsconfig.json en la imagen, o la resolución de módulos fallará al arrancar aunque funcione localmente.

¿Cuándo debo usar un binario compilado con bun build --compile en lugar de ejecutar bun run en el contenedor?

Usa la compilación multi-stage con binario compilado cuando quieras una imagen final más pequeña y sin instalación de Bun en la etapa de ejecución. bun build --compile produce un ejecutable autocontenido que empaqueta tu código, los paquetes npm, los assets y el runtime de Bun, por lo que puedes copiar únicamente ese binario en una base mínima como debian:12-slim sin node_modules ni imagen de Bun. Usa bun run directamente para compilaciones más sencillas o cuando dependas de características en tiempo de ejecución como el hot reload durante el desarrollo.

¿La variante alpine de la imagen oven/bun funciona con todas las dependencias?

No siempre. La variante oven/bun:1-alpine usa musl libc en lugar de glibc, mientras que la imagen predeterminada basada en Debian oven/bun:1 usa glibc. Las dependencias nativas compiladas contra glibc, o los paquetes que distribuyen binarios precompilados solo para glibc, pueden fallar en Alpine. La imagen Alpine es más pequeña, pero verifica que tu árbol de dependencias compila y funciona allí antes de adoptarla en producción, en lugar de asumir que es un reemplazo directo.

¿Por qué falla la construcción de mi contenedor durante bun install con un error de frozen-lockfile?

La construcción se interrumpe porque bun.lock está desincronizado con package.json y bun install --frozen-lockfile se niega a construir contra un lockfile desincronizado. Este es el comportamiento correcto en un Dockerfile: expone la desincronización del lockfile en tiempo de construcción en lugar de en tiempo de ejecución. Corrígelo ejecutando bun install localmente para regenerar bun.lock y luego haciendo commit del archivo actualizado. Si tu Dockerfile todavía referencia el antiguo bun.lockb binario, cámbialo a bun.lock, ya que Bun 1.2 convirtió el formato basado en texto en el predeterminado.

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.