12k
All articles

Como Dockerizar uma Aplicação Bun

Dockerize uma app Bun com Dockerfile pronto para produção, .dockerignore, bind 0.0.0.0, healthchecks, Compose e desligamento SIGTERM.

OpenReplay Team
OpenReplay Team
Como Dockerizar uma Aplicação Bun

Um Dockerfile pronto para produção de um serviço Bun cabe em poucas linhas, mas a diferença entre um Dockerfile que funciona localmente e um que sobrevive a um deploy se resume a quatro aspectos: uma imagem base com tag fixada, um usuário não-root, um serviço vinculado a 0.0.0.0, e um handler de SIGTERM que drena as requisições em andamento. Este artigo fornece o Dockerfile funcional e, em seguida, resolve cada uma dessas lacunas com código pronto para uso.

Você tem uma aplicação Bun — uma API, um worker ou um backend-for-frontend — que funciona na sua máquina. Você precisa containerizá-la sem aprender os detalhes do Bun com Docker da forma difícil. A seguir: o Dockerfile mínimo, cache de camadas, um binário compilado multi-stage, hardening para produção, Docker Compose com Postgres, um handler de graceful shutdown para o servidor nativo do Bun, e uma tabela com as quatro falhas silenciosas mais comuns e suas correções.

Principais Aprendizados

  • Um serviço Bun que escuta apenas em 127.0.0.1 dentro de um container é inacessível através de uma porta publicada; vincule a 0.0.0.0 (todas as interfaces IPv4) para que o port publishing do Docker possa encaminhar o tráfego para ele.
  • O Bun 1.2, lançado em 22 de janeiro de 2025, alterou o formato padrão do lockfile para o formato textual bun.lock; migre do bun.lockb com bun install --save-text-lockfile --frozen-lockfile --lockfile-only.
  • Referências a imagens Docker assumem latest quando nenhuma tag é especificada, o que pode alterar implicitamente o seu runtime do Bun entre deploys; fixe uma tag explícita como oven/bun:1.
  • Copie package.json e bun.lock antes do código-fonte da aplicação para que o cache de camadas do Docker ignore o bun install em rebuilds onde apenas o código-fonte foi alterado.
  • Sem um handler de SIGTERM, o Docker envia SIGTERM, aguarda até o timeout de parada padrão (10 segundos para containers Linux) e, em seguida, envia SIGKILL se o processo não tiver encerrado, potencialmente descartando requisições em andamento.

O Dockerfile Mínimo

Quatro linhas colocam uma aplicação Bun em um container. Este é o caminho mais rápido de funcionando-localmente para uma imagem construída.

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

Faça o build:

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

O Bun executa TypeScript diretamente, portanto não há um estágio de build com tsc — o entrypoint é o seu arquivo .ts. Substitua src/index.ts pelo seu entrypoint real. A imagem base é a imagem oficial oven/bun no Docker Hub.

Use isso em um ambiente de testes, não em produção. A linha FROM oven/bun faz o pull de latest implicitamente, copia todo o seu diretório (incluindo node_modules e .env), executa como root e reconstrói as dependências a cada alteração de código. O restante deste artigo corrige cada um desses problemas.

Adicione um .dockerignore

Um .dockerignore mantém o contexto de build enxuto e impede que segredos e artefatos locais sejam copiados para a imagem. Crie-o antes de qualquer outra coisa.

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

Duas entradas são as mais importantes. node_modules é excluído porque o bun install o regenera dentro da imagem a partir do seu lockfile — copiar o node_modules do seu host inclui binários específicos da plataforma que podem não ser compatíveis com o container. .env e .env.* são excluídos para que segredos locais nunca acabem em uma camada da imagem, onde persistem mesmo que uma camada posterior delete o arquivo.

Devo Usar bun.lock ou bun.lockb no Meu Dockerfile?

Use bun.lock por padrão. O Bun 1.2, lançado em 22 de janeiro de 2025, alterou o formato padrão do lockfile para um arquivo textual bun.lock, substituindo o antigo bun.lockb binário. Tutoriais mais antigos ainda fazem referência ao bun.lockb, e copiar o nome de arquivo errado no seu Dockerfile produz um erro confuso de --frozen-lockfile.

Se o seu repositório ainda possui um lockfile binário, migre-o uma única vez:

bun install --save-text-lockfile --frozen-lockfile --lockfile-only
# então delete bun.lockb e faça commit do bun.lock

