12k
All articles

Comment Dockeriser une Application Bun

Dockerisez une app Bun avec un Dockerfile prêt pour la production, .dockerignore, liaison 0.0.0.0, healthchecks, Compose et arrêt SIGTERM.

OpenReplay Team
OpenReplay Team
Comment Dockeriser une Application Bun

Un Dockerfile crédible pour la production d’un service Bun tient en quelques lignes, mais la différence entre un Dockerfile qui fonctionne en local et un qui survit à un déploiement repose sur quatre éléments : une image de base épinglée, un utilisateur non-root, un service lié à 0.0.0.0, et un gestionnaire SIGTERM qui draine les requêtes en cours. Cet article vous fournit le Dockerfile fonctionnel, puis comble chacune de ces lacunes avec du code prêt à copier-coller.

Vous disposez d’une application Bun — une API, un worker, ou un backend-for-frontend — qui fonctionne sur votre machine. Vous devez la conteneuriser sans apprendre les particularités Docker de Bun à la dure. Voici ce que vous trouverez ci-dessous : le Dockerfile minimal, la mise en cache des layers, un binaire compilé multi-stage, le durcissement pour la production, Docker Compose avec Postgres, un gestionnaire d’arrêt gracieux pour le serveur intégré de Bun, et un tableau des quatre échecs silencieux les plus courants avec leurs correctifs.

Points Clés

  • Un service Bun qui écoute uniquement sur 127.0.0.1 à l’intérieur d’un conteneur est inaccessible via un port publié ; liez-le à 0.0.0.0 (toutes les interfaces IPv4) pour que la publication de ports Docker puisse lui transférer le trafic.
  • Bun 1.2, sorti le 22 janvier 2025, a modifié le format par défaut du lockfile en faveur du format texte bun.lock ; migrez depuis bun.lockb avec bun install --save-text-lockfile --frozen-lockfile --lockfile-only.
  • Les références d’images Docker utilisent par défaut latest lorsqu’aucun tag n’est spécifié, ce qui peut modifier implicitement votre runtime Bun entre les déploiements ; épinglez un tag explicite tel que oven/bun:1.
  • Copiez package.json et bun.lock avant le code source de l’application afin que le cache de layers Docker ignore bun install lors des reconstructions où seul le code source a changé.
  • Sans gestionnaire SIGTERM, Docker envoie SIGTERM, attend jusqu’à son délai d’arrêt par défaut (10 secondes pour les conteneurs Linux), puis envoie SIGKILL si le processus ne s’est pas terminé, ce qui risque de perdre des requêtes en cours de traitement.

Le Dockerfile Minimal

Quatre lignes suffisent pour placer une application Bun dans un conteneur. C’est le chemin le plus rapide entre « ça fonctionne en local » et une image construite.

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

Construisez-la :

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

Bun exécute TypeScript directement, il n’y a donc pas d’étape de build tsc — le point d’entrée est votre fichier .ts. Remplacez src/index.ts par votre véritable point d’entrée. L’image de base est l’image officielle oven/bun sur Docker Hub.

Utilisez ceci pour un environnement de test, pas pour la production. La ligne FROM oven/bun tire latest implicitement, copie l’intégralité de votre répertoire (y compris node_modules et .env), s’exécute en tant que root, et reconstruit les dépendances à chaque modification du code. La suite de cet article corrige chacun de ces points.

Ajouter un .dockerignore

Un .dockerignore allège votre contexte de build et empêche les secrets et artefacts locaux d’être copiés dans l’image. Créez-le avant toute autre chose.

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

Deux entrées sont particulièrement importantes. node_modules est exclu car bun install le régénère à l’intérieur de l’image à partir de votre lockfile — copier le node_modules de votre hôte embarquerait des binaires spécifiques à la plateforme qui pourraient ne pas correspondre au conteneur. .env et .env.* sont exclus pour que les secrets locaux n’atterrissent jamais dans un layer d’image, où ils persistent même si un layer ultérieur supprime le fichier.

