Artigo original: How to implement runtime environment variables with create-react-app, Docker, and Nginx

Escrito por: Krunoslav Banovac

Existem muitas maneiras de configurar sua aplicação em React. Vamos usar uma abordagem que respeita a metodologia Twelve-Factor App. Isso significa que ela exige a reconfiguração durante o tempo de execução. Assim, não é exigida uma build por ambiente.

O que queremos conseguir?

Queremos poder executar nossa aplicação em React como um contêiner do Docker criado uma única vez. Ele poderá ser executado em qualquer lugar, sendo configurável durante o tempo de execução. O resultado deve ser um contêiner leve e com bom desempenho, que serve nossa aplicação do React como um conteúdo estático, o que conseguiremos usando o Ngnix Alpine. Nossa aplicação deve permitir a configuração no arquivo docker-compose, assim:

version: "3.2"
services:
  my-react-app:
    image: my-react-app
    ports:
      - "3000:80"
    environment:
      - "API_URL=https://production.example.com"

Devemos poder configurar nossa aplicação em React usando a flag -e (de variáveis de ambiente) ao usar o comando Docker run.

À primeira vista, essa abordagem pode parecer trazer um benefício muito pequeno com relação ao trabalho adicional que ele exige para a configuração inicial. Porém, assim que a configuração está pronta, as configurações específicas do ambiente e sua implantação serão muito mais fáceis de tratar. Assim, para aqueles que buscarem ambientes dinâmicos ou que usarem sistemas de orquestração, essa abordagem é, definitivamente, algo para se pensar a respeito.

O problema

Para começar, deve ficar claro que não existe algo como variáveis de ambiente dentro do ambiente do navegador. Seja qual for a solução que usarmos hoje em dia, ela será apenas uma abstração falsa.

Porém, você pode estar se perguntando, e quanto aos arquivos .env e as variáveis prefixadas com REACT_APP que vêm diretamente da documentação (texto em inglês)? Mesmo dentro do código fonte, elas são usadas como process.env, assim como usamos as variáveis de ambiente dentro do Node.js.

Na verdade, o objeto process não existe dentro do ambiente do navegador. Ele é específico do Node. O create-react-app, por padrão, não faz renderização do lado do servidor. Ele não pode injetar variáveis de ambiente quando o conteúdo é servido (como faz o Next.js). Durante a transpilação, o processo do Webpack substitui todas as ocorrências de process.env com um valor de string que foi fornecido. Isso significa que ele somente pode ser configurado durante o tempo de criação (build).

Solução

O momento específico quanto ainda é possível injetar variáveis de ambiente ocorre quando iniciamos nosso contêiner. Então, podemos ler as variáveis de ambiente dentro do contêiner. Podemos escrevê-las em um arquivo que pode ser servido por meio do Nginx (que também serve nossa aplicação em React). Elas são importadas em nossa aplicação usando a tag <script> na seção head do index.html. Desse modo, nesse momento, executamos um script do bash que cria um arquivo JavaScript com variáveis de ambiente atribuídas como propriedades do objeto global window. Injetadas de modo a estarem disponíveis globalmente dentro de nossa aplicação da maneira que o navegador as recebe.

0_mqvCYhYLV_KN3VzY
Você verá um link para o repositório do GitHub ao final do artigo.

Guia passo a passo

Vamos iniciar com um projeto do create-react-app simples e criar um arquivo .env com a primeira variável de ambiente que queremos expor.

# Gerar a aplicação do React
create-react-app cra-runtime-environment-variables
cd cra-runtime-environment-variables

# Criar as variáveis de ambiente padrão que queremos usar
touch .env
echo "API_URL=https//default.dev.api.com" >> .env
(1) Usar a CRA CLI para gerar a aplicação do React (2) Criar o arquivo .env no diretório raiz do projeto recém-gerado

Em seguida, vamos escrever um script do bash pequeno, que lerá o arquivo .env e extrairá as variáveis de ambiente que serão escritas no arquivos. Se você definir uma variável de ambiente dentro do contêiner, seu valor será usado. Do contrário, ela voltará ao valor padrão do arquivo .env. Será criado um arquivo JavaScript que coloca os valores das variáveis de ambiente como um objeto, que é atribuído como uma propriedade do objeto window.

#!/bin/bash

# Recriar o arquivo de configuração
rm -rf ./env-config.js
touch ./env-config.js

# Adicionar a atribuição 
echo "window._env_ = {" >> ./env-config.js

