"Funciona na minha máquina" é a frase que mata projetos na hora do deploy. O app depende de uma versão específica do R, de dezenas de pacotes em versões compatíveis, de bibliotecas de sistema (GDAL para sf, por exemplo), talvez de uma fonte instalada. Reproduzir tudo isso à mão num servidor é frágil e demorado. Docker empacota o app e todo o seu ambiente — sistema operacional, R, pacotes, bibliotecas — numa imagem que roda idêntica em qualquer lugar. Neste módulo, conteinerizamos o pacote golem de desenvolvimento com Golem.

Conteinerizar uma aplicação Shiny com Docker de forma reprodutível: entender imagens e contêineres, congelar dependências com renv, escrever um Dockerfile sobre as imagens rocker, construir e rodar a imagem, e passar configuração/segredos por variáveis de ambiente.

O modelo mental do Docker

Três conceitos, e a confusão entre eles causa a maioria dos erros:

  • Imagem (image): um "molde" imutável que contém tudo — SO, R, pacotes, seu app. É construída a partir de um Dockerfile. Pense nela como uma classe.
  • Contêiner (container): uma instância em execução de uma imagem. Você pode rodar vários contêineres da mesma imagem. Pense nele como um objeto.
  • Dockerfile: a receita — um arquivo de texto com os passos para construir a imagem.

O ganho central: a imagem inclui o ambiente inteiro, então ela roda igual no seu laptop, no servidor da empresa e na nuvem. Acaba o "funciona na minha máquina" — a máquina vai junto.

Reprodutibilidade de pacotes com renv

Docker congela o SO e o R; renv congela as versões exatas dos pacotes R. Sem ele, install.packages no build pega a versão mais recente — que pode ter mudado desde que seu app foi escrito e quebrar algo. renv grava um renv.lock com a versão precisa de cada pacote.

# No projeto, uma vez:
renv::init()        # cria o ambiente isolado e detecta as dependências

# Sempre que adicionar/atualizar um pacote:
renv::snapshot()    # grava as versões exatas em renv.lock

O renv.lock (versionado no Git) é o que garante que o build de hoje e o de daqui a um ano instalem exatamente os mesmos pacotes. É a peça que torna a imagem Docker verdadeiramente reprodutível — sem ele, você congela o sistema mas não os pacotes.

As imagens rocker: não comece do zero

O projeto Rocker mantém imagens Docker oficiais com R já instalado e configurado. Você constrói sobre elas, não do zero.

  • rocker/r-ver:4.4.1 — R numa versão fixa (reprodutível).
  • rocker/shiny — R + Shiny Server já configurado.
  • rocker/shiny-verse — o anterior + tidyverse (útil, evita recompilar).
  • rocker/geospatial — inclui as libs de sistema para sf/leaflet (GDAL, GEOS, PROJ) já compiladas.

Escolher a imagem base certa economiza muito tempo de build: se seu app usa sf, partir de rocker/geospatial evita a dor de instalar GDAL manualmente.

Um Dockerfile para o app golem

Aqui está um Dockerfile de produção comentado, que conteineriza o pacote de desenvolvimento com Golem:

# 1) Imagem base: R em versão fixa (reprodutível)
FROM rocker/r-ver:4.4.1