Dois-je Utiliser bun.lock ou bun.lockb dans Mon Dockerfile ?

Optez par défaut pour bun.lock. Bun 1.2, sorti le 22 janvier 2025, a modifié le format par défaut du lockfile en faveur d’un fichier bun.lock textuel, remplaçant l’ancien bun.lockb binaire. Les anciens tutoriels font encore référence à bun.lockb, et copier le mauvais nom de fichier dans votre Dockerfile produit une erreur --frozen-lockfile difficile à interpréter.

Si votre dépôt possède encore un lockfile binaire, migrez-le une fois pour toutes :

bun install --save-text-lockfile --frozen-lockfile --lockfile-only
# puis supprimez bun.lockb et committez bun.lock

Cette commande est documentée dans la documentation du lockfile Bun. Après la migration, référencez bun.lock partout dans votre Dockerfile.

Mise en Cache des Layers : Séparer les Dépendances du Code Source

Copiez package.json et bun.lock avant votre code source applicatif afin que le cache de layers Docker ignore bun install à chaque reconstruction où seuls les fichiers source ont changé — c’est la modification à l’impact le plus élevé pour la vitesse d’itération en local.

FROM oven/bun:1

WORKDIR /app

# Copier le lockfile et le manifeste en premier — ce layer n'est invalidé
# que lorsque les dépendances changent
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

# Copier les sources en dernier — modifier le code ne reconstruit qu'à partir d'ici
COPY src ./src
COPY tsconfig.json ./

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

Docker met en cache chaque instruction sous forme de layer et réutilise les layers mis en cache lorsque leurs entrées sont inchangées. Étant donné que COPY package.json bun.lock précède COPY src, modifier un fichier source laisse le layer des dépendances intact et bun install est entièrement ignoré. bun install --frozen-lockfile fait échouer le build si bun.lock n’est pas synchronisé avec package.json — comportement correct dans un Dockerfile, car il révèle les dérives du lockfile au moment du build plutôt qu’à l’exécution. Le comportement de ce flag est documenté dans la documentation CLI de bun install.

Notez la ligne COPY tsconfig.json. Bun lit tsconfig.json pour les alias de chemins et les options du compilateur ; si votre application utilise le mapping de chemins et que vous oubliez de le copier, la résolution des modules échoue au démarrage dans le conteneur alors qu’elle fonctionne en local.

Build Multi-Stage : Compiler vers un Binaire Autonome

bun build --compile crée un exécutable autonome qui regroupe votre code, les packages npm, les assets et le runtime Bun, de sorte que l’image finale n’a pas besoin de Bun installé séparément. Utilisez-le dans un build multi-stage pour ne copier que le binaire dans une base minimale, afin d’obtenir une image finale plus légère.

# --- Étape de build ---
FROM oven/bun:1 AS build
WORKDIR /app

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

COPY src ./src
COPY tsconfig.json ./

# Compiler vers un exécutable unique qui inclut le runtime Bun
RUN bun build ./src/index.ts --compile --outfile server

# --- Étape d'exécution ---
FROM debian:12-slim
WORKDIR /app

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

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

L’étape de build installe les dépendances et compile. L’étape d’exécution part de debian:12-slim et n’y copie que le binaire server compilé — pas d’image Bun, pas de node_modules, pas de source. Le binaire inclut le runtime, il s’exécute donc sans Bun présent. Mesurez le delta de taille sur votre propre application avant de citer un chiffre ; il dépend du nombre de dépendances et de la taille des assets.

Durcissement pour la Production

Épinglez un tag Bun explicite, exécutez en tant qu’utilisateur non-root, et installez uniquement les dépendances de production. Ces trois modifications transforment un Dockerfile fonctionnel en un Dockerfile déployable.

FROM oven/bun:1-alpine

WORKDIR /app

# Créer un utilisateur non-root (syntaxe 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"]