# Ler cada linha no arquivo .env
# Cada linha representa pares chave-valor
while read -r line || [[ -n "$line" ]];
do
  # Dividir as variáveis env pelo caractere `=`
  if printf '%s\n' "$line" | grep -q -e '='; then
    varname=$(printf '%s\n' "$line" | sed -e 's/=.*//')
    varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//')
  fi

  # Ler os valores da variável atual se existir como uma variável de ambiente
  value=$(printf '%s\n' "${!varname}")
  # Caso contrário, usar o valor do arquivo .env
  [[ -z $value ]] && value=${varvalue}
  
  # Associar a propriedade de configuração ao arquivo JS
  echo "  $varname: \"$value\"," >> ./env-config.js
done < .env

echo "}" >> ./env-config.js
env.sh – (1) Remove o arquivo antigo e cria outro. (2) Escreve o código em JS que abre o literal de objeto (object literal) e o atribui ao objeto global window. (3) Lê cada linha do arquivo .env e o divide em pares chave-valor. (4) Procura a variável de ambiente, se estiver definida, e usa esse valor. Do contrário, usa o valor padrão do arquivo .env. (5) Anexa-o ao objeto que atribuímos ao objeto global window (6) Fecha o literal de objeto (object literal)

Precisamos adicionar a linha abaixo ao elemento <head> de index.html, que importa o arquivo criado por nosso script do bash.

<script src="%PUBLIC_URL%/env-config.js"></script>
index.html

Vamos exibir nossa variável de ambiente dentro da aplicação:

<p>API_URL: {window._env_.API_URL}</p>
src/App.js

Desenvolvimento

Durante o desenvolvimento, se não usarmos o Docker, podemos executar o script do bash pelo executor npm script modificando o package.json:

  "scripts": {
    "dev": "chmod +x ./env.sh && ./env.sh && cp env-config.js ./public/ && react-scripts start",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "build": "react-scripts build'"
  },
Executando o script do shell e depois react-script start

Se executarmos yarn dev, deveremos ver o resultado, assim:

1_e4ugnbph1YnN3uVbH2QNZA
Usando o valor padrão de API_URL do arquivo .env

Existem duas maneiras de reconfigurar as variáveis de ambiente dentro de dev. Mude o valor padrão dentro do arquivo .env ou sobrescreva os padrões executando o comando yarn dev com as variáveis de ambiente pré-anexadas:

API_URL=https://my.new.dev.api.com yarn dev
1_MHnRJn_JkV33mmK6Yh1raw
Usando o valor de API_URL que foi passado pela CLI

Por fim, editamos .gitignore, para que possamos excluir as configurações de ambiente do código fonte:

# Arquivos env temporários
/public/env-config.js
env-config.js

Quanto à variável de ambiente, estamos resolvidos! Já estamos a meio caminho. Não há uma diferença muito grande nesse momento em comparação ao que oferecia o create-react-app por padrão para o ambiente de desenvolvimento. O verdadeiro potencial dessa abordagem aparece de verdade em produção.

Produção

Agora, criaremos a configuração mínima do Nginx de modo a podermos criar uma imagem otimizada que sirva a aplicação já pronta para a produção.

# Criar o diretório para a configuração do Ngnix
mkdir -p conf/conf.d
touch conf/conf.d/default.conf conf/conf.d/gzip.conf

O arquivo de configuração principal deve ter essa aparência:

server {
  listen 80;
  location / {
    root   /usr/share/nginx/html;
    index  index.html index.htm;
    try_files $uri $uri/ /index.html;
    expires -1; # Defina com um valor diferente, dependendo de seus requisitos padrão
  }
  error_page   500 502 503 504  /50x.html;
  location = /50x.html {
    root   /usr/share/nginx/html;
  }
}
conf/conf.d/default.conf

Também é útil habilitar a compressão gzip para que os ativos de nossa aplicação sejam mais leves durante a transição de rede:

gzip on;
gzip_http_version  1.0;
gzip_comp_level    5; # 1-9
gzip_min_length    256;
gzip_proxied       any;
gzip_vary          on;

# MIME-types
gzip_types
  application/atom+xml
  application/javascript
  application/json
  application/rss+xml
  application/vnd.ms-fontobject
  application/x-font-ttf
  application/x-web-app-manifest+json
  application/xhtml+xml
  application/xml
  font/opentype
  image/svg+xml
  image/x-icon
  text/css
  text/plain
  text/x-component;