Este comando está documentado na documentação de lockfile do Bun. Após a migração, referencie bun.lock em todo o seu Dockerfile.

Cache de Camadas: Separe as Dependências do Código-Fonte

Copie package.json e bun.lock antes do código-fonte da sua aplicação para que o cache de camadas do Docker ignore o bun install em cada rebuild onde apenas os arquivos-fonte foram alterados — a única mudança de maior impacto para a velocidade de iteração local.

FROM oven/bun:1

WORKDIR /app

# Copie o lockfile e o manifesto primeiro — esta camada só é invalidada
# quando as dependências mudam
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

# Copie o código-fonte por último — editar o código só reconstrói a partir daqui
COPY src ./src
COPY tsconfig.json ./

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

O Docker armazena em cache cada instrução como uma camada e reutiliza as camadas em cache quando suas entradas não foram alteradas. Como COPY package.json bun.lock precede COPY src, editar um arquivo-fonte deixa a camada de dependências intacta e o bun install é ignorado completamente. O bun install --frozen-lockfile falha o build se bun.lock estiver fora de sincronia com package.json — o comportamento correto em um Dockerfile, pois expõe a divergência do lockfile no momento do build em vez de em tempo de execução. O comportamento da flag está documentado na documentação da CLI de install do Bun.

Observe a linha COPY tsconfig.json. O Bun lê o tsconfig.json para aliases de path e opções do compilador; se a sua aplicação usa mapeamento de paths e você esquecer de copiá-lo, a resolução de módulos falha na inicialização dentro do container, mesmo que funcione localmente.

Build Multi-Stage: Compile para um Binário Standalone

O bun build --compile cria um executável autocontido que empacota seu código, pacotes npm, assets e o runtime do Bun, para que a imagem final não precise do Bun instalado separadamente. Use-o em um build multi-stage para copiar apenas o binário para uma base mínima, resultando em uma imagem final menor.

# --- Estágio 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 ./

# Compila para um único executável que inclui o runtime do Bun
RUN bun build ./src/index.ts --compile --outfile server

# --- Estágio de runtime ---
FROM debian:12-slim
WORKDIR /app

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

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

O estágio de build instala as dependências e compila. O estágio de runtime parte do debian:12-slim e copia apenas o binário server compilado — sem imagem do Bun, sem node_modules, sem código-fonte. O binário inclui o runtime, portanto é executado sem o Bun presente. Meça a diferença de tamanho na sua própria aplicação antes de citar um número; ela depende da quantidade de dependências e do tamanho dos assets.

Hardening para Produção

Fixe uma tag explícita do Bun, execute como um usuário não-root e instale apenas as dependências de produção. Essas três mudanças transformam um Dockerfile funcional em um pronto para deploy.

FROM oven/bun:1-alpine

WORKDIR /app

# Cria um usuário não-root (sintaxe 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"]

Fixe a tag. Referências a imagens Docker assumem latest quando nenhuma tag é especificada, o que significa que um docker pull pode alterar implicitamente o seu runtime do Bun entre deploys. Fixe uma tag explícita do Bun para manter as atualizações de runtime deliberadas e auditáveis — o guia oficial do Docker do Bun usa oven/bun:1. A variante 1-alpine existe no Docker Hub para um footprint menor; verifique se ela é adequada para as suas dependências antes de adotá-la, pois o Alpine usa musl libc em vez de glibc.

Execute como não-root. O usuário padrão do container é root. Criar appuser e mudar para ele com USER appuser limita o raio de impacto caso o processo seja comprometido — uma prática básica das diretrizes de segurança do Docker.

Instale apenas as dependências de produção. O bun install --frozen-lockfile --production ignora as devDependencies, mantendo a imagem menor e a superfície de ataque mais reduzida.

Por Que Meu Container Bun Não Está Acessível na Porta Publicada?

Um serviço Bun que escuta apenas em 127.0.0.1 dentro de um container Docker não será acessível através de uma porta publicada. Vincule a 0.0.0.0 (todas as interfaces IPv4) para que o port publishing do Docker possa encaminhar o tráfego para ele.

Esta é a falha silenciosa mais comum em um serviço Bun containerizado. O container inicia, os logs parecem saudáveis, o docker ps mostra a porta mapeada — e toda requisição do host trava ou retorna um erro de conexão. A causa é o endereço de escuta.

Para o Bun.serve() nativo, defina hostname explicitamente:

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

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