Épinglez le tag. Les références d’images Docker utilisent par défaut latest lorsqu’aucun tag n’est spécifié, ce qui signifie qu’un docker pull peut modifier implicitement votre runtime Bun entre les déploiements. Épinglez un tag Bun explicite pour que les mises à jour du runtime restent délibérées et traçables — le guide Docker officiel de Bun utilise oven/bun:1. La variante 1-alpine existe sur Docker Hub pour une empreinte plus légère ; vérifiez qu’elle convient à vos dépendances avant de l’adopter, car Alpine utilise musl libc plutôt que glibc.

Exécutez en tant que non-root. L’utilisateur par défaut du conteneur est root. Créer appuser et basculer vers cet utilisateur avec USER appuser limite le rayon d’impact en cas de compromission du processus — une pratique de base des recommandations de sécurité Docker.

Installez uniquement les dépendances de production. bun install --frozen-lockfile --production ignore les devDependencies, ce qui réduit la taille de l’image et la surface d’attaque.

Pourquoi Mon Conteneur Bun Est-il Inaccessible sur Son Port Publié ?

Un service Bun qui écoute uniquement sur 127.0.0.1 à l’intérieur d’un conteneur Docker ne sera pas accessible via un port publié. Liez-le à 0.0.0.0 (toutes les interfaces IPv4) pour que la publication de ports Docker puisse lui transférer le trafic.

C’est l’échec silencieux le plus courant dans un service Bun dockerisé. Le conteneur démarre, les logs semblent sains, docker ps affiche le port mappé — et chaque requête depuis l’hôte reste en attente ou retourne une erreur de connexion. La cause est l’adresse d’écoute.

Pour Bun.serve() brut, définissez hostname explicitement :

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