conf/conf.d/gzip.conf

Agora que nossa configuração do Nginx está pronta, podemos finalmente criar os arquivos Dockerfile e docker-compose:

touch Dockerfile docker-compose.yml
Criação dos arquivos do Docker

Inicialmente, usamos a imagem node:alpine para criar uma build de produção otimizada de nossa aplicação. Em seguida, criamos uma imagem de tempo de execução com base no nginx:alpine.

# => Contêiner da build
FROM node:alpine as builder
WORKDIR /app
COPY package.json .
COPY yarn.lock .
RUN yarn
COPY . .
RUN yarn build

# => Executar o contêiner
FROM nginx:1.15.2-alpine

# Config do Nginx
RUN rm -rf /etc/nginx/conf.d
COPY conf /etc/nginx

# Build estática
COPY --from=builder /app/build /usr/share/nginx/html/

# Exposição da porta padrão
EXPOSE 80

# Copiar o arquivo .env e o script do shell no contêiner
WORKDIR /usr/share/nginx/html
COPY ./env.sh .
COPY .env .

# Adicionar o bash
RUN apk add --no-cache bash

# Tornar nosso script do shell executável
RUN chmod +x env.sh

# Iniciar o servidor do Nginx
CMD ["/bin/bash", "-c", "/usr/share/nginx/html/env.sh && nginx -g \"daemon off;\""]

Agora, nosso contêiner está pronto. Podemos fazer todas as ações padrão com ele. Podemos criar um contêiner, executá-lo com as configurações em linha e enviá-las por push a um repositório fornecido por serviços como o Dockerhub.

docker build . -t kunokdev/cra-runtime-environment-variables
docker run -p 3000:80 -e API_URL=https://staging.api.com -t kunokdev/cra-runtime-environment-variables
docker push -t kunokdev/cra-runtime-environment-variables
Exemplo dos comandos de build, execução e push

O comando docker run acima deve ter como resultado uma aplicação assim:

1_kK7Ss5ODlukXgsLNuYh0Lg
Usando o API_URL fornecido pela flag de variável de ambiente para o comando docker run

Por fim, vamos criar nosso arquivo docker-compose. Você geralmente terá arquivos docker-compose diferentes, dependendo do ambiente, e usará a flag -f para selecionar o arquivo a ser usado.

version: "3.2"
services:
  cra-runtime-environment-variables:
    image: kunokdev/cra-runtime-environment-variables
    ports:
      - "5000:80"
    environment:
      - "API_URL=production.example.com"

Se usarmos docker-compose up, deveremos ver o seguinte resultado:

1_7TBDwzS_otshjMhQqvycmg
Usando o API_URL que foi fornecido pela propriedade de ambiente do docker-compose

Ótimo! Alcançamos nosso objetivo. Podemos reconfigurar nossa aplicação facilmente nos ambientes de desenvolvimento e de produção de modo bastante conveniente. Podemos finalmente criar uma vez e executar em qualquer lugar!

Se ficar travado em algum momento ou se tiver outras ideias para dar, acesse o código fonte no GitHub.

Próximos passos

A implementação atual do script do shell imprimirá todas as variáveis incluídas com o arquivo .env. Na maioria das vezes, não queremos expor todas elas. Você pode implementar filtros para as variáveis que não quiser expor usando prefixos ou alguma técnica semelhante.

Soluções alternativas

Como vimos acima, a configuração do tempo de criação (da build) satisfará a maioria dos casos de uso. Você pode confiar na abordagem padrão usando o arquivo .env por ambiente, criar um contêiner para cada ambiente e injetar os valores pelas variáveis de ambiente fornecidas pelo Webpack do create-react-app.

Você também pode dar uma olhada nessa issue do repositório do GitHub do create-react-app, a qual trata desse problema. Neste momento, já deve haver mais publicações e issues que tratem desse tópico. Cada uma delas oferecerá uma solução semelhante a que vemos acima. A decisão é sua sobre como desejará implementar os detalhes específicos. Você pode usar o Node.js para servir sua aplicação, o que significa que você também substituirá os scripts do shell por scripts do Node.js. Observe que o Nginx é mais conveniente para servir o conteúdo estático.

Se tiver perguntas ou se quiser enviar comentários, fique à vontade de abrir uma issue no GitHub. Como opção, você também pode seguir o autor para ver outras outras publicações dele relacionadas às tecnologias da web.