As opções do Bun.serve() estão documentadas na documentação da API HTTP do Bun. Para o Elysia, configure serve.hostname: "0.0.0.0". Para outros frameworks executando no Bun, certifique-se de que o servidor Bun subjacente está configurado para escutar em 0.0.0.0.

Session replays de frontends que se comunicam com um serviço Bun podem revelar essa falha uma camada abaixo, no Dockerfile. Um serviço vinculado ao localhost se manifesta no browser como uma cascata de timeouts de fetch/XHR imediatamente após um deploy — o tipo de sintoma que parece um bug de frontend, mas tem origem no endereço de escuta do container. Ferramentas como o OpenReplay capturam esse padrão de falha no lado do cliente, que é onde essa configuração incorreta costuma ser notada pela primeira vez.

Docker Compose com Postgres

Um arquivo Docker Compose executa sua aplicação Bun junto com o PostgreSQL, aguarda o banco de dados passar em um healthcheck antes de iniciar a aplicação e reinicia a aplicação em caso de falha.

# 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:

O depends_on com condition: service_healthy mantém o serviço api aguardando até que o Postgres reporte healthy, o que requer um healthcheck na dependência — sem ele, o service_healthy nunca é resolvido. O restart: unless-stopped reinicia a aplicação após uma falha, mas respeita uma parada manual.

Observe que não há uma chave version: no nível raiz. A Especificação do Compose marca a propriedade version de nível raiz como obsoleta; o Compose a ignora para seleção de schema e emite um aviso se ela estiver presente. Omita-a.

Variáveis de Ambiente: Não Incorpore Segredos na Imagem

Passe a configuração através de env_file em tempo de execução em vez de copiar segredos para uma camada da imagem. As camadas de imagem são persistentes e inspecionáveis, portanto um segredo gravado em uma delas permanece recuperável mesmo que uma instrução posterior o delete.

O serviço Compose acima já referencia .env.production via env_file. Leia esses valores no código a partir de process.env:

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

Mantenha .env.production fora da imagem com a entrada .env.* no .dockerignore, e fora do controle de versão com uma entrada correspondente no .gitignore. A imagem permanece genérica; os segredos são injetados em tempo de execução.

Fluxo de Desenvolvimento: Hot Reload com Bind Mount

Para desenvolvimento local, use um Dockerfile separado que execute bun --hot e monte seu código-fonte como um volume para que as alterações recarreguem instantaneamente dentro do container. Mantenha isso separado do seu Dockerfile de produção.

# 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

A flag --hot habilita o hot reloading do Bun: salvar um arquivo atualiza o servidor em execução sem um reinício completo do processo. O bind mount .:/app torna as edições do host visíveis dentro do container, e o volume anônimo /app/node_modules impede que o diretório do host sobreponha as dependências instaladas durante o build. Execute com docker compose -f compose.dev.yaml up --build.

Graceful Shutdown: Trate o SIGTERM

Adicione um handler de SIGTERM para que as requisições em andamento sejam concluídas antes de o processo encerrar. Sem ele, o Docker envia SIGTERM, aguarda até o timeout de parada padrão (10 segundos para containers Linux) e, em seguida, envia SIGKILL se o processo não tiver encerrado, potencialmente descartando requisições ainda sendo processadas.

O Bun.serve() retorna um objeto de servidor com um método .stop(). Aguarde-o no SIGTERM para que as requisições em andamento sejam concluídas antes de encerrar:

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

O handle server e seu método .stop() fazem parte da API HTTP do Bun. O padrão central é capturar a instância de servidor retornada pelo Bun.serve() e encerrá-la quando um sinal de terminação chegar.

Expectativas de Tamanho de Imagem

A imagem base que você escolhe determina a maior parte do tamanho final da imagem. O Docker Hub publica os tamanhos comprimidos para cada variante do oven/bun; os valores abaixo são da página de tags do Docker Hub para oven/bun:1.3.14 (linux/amd64).

Imagem baseVarianteTamanho comprimido
oven/bun:1.3.14Baseada em 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

Esses são os tamanhos da imagem base, não da sua imagem final de aplicação. Sua imagem adiciona dependências e código-fonte por cima. Para medir a sua, execute:

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

A variante multi-stage com binário compilado sobre debian:12-slim pode reduzir ainda mais a imagem final porque omite a imagem completa do Bun e o node_modules, mas o delta exato depende do seu footprint de dependências — meça-o em vez de citar uma porcentagem genérica.

Quais São as Falhas Mais Comuns em Containers Docker com Bun?