Bun.serve({
  port,
  hostname: "0.0.0.0", // requis à l'intérieur 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}`);

Les options de Bun.serve() sont documentées dans la documentation de l’API HTTP Bun. Pour Elysia, configurez serve.hostname: "0.0.0.0". Pour les autres frameworks fonctionnant sur Bun, assurez-vous que le serveur Bun sous-jacent est configuré pour écouter sur 0.0.0.0.

Les replays de session des frontends communiquant avec un service Bun peuvent révéler cet échec à un niveau inférieur, dans le Dockerfile. Un service lié à localhost se manifeste dans le navigateur par une cascade de timeouts fetch/XHR immédiatement après un déploiement — le type de symptôme qui ressemble à un bug frontend mais qui trouve son origine dans l’adresse d’écoute du conteneur. Des outils comme OpenReplay capturent ce pattern d’échec côté client, là où cette mauvaise configuration est généralement remarquée en premier.

Docker Compose avec Postgres

Un fichier Docker Compose exécute votre application Bun aux côtés de PostgreSQL, attend que la base de données passe un healthcheck avant de démarrer l’application, et redémarre l’application en cas d’échec.

# 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 avec condition: service_healthy retient le service api jusqu’à ce que Postgres signale un état sain, ce qui nécessite un healthcheck sur la dépendance — sans lui, service_healthy ne se résout jamais. restart: unless-stopped redémarre l’application après un crash mais respecte un arrêt manuel.

Notez l’absence de clé version: au niveau racine. La Spécification Compose marque la propriété version de niveau supérieur comme obsolète ; Compose l’ignore pour la sélection du schéma et émet un avertissement si elle est présente. Omettez-la.

Variables d’Environnement : Ne Pas Intégrer les Secrets dans l’Image

Transmettez la configuration via env_file à l’exécution plutôt que de copier des secrets dans un layer d’image. Les layers d’image sont persistants et inspectables, donc un secret écrit dans l’un d’eux reste récupérable même si une instruction ultérieure le supprime.

Le service Compose ci-dessus référence déjà .env.production via env_file. Lisez ces valeurs dans le code depuis process.env :

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

Gardez .env.production hors de l’image grâce à l’entrée .env.* dans .dockerignore, et hors du contrôle de version avec une entrée correspondante dans .gitignore. L’image reste générique ; les secrets sont injectés à l’exécution.

Workflow de Développement : Rechargement à Chaud avec un Bind Mount

Pour le développement local, utilisez un Dockerfile séparé qui exécute bun --hot et montez votre source en tant que volume pour que les sauvegardes rechargent instantanément à l’intérieur du conteneur. Gardez ceci distinct de votre Dockerfile de production.

# 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

Le flag --hot active le rechargement à chaud de Bun : sauvegarder un fichier met à jour le serveur en cours d’exécution sans redémarrage complet du processus. Le bind mount .:/app rend les modifications de l’hôte visibles à l’intérieur du conteneur, et le volume anonyme /app/node_modules empêche le répertoire hôte de masquer les dépendances installées lors du build. Lancez-le avec docker compose -f compose.dev.yaml up --build.

Arrêt Gracieux : Gérer SIGTERM

Ajoutez un gestionnaire SIGTERM pour que les requêtes en cours se terminent avant la sortie du processus. Sans lui, Docker envoie SIGTERM, attend jusqu’à son délai d’arrêt par défaut (10 secondes pour les conteneurs Linux), puis envoie SIGKILL si le processus ne s’est pas terminé, risquant de perdre des requêtes encore en cours de traitement.

Bun.serve() retourne un objet serveur avec une méthode .stop(). Attendez-la sur SIGTERM pour laisser les requêtes en cours se terminer avant de quitter :

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

Le handle server et sa méthode .stop() font partie de l’API HTTP Bun. Le principe fondamental est de capturer l’instance serveur retournée par Bun.serve() et de l’arrêter à la réception d’un signal de terminaison.

Estimation de la Taille des Images

L’image de base que vous choisissez détermine l’essentiel de la taille finale de l’image. Docker Hub publie les tailles compressées pour chaque variante oven/bun ; les chiffres ci-dessous proviennent de la page des tags Docker Hub pour oven/bun:1.3.14 (linux/amd64).

Image de baseVarianteTaille compressée
oven/bun:1.3.14Basée sur Debian81,93 Mo
oven/bun:1.3.14-slimDebian slim63,32 Mo
oven/bun:1.3.14-alpineAlpine40,87 Mo
oven/bun:1.3.14-distrolessDistroless40,52 Mo

Ce sont des tailles d’images de base, pas celles de votre image finale. Votre image ajoute des dépendances et du code source par-dessus. Pour mesurer la vôtre, exécutez :

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

La variante multi-stage avec binaire compilé sur debian:12-slim peut réduire davantage l’image finale car elle exclut l’image Bun complète et node_modules, mais le delta exact dépend de l’empreinte de vos dépendances — mesurez-le plutôt que de citer un pourcentage générique.

Quels Sont les Échecs les Plus Courants dans un Conteneur Bun Docker ?

Les quatre raisons les plus courantes pour lesquelles un conteneur Bun dockerisé échoue silencieusement sont : un chemin de point d’entrée incorrect, une liaison sur localhost, un lockfile obsolète, et une étape de copie de tsconfig.json manquante. Chacun a un symptôme distinct et un correctif en une ligne.

SymptômeCause racineCorrectif
Le conteneur se termine immédiatement, code 1Chemin de point d’entrée incorrect dans CMD, ou fichier d’entrée non copiéVérifiez que le chemin dans CMD ["bun", "run", "src/index.ts"] correspond au fichier copié ; vérifiez que COPY src ./src a été exécuté
Port mappé mais inaccessibleService lié à 127.0.0.1/localhostLiez à 0.0.0.0 dans Bun.serve({ hostname: "0.0.0.0" })
Le build s’interrompt pendant bun installbun.lock désynchronisé avec package.jsonExécutez bun install en local, committez le bun.lock mis à jour ; --frozen-lockfile passe ensuite
La résolution des modules échoue au démarrageAlias de chemins tsconfig.json non résolus — fichier non copiéAjoutez COPY tsconfig.json ./ avant le CMD

Lorsqu’un conteneur se termine avant que vous puissiez l’inspecter, ouvrez un shell sur l’image pour vérifier ce qui a réellement été copié :

docker run -it  --rm  --entrypoint  sh bun-app
# à l'intérieur : ls /app, cat package.json, etc.

Le cas de désynchronisation du lockfile est celui le plus souvent interprété à tort comme un problème Docker. C’est un problème Bun révélé par Docker — bun install --frozen-lockfile fait son travail en refusant de builder contre un lockfile dérivé, exactement comme le décrit la documentation de bun install.

Conclusion

Vous disposez maintenant de tous les éléments : un Dockerfile de production épinglé, non-root ; une variante multi-stage avec binaire compilé ; une stack Compose avec un Postgres doté d’un healthcheck ; des outils de développement avec rechargement à chaud ; et un gestionnaire SIGTERM qui draine les requêtes proprement. Commencez par remplacer oven/bun par oven/bun:1 et en ajoutant la liaison 0.0.0.0 — ces deux modifications éliminent les deux échecs les plus susceptibles de vous affecter lors de votre premier déploiement, puis intégrez le reste au fur et à mesure que votre service évolue.

FAQ

Ai-je besoin d'une étape de build séparée pour exécuter TypeScript dans un conteneur Bun Docker ?

Non. Bun exécute TypeScript directement, il n'y a donc pas d'étape de compilation tsc ou tsx dans le build Docker. Votre CMD pointe directement vers le fichier d'entrée .ts, par exemple CMD ['bun', 'run', 'src/index.ts']. La seule mise en garde concerne les alias de chemins : si votre application utilise le mapping de chemins tsconfig.json, vous devez copier tsconfig.json dans l'image, sinon la résolution des modules échoue au démarrage alors qu'elle fonctionne en local.

Quand dois-je utiliser un binaire compilé avec bun build --compile plutôt que d'exécuter bun run dans le conteneur ?

Utilisez le build multi-stage avec binaire compilé lorsque vous souhaitez une image finale plus légère et aucune installation de Bun dans l'étape d'exécution. bun build --compile produit un exécutable autonome regroupant votre code, les packages npm, les assets et le runtime Bun, vous pouvez donc copier uniquement ce binaire sur une base minimale comme debian:12-slim sans node_modules ni image Bun. Utilisez bun run classique pour des builds plus simples ou lorsque vous dépendez de fonctionnalités d'exécution comme le rechargement à chaud pendant le développement.

La variante alpine de l'image oven/bun fonctionne-t-elle avec toutes les dépendances ?

Pas toujours. La variante oven/bun:1-alpine utilise musl libc au lieu de glibc, tandis que l'image Debian par défaut oven/bun:1 utilise glibc. Les dépendances natives compilées contre glibc, ou les packages distribuant des binaires précompilés uniquement pour glibc, peuvent échouer sur Alpine. L'image Alpine est plus légère, mais vérifiez que votre arbre de dépendances se compile et s'exécute correctement avant de l'adopter en production plutôt que de supposer un remplacement direct.

Pourquoi mon build de conteneur échoue-t-il pendant bun install avec une erreur frozen-lockfile ?

Le build s'interrompt car bun.lock n'est pas synchronisé avec package.json et bun install --frozen-lockfile refuse de builder contre un lockfile dérivé. C'est le comportement correct dans un Dockerfile : il révèle les dérives du lockfile au moment du build plutôt qu'à l'exécution. Corrigez-le en exécutant bun install en local pour régénérer bun.lock, puis committez le fichier mis à jour. Si votre Dockerfile référence encore l'ancien bun.lockb binaire, remplacez-le par bun.lock, car Bun 1.2 a fait du format textuel le format par défaut.

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.