Usar imagens prontas do Docker Hub é o ponto de partida. O passo seguinte — e o mais importante para qualquer profissional de DevOps — é saber construir imagens customizadas que empacotam aplicações reais.
O Dockerfile é o arquivo de texto que descreve, passo a passo, como uma imagem deve ser construída. Cada instrução do Dockerfile cria uma camada na imagem final. Entender como essas camadas funcionam é a diferença entre uma imagem bem construída e uma imagem pesada, lenta de construir e difícil de manter.
Estrutura Básica de um Dockerfile
Um Dockerfile mínimo para uma aplicação Node.js:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
Cada instrução tem um papel específico.
FROM — define a imagem base. Todo Dockerfile começa aqui. A imagem base fornece o sistema operacional e, neste caso, o runtime do Node.js. O sufixo alpine indica uma variante baseada no Alpine Linux — uma distribuição minimalista de apenas 5MB que resulta em imagens significativamente menores.
WORKDIR — define o diretório de trabalho dentro do container. Todos os comandos subsequentes são executados a partir desse diretório. Se o diretório não existir, o Docker o cria automaticamente.
COPY — copia arquivos do contexto de build (a máquina local) para dentro da imagem. O primeiro argumento é a origem, o segundo é o destino dentro da imagem.
RUN — executa um comando durante a construção da imagem. O resultado é persistido na camada gerada. Usado para instalar dependências, compilar código, criar diretórios.
EXPOSE — documenta que o container escuta na porta indicada. É apenas documentação — não publica a porta automaticamente. A publicação acontece no docker run -p.
CMD — define o comando padrão executado quando o container inicia. Deve ser especificado no formato de array JSON (forma exec), não como string.
Construindo e Testando a Imagem
Com o Dockerfile criado na raiz do projeto:
# Constrói a imagem com uma tag
docker build -t minha-app:1.0.0 .
# O ponto final indica que o contexto de build é o diretório atual
Acompanhar a saída do build é valioso — cada linha corresponde a uma instrução do Dockerfile e mostra o hash da camada gerada:
[+] Building 12.4s (10/10) FINISHED
=> [internal] load build definition from Dockerfile
=> [1/6] FROM node:20-alpine
=> [2/6] WORKDIR /app
=> [3/6] COPY package*.json ./
=> [4/6] RUN npm ci
=> [5/6] COPY . .
=> exporting to image
Rodando e testando:
docker run -d -p 3000:3000 --name minha-app minha-app:1.0.0
curl http://localhost:3000
docker logs minha-app
A Ordem das Instruções Importa — e Muito
O Docker utiliza um mecanismo de cache por camada: se uma instrução e todos os seus predecessores não mudaram desde o último build, o Docker reutiliza a camada cacheada em vez de reconstruí-la. Isso acelera enormemente o ciclo de desenvolvimento.
O problema é que qualquer mudança invalida o cache de todas as camadas subsequentes. Por isso a ordem das instruções no Dockerfile não é arbitrária — ela deve colocar o que muda com menos frequência primeiro e o que muda com mais frequência por último.
Considere a diferença entre essas duas abordagens:
Ordem ineficiente — invalida o cache de dependências a cada mudança de código:
FROM node:20-alpine
WORKDIR /app
COPY . . # copia tudo, incluindo o código
RUN npm ci # instala dependências APÓS o código
CMD ["node", "src/index.js"]
Ordem eficiente — dependências são cacheadas separadamente do código:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # copia apenas o manifesto de dependências
RUN npm ci # instala dependências — esta camada só é reconstruída
# quando package.json muda
COPY . . # copia o código — muda frequentemente, mas não invalida
# a camada de dependências acima
CMD ["node", "src/index.js"]
Na prática, a segunda abordagem reduz o tempo de build de dezenas de segundos para poucos segundos na maioria dos ciclos de desenvolvimento.
O Arquivo .dockerignore
Assim como o .gitignore evita que arquivos desnecessários entrem no repositório, o .dockerignore evita que arquivos desnecessários entrem no contexto de build — o conjunto de arquivos enviados ao Docker daemon durante o docker build.
# .dockerignore
node_modules
.git
.gitignore
*.log
.env
.env.*
dist
coverage
README.md
Dockerfile
.dockerignore
Sem o .dockerignore, a pasta node_modules inteira seria enviada ao daemon e depois sobrescrita pelo npm ci — um desperdício considerável de tempo e banda.
Multi-stage Builds: Imagens Enxutas para Produção
Um padrão avançado mas essencial: o multi-stage build permite usar múltiplas imagens base em um único Dockerfile, copiando apenas os artefatos necessários de cada estágio para o próximo. O resultado é uma imagem final que contém apenas o necessário para executar a aplicação — sem compiladores, sem ferramentas de build, sem dependências de desenvolvimento.
Exemplo para uma aplicação Node.js com TypeScript:
# ── Estágio 1: Build ──────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # compila TypeScript para JavaScript em /app/dist
# ── Estágio 2: Produção ───────────────────────────
FROM node:20-alpine AS production
WORKDIR /app
# Copia apenas o manifesto e instala somente dependências de produção
COPY package*.json ./
RUN npm ci --only=production
# Copia apenas o código compilado do estágio anterior
COPY --from=builder /app/dist ./dist
# Usuário não-root por segurança
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
O estágio builder tem todas as ferramentas de desenvolvimento. O estágio production começa do zero e importa apenas o resultado compilado. Uma aplicação que geraria uma imagem de 800MB pode resultar em menos de 100MB com essa técnica.
Instruções Complementares
ENV — define variáveis de ambiente que estarão disponíveis durante o build e em tempo de execução:
ENV NODE_ENV=production
ENV PORT=3000
ARG — define variáveis disponíveis apenas durante o build, não em tempo de execução. Útil para passar tokens de autenticação durante a construção sem que fiquem na imagem final:
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \
&& npm ci \
&& rm .npmrc
HEALTHCHECK — define como o Docker deve verificar se o container está saudável:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
ENTRYPOINT vs CMD — a distinção é sutil mas importante. O CMD define argumentos padrão que podem ser substituídos ao rodar o container. O ENTRYPOINT define o executável principal, que não é substituído facilmente:
# Com ENTRYPOINT, o container sempre usa 'node' como executável
# O CMD fornece o argumento padrão, substituível no docker run
ENTRYPOINT ["node"]
CMD ["dist/index.js"]
# Roda com o argumento padrão
docker run minha-app
# Substitui o CMD para rodar outro arquivo
docker run minha-app dist/worker.js
Um Dockerfile Completo e Comentado
# Imagem base com Node.js 20 em Alpine Linux
FROM node:20-alpine AS base
# Instala dependências do sistema necessárias
RUN apk add --no-cache tini
# ── Estágio de dependências ───────────────────────
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# ── Estágio de build ──────────────────────────────
FROM deps AS builder
COPY . .
RUN npm run build
# ── Estágio de produção ───────────────────────────
FROM base AS production
WORKDIR /app
# Variáveis de ambiente de produção
ENV NODE_ENV=production
ENV PORT=3000
# Dependências apenas de produção
COPY package*.json ./
RUN npm ci --only=production
# Código compilado
COPY --from=builder /app/dist ./dist
# Usuário não-root — boa prática de segurança
USER node
# Porta exposta
EXPOSE 3000
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
# tini como init process — gerencia sinais corretamente
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]
O Que Vem a Seguir
No próximo artigo serão abordados volumes e redes no Docker — como persistir dados além do ciclo de vida de um container e como fazer containers diferentes se comunicarem entre si. São os dois temas que completam o entendimento do Docker como plataforma de execução de serviços.
Referências para Aprofundamento
Documentação oficial
- Dockerfile Reference — docs.docker.com — Referência completa de todas as instruções do Dockerfile com exemplos para cada uma.
- Multi-stage Builds — docs.docker.com — Guia oficial sobre multi-stage builds com exemplos em múltiplas linguagens.
Boas práticas
- Best Practices for Writing Dockerfiles — docs.docker.com — Guia oficial de boas práticas, cobrindo cache de camadas, tamanho de imagem e segurança.
- Dockerfile Linting with Hadolint — Linter para Dockerfiles que verifica boas práticas e erros comuns. Pode ser integrado ao pipeline de CI.
Prática
- Docker Build — Getting Started — docs.docker.com — Guia prático do Docker Build com exemplos progressivos de complexidade crescente.
Exercícios
Exercício 1
O que a instrução EXPOSE 3000 faz — e, principalmente, o que ela não faz?
Ver resposta
✓ Resposta: Ela apenas documenta que o container escuta naquela porta, gravando um metadado na imagem que ferramentas e quem faz docker inspect conseguem ler. O que ela não faz é publicar a porta: nada se torna acessível a partir do host por causa dela. A publicação acontece exclusivamente no docker run -p 3000:3000, ou no mapeamento equivalente do Compose. O engano é frequente e desorienta bastante — a pessoa escreve EXPOSE 3000, sobe o container sem -p, recebe conexão recusada no navegador e conclui que a aplicação quebrou, quando ela está funcionando perfeitamente e apenas inalcançável.
Exercício 2
Por que COPY package*.json ./ seguido de RUN npm ci vem antes de COPY . .? O que muda concretamente no dia a dia se a ordem for invertida?
Ver resposta
✓ Resposta: Por causa do cache por camada: qualquer instrução que muda invalida o cache dela e de todas as seguintes. Copiando apenas o manifesto primeiro, a camada do npm ci só é reconstruída quando o package.json muda — o que é raro. O código, que muda a cada commit, entra depois, e sua alteração não alcança a camada de dependências. Com a ordem invertida, o COPY . . vem antes e qualquer linha alterada em qualquer arquivo invalida tudo o que vem depois: o npm ci reinstala o projeto inteiro do zero. A diferença no dia a dia é sair de poucos segundos por build para dezenas de segundos, repetidos dezenas de vezes ao dia.
Exercício 3
O que o .dockerignore evita? Por que node_modules e .env merecem estar nele — e por motivos bem diferentes?
Ver resposta
✓ Resposta: Ele reduz o contexto de build, que é o conjunto de arquivos enviado ao daemon antes de o build sequer começar. O node_modules está ali por desperdício: seria transferido inteiro e logo sobrescrito pelo npm ci — e, pior, se a máquina local usar outro sistema operacional, binários compilados para a plataforma errada podem acabar dentro da imagem e quebrá-la de forma difícil de diagnosticar. O .env está ali por segurança: sem a exclusão, o COPY . . leva suas credenciais para dentro da imagem. E imagem vai para registro — o segredo fica gravado em uma camada, recuperável mesmo que você apague o arquivo em uma instrução posterior.
Exercício 4
No multi-stage build, o que fica em cada estágio? Por que a imagem final encolhe tanto, e qual o ganho além do tamanho?
Ver resposta
✓ Resposta: O estágio builder carrega tudo que é necessário para construir: dependências de desenvolvimento, compilador TypeScript, ferramentas de build. O estágio production parte de uma base limpa, instala apenas dependências de produção e importa somente o resultado, via COPY --from=builder /app/dist ./dist. A imagem final encolhe porque as camadas do primeiro estágio nunca fazem parte dela — não são removidas depois, simplesmente não são incluídas. O ganho além do tamanho é de segurança: sem compilador, gerenciador de pacotes de desenvolvimento e ferramentas auxiliares em produção, sobra bem menos superfície para quem conseguir executar algo dentro do container.
Exercício 5
Qual a diferença entre ENV e ARG? E por que, no exemplo do NPM_TOKEN, a criação e a remoção do .npmrc acontecem dentro de um único RUN?
Ver resposta
✓ Resposta: ENV define uma variável disponível durante o build e em tempo de execução, e ela fica gravada na imagem. ARG existe apenas durante o build. Por isso o token usa ARG: ele não deve sobreviver na imagem final. Só que isso, sozinho, não basta — o arquivo .npmrc gerado com o token cria uma camada. Se a criação e a remoção estivessem em instruções RUN separadas, a camada intermediária contendo o arquivo continuaria fazendo parte da imagem, e o token seria recuperável por quem examinasse as camadas, ainda que ele não apareça no sistema de arquivos final. Encadear tudo com && em um único RUN faz o arquivo nascer e morrer dentro da mesma camada.
Exercício 6
O que USER node e HEALTHCHECK resolvem, cada um? Que informação o healthcheck dá que o docker ps sozinho não daria?
Ver resposta
✓ Resposta: USER node faz o processo rodar como usuário não-root dentro do container. Por padrão containers rodam como root, de modo que uma falha explorada na aplicação já nasce com privilégio máximo lá dentro — situação especialmente grave quando há volumes do host montados. O HEALTHCHECK resolve outra coisa: ele distingue rodando de saudável. Um processo pode estar de pé, aparecendo como Up no docker ps, e ter parado de responder — travado em um deadlock, sem conexão com o banco, com a fila de requisições estourada. Com o healthcheck, o container passa ao estado unhealthy, e orquestradores usam esse sinal para reiniciá-lo ou removê-lo do balanceamento. Sem ele, o tráfego continua sendo entregue a um container que não atende ninguém.