As quatro razões mais comuns para um container Bun containerizado falhar silenciosamente são: um caminho de entrypoint incorreto, vinculação ao localhost, um lockfile desatualizado e uma etapa de cópia do tsconfig.json ausente. Cada uma tem um sintoma distinto e uma correção de uma linha.

SintomaCausa raizCorreção
Container encerra imediatamente, código 1Caminho de entrypoint incorreto no CMD, ou arquivo de entrada não copiadoVerifique se o caminho em CMD ["bun", "run", "src/index.ts"] corresponde ao arquivo copiado; confirme que COPY src ./src foi executado
Porta mapeada mas inacessívelServiço vinculado a 127.0.0.1/localhostVincule a 0.0.0.0 em Bun.serve({ hostname: "0.0.0.0" })
Build abortado durante bun installbun.lock fora de sincronia com package.jsonExecute bun install localmente, faça commit do bun.lock atualizado; --frozen-lockfile então passa
Resolução de módulos falha na inicializaçãoAliases de path do tsconfig.json não resolvidos — arquivo não copiadoAdicione COPY tsconfig.json ./ antes do CMD

Quando um container encerra antes de você conseguir inspecioná-lo, abra um shell na imagem para verificar o que foi realmente copiado:

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

O caso de divergência de lockfile é o mais frequentemente confundido com um problema do Docker. É um problema do Bun exposto pelo Docker — o bun install --frozen-lockfile está fazendo seu trabalho ao se recusar a fazer o build com um lockfile divergente, exatamente como a documentação de install do Bun descreve.

Conclusão

Agora você tem todas as peças: um Dockerfile fixado, não-root e pronto para produção; uma variante multi-stage com binário compilado; uma stack Compose com Postgres com healthcheck; ferramentas de hot reload para desenvolvimento; e um handler de SIGTERM que drena as requisições de forma limpa. Comece substituindo oven/bun por oven/bun:1 e adicionando o vínculo 0.0.0.0 — essas duas edições eliminam as duas falhas com maior probabilidade de impactar você no seu primeiro deploy; em seguida, adicione o restante conforme o seu serviço cresce.

Perguntas Frequentes

Preciso de uma etapa de build separada para executar TypeScript em um container Docker com Bun?

Não. O Bun executa TypeScript diretamente, portanto não há uma etapa de compilação com tsc ou tsx no build do Docker. O seu CMD aponta diretamente para o arquivo de entrada .ts, por exemplo CMD ['bun', 'run', 'src/index.ts']. A única ressalva são os aliases de path: se a sua aplicação usa mapeamento de paths do tsconfig.json, você deve copiar o tsconfig.json para a imagem, ou a resolução de módulos falha na inicialização mesmo que funcione localmente.

Quando devo usar um binário compilado com bun build --compile em vez de executar bun run no container?

Use o build multi-stage com binário compilado quando quiser uma imagem final menor e sem a instalação do Bun no estágio de runtime. O bun build --compile produz um executável autocontido que empacota seu código, pacotes npm, assets e o runtime do Bun, para que você possa copiar apenas esse binário para uma base mínima como debian:12-slim sem node_modules ou imagem do Bun. Use o bun run simples para builds mais simples ou quando depender de funcionalidades de runtime como hot reload durante o desenvolvimento.

A variante alpine da imagem oven/bun funciona com todas as dependências?

Nem sempre. A variante oven/bun:1-alpine usa musl libc em vez de glibc, enquanto a imagem padrão baseada em Debian oven/bun:1 usa glibc. Dependências nativas compiladas contra glibc, ou pacotes que distribuem binários pré-compilados apenas para glibc, podem falhar no Alpine. A imagem Alpine é menor, mas verifique se a sua árvore de dependências compila e executa corretamente nela antes de adotá-la em produção, em vez de assumir que é uma substituição direta.

Por que o build do meu container falha durante o bun install com um erro de frozen-lockfile?

O build é abortado porque o bun.lock está fora de sincronia com o package.json e o bun install --frozen-lockfile se recusa a fazer o build com um lockfile divergente. Esse é o comportamento correto em um Dockerfile: ele expõe a divergência do lockfile no momento do build em vez de em tempo de execução. Corrija executando bun install localmente para regenerar o bun.lock e, em seguida, faça commit do arquivo atualizado. Se o seu Dockerfile ainda referencia o antigo bun.lockb binário, mude para bun.lock, pois o Bun 1.2 tornou o formato textual o padrão.

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.