# 2) Bibliotecas de SISTEMA que alguns pacotes R precisam
#    (ex.: sf precisa de GDAL/GEOS/PROJ; xml2 precisa de libxml2)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libcurl4-openssl-dev libssl-dev libxml2-dev \
    libgdal-dev libgeos-dev libproj-dev \
    && rm -rf /var/lib/apt/lists/*

# 3) Restaurar os pacotes R nas versões EXATAS do renv.lock
WORKDIR /app
COPY renv.lock renv.lock
RUN R -e "install.packages('renv'); renv::restore()"

# 4) Copiar o código do pacote e instalá-lo
COPY . .
RUN R -e "remotes::install_local('.', upgrade = 'never')"

# 5) Expor a porta do Shiny
EXPOSE 3838

# 6) Comando que INICIA o app (o run_app() do golem)
CMD ["R", "-e", "options(shiny.host='0.0.0.0', shiny.port=3838); meuDashboard::run_app()"]

A ordem dos passos importa por causa do cache de camadas: o Docker reaproveita camadas que não mudaram. Como renv.lock muda menos que o código, copiá-lo e restaurar antes de copiar o código-fonte faz o Docker pular a reinstalação de pacotes (a etapa lenta) quando você só alterou uma linha do app. Essa ordenação economiza minutos a cada build.

Dois detalhes cruciais no CMD: shiny.host = '0.0.0.0' faz o app escutar em todas as interfaces de rede (sem isso, ele só aceita conexões de dentro do contêiner, e você não acessa de fora); e shiny.port = 3838 fixa a porta que o EXPOSE publica.

Construir e rodar

# Construir a imagem (o "." é o diretório com o Dockerfile)
docker build -t meu-dashboard:1.0 .

# Rodar um contêiner: -p mapeia porta_do_host:porta_do_container
docker run -p 3838:3838 meu-dashboard:1.0
# Acesse em http://localhost:3838

# Rodar em segundo plano (detached) e nomear o contêiner
docker run -d --name dash -p 3838:3838 meu-dashboard:1.0

# Ver logs, parar, remover
docker logs -f dash
docker stop dash
docker rm dash

O mapeamento de portas -p 3838:3838 conecta a porta 3838 do host (sua máquina/servidor) à porta 3838 dentro do contêiner. Sem esse mapeamento, o app roda mas fica inacessível de fora — outro erro clássico.

Configuração e segredos em contêineres

Retomando notificações, validação e segurança e o golem-config.yml de Golem: em Docker, configuração e segredos entram por variáveis de ambiente, nunca embutidos na imagem (a imagem pode ser compartilhada; um segredo nela vaza).

# Passar variáveis de ambiente na execução
docker run -p 3838:3838 \
  -e GOLEM_CONFIG_ACTIVE=production \
  -e DB_HOST=meu-banco.interno \
  -e DB_PASSWORD="$DB_PASSWORD" \
  meu-dashboard:1.0
# No app, lê-se com Sys.getenv() — mesmo padrão de sempre
con <- dbConnect(RPostgres::Postgres(),
                 host     = Sys.getenv("DB_HOST"),
                 password = Sys.getenv("DB_PASSWORD"))

Regra absoluta: nunca faça COPY .Renviron nem escreva senhas no Dockerfile. Segredos são injetados em runtime (-e, ou secrets do orquestrador), mantendo a imagem limpa e compartilhável.

Imagens menores e mais seguras (boas práticas)

  • .dockerignore: liste o que não deve entrar na imagem (.git, .Renviron, renv/library, dados grandes). Reduz tamanho e evita vazar segredos.
  • Multi-stage builds: compile numa etapa, copie só o necessário para a imagem final — resulta em imagens menores.
  • Não rode como root: crie um usuário sem privilégios para o app (USER shiny), reduzindo o impacto de uma eventual invasão.
  • Fixe versões: rocker/r-ver:4.4.1, não rocker/r-ver:latest — latest quebra a reprodutibilidade ao mudar sem aviso.
# Exemplo: rodar como usuário não-root
RUN useradd -m shiny
USER shiny

O Docker empacota o app e o ambiente inteiro — sistema operacional, R, pacotes e bibliotecas — numa imagem que roda idêntica em qualquer lugar, encerrando o "funciona na minha máquina". Vale fixar o vocabulário: a imagem é o molde imutável descrito pelo Dockerfile, e o contêiner é uma instância em execução. O renv entra como complemento, congelando as versões exatas dos pacotes R no renv.lock: o Docker congela o sistema e o R, o renv congela os pacotes, e juntos entregam reprodutibilidade de verdade. As imagens rocker já trazem R pronto — r-ver, shiny, shiny-verse, geospatial —, e a base se escolhe pelas bibliotecas de sistema que o app precisa. No Dockerfile, a ordem é instalar as libs de sistema, restaurar o renv, instalar o pacote, declarar EXPOSE 3838 e, no CMD, chamar run_app() com shiny.host='0.0.0.0'. Ordenar os passos aproveitando o cache de camadas — o renv.lock antes do código-fonte — acelera muito os rebuilds. Sobe-se com docker build -t nome:tag . e docker run -p 3838:3838 nome:tag, e sem o mapeamento de portas não há acesso de fora. Configuração e segredos entram por variáveis de ambiente, nunca embutidos; e completam a higiene o .dockerignore, versões fixas e não rodar como root.

Exercícios

  1. Explique a diferença entre uma imagem e um contêiner Docker.

    ✓ Resposta:

    Uma imagem é um artefato imutável construído a partir de um Dockerfile, contendo o sistema operacional, o R, os pacotes e o app — é um "molde". Um contêiner é uma instância em execução dessa imagem — o processo de fato rodando. Da mesma imagem você pode iniciar vários contêineres simultâneos e independentes. A analogia usual: a imagem é a classe, o contêiner é o objeto instanciado. A imagem se constrói uma vez; contêineres se criam, param e destroem à vontade.

  2. Docker já garante reprodutibilidade. Por que ainda preciso de renv?

    ✓ Resposta:

    Porque Docker congela o sistema operacional e a versão do R, mas não, por si só, as versões dos pacotes R. Se o Dockerfile fizer install.packages("dplyr"), o build de hoje pega a versão atual e o build de amanhã pode pegar uma versão nova que mudou comportamento e quebra o app. O renv grava em renv.lock a versão exata de cada pacote, e renv::restore() no build reinstala precisamente essas versões. Docker + renv juntos congelam o ambiente completo; só Docker deixa os pacotes soltos.

  3. Por que copiar renv.lock e rodar renv::restore() antes de copiar o código-fonte no Dockerfile?

    ✓ Resposta:

    Por causa do cache de camadas do Docker. Cada instrução do Dockerfile vira uma camada, e o Docker reaproveita camadas cujas entradas não mudaram. O código-fonte muda com frequência; o renv.lock (dependências) muda raramente. Colocando a restauração dos pacotes — a etapa lenta — antes de COPY . ., uma simples alteração no código não invalida a camada de instalação de pacotes, e o Docker a reaproveita do cache, tornando rebuilds muito mais rápidos. Se o COPY . . viesse antes, qualquer mudança de código forçaria reinstalar todos os pacotes.

  4. No CMD, por que é necessário options(shiny.host = '0.0.0.0')? O que acontece sem isso?

    ✓ Resposta:

    shiny.host = '0.0.0.0' faz o Shiny escutar em todas as interfaces de rede do contêiner. Por padrão, o Shiny escuta em 127.0.0.1 (localhost), o que significa "apenas conexões vindas de dentro do próprio contêiner". Nesse caso, mesmo mapeando a porta com -p, as conexões externas (do host, dos usuários) não chegam ao app, e você vê a página não carregar. Com 0.0.0.0, o app aceita conexões de fora do contêiner, permitindo que o mapeamento de portas funcione. É um dos erros mais comuns ao conteinerizar Shiny.

  5. Como você passa a senha do banco para o app dentro do contêiner sem embuti-la na imagem? Por que não embutir?

    ✓ Resposta:

    Passando-a como variável de ambiente em runtime: docker run -e DB_PASSWORD="$DB_PASSWORD" ..., e no app lendo com Sys.getenv("DB_PASSWORD"). Não se deve embutir o segredo na imagem (via COPY .Renviron, ENV DB_PASSWORD=... ou escrevê-lo no código) porque a imagem é um artefato compartilhável — enviada a um registry, distribuída à equipe, versionada — e qualquer segredo nela fica acessível a quem tiver a imagem (inclusive no histórico de camadas). Injetar em runtime mantém a imagem limpa e o segredo fora do artefato. Use também .dockerignore para garantir que .Renviron nunca seja copiado por engano.

Fontes e leituras recomendadas