Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

Variáveis de ambiente no Node: process.env e o arquivo .env

Como ler process.env, carregar o .env com o --env-file nativo do Node, separar dev de produção e não deixar segredo vazar no repositório.

Rodolfo Mori11 min de leitura

Variável de ambiente é um valor que vem de fora do código. O Node coloca todas elas em process.env quando o programa sobe, e é assim que a mesma API aponta para o banco da sua máquina hoje e para o banco de produção amanhã, sem trocar uma linha.

Os exemplos são todos da agenda da Pata Amiga, uma clínica veterinária. É uma API em Express que marca consultas, guarda tudo no Postgres e manda SMS de lembrete para os tutores. Porta, endereço do banco e chave do provedor de SMS são exatamente as três coisas que mudam de máquina para máquina — e por isso nenhuma delas pode estar escrita no código.

O mesmo aparelho, tomadas de lugares diferentes

Uma cafeteira continua sendo a mesma quando sai da sua casa e vai para um escritório, mas endereço, voltagem e tomada ao redor podem mudar. Uma aplicação também deve manter o mesmo código enquanto recebe de cada ambiente a porta, a URL do banco e as credenciais adequadas. Esses valores externos são as variáveis de ambiente.

No Node, process.env é a caixa de configuração entregue ao processo quando ele começa. Ela não é um cofre mágico: os valores existem na memória do programa, chegam sempre como strings e ainda precisam ser validados. Antes de criar .env, liste o que realmente muda entre sua máquina e produção. Se algo é regra fixa da aplicação, fica no código; se depende do lugar ou é segredo, entra pela configuração. Essa separação evita usar .env como gaveta para qualquer constante.

process.env: de onde essas variáveis realmente vêm

process.env é um objeto que o Node monta uma única vez, no boot, a partir do ambiente que o sistema operacional entregou ao processo. Quem entrega é quem chamou o node — normalmente o seu terminal.

js
console.log('porta:', process.env.PORT);
console.log('banco:', process.env.DATABASE_URL);
console.log('clinica:', process.env.NOME_CLINICA);

Rodando esse arquivo direto, sem nenhum preparo:

bash
node config.js
porta: undefined banco: undefined clinica: undefined

Nada quebrou. Variável que não existe vira undefined, e é isso que torna o problema traiçoeiro: a API sobe achando que está tudo certo e só falha na hora de conectar no banco.

Agora com um valor na frente do comando:

bash
PORT=4477 node config.js
porta: 4477 banco: undefined clinica: undefined

Esse PORT=4477 antes do node não é sintaxe do Node: é o shell criando uma variável válida só para aquele processo filho. Feche o terminal e ela some.

Boa parte do que está em process.env você nunca escreveu:

js
console.log('SHELL:', process.env.SHELL);
console.log('LANG:', process.env.LANG);
console.log('total herdado:', Object.keys(process.env).length);
console.log('PORT:', process.env.PORT);
SHELL: /bin/zsh LANG: C.UTF-8 total herdado: 59 PORT: undefined

Cinquenta e nove variáveis no meu terminal, herdadas do zsh sem ninguém pedir. O número muda de máquina para máquina — o que não muda é o mecanismo: o processo filho recebe uma cópia do ambiente do pai.

E tem um detalhe que vai importar mais adiante: o Node converte tudo para texto na hora da atribuição.

js
process.env.CONSULTAS_POR_DIA = 30;
console.log(typeof process.env.CONSULTAS_POR_DIA, process.env.CONSULTAS_POR_DIA);
string 30

Guardei um número e li uma string. Isso não é acidente do exemplo: é a regra do objeto inteiro.

–env-file: o Node 24 lê o .env sem instalar dotenv

Digitar cinco variáveis na frente de todo comando é inviável. A solução de sempre foi um arquivo .env na raiz do projeto — e desde o Node 20.6 ele é lido pelo próprio Node, sem dependência nenhuma.

Este é o .env da agenda:

bash
PORT=4477
NOME_CLINICA=Pata Amiga
DATABASE_URL=postgres://vet:senha_local@localhost:5432/agenda
SMS_API_KEY=sk_teste_9f3c1b7a
LEMBRETE_SMS=false
CONSULTAS_POR_DIA=24

E é só apontar a flag para ele:

bash
node --env-file=.env config.js
porta: 4477 banco: postgres://vet:senha_local@localhost:5432/agenda clinica: Pata Amiga

Repare que o arquivo não é lido pelo seu código: quem lê é o Node, antes de a primeira linha do programa executar. Por isso config.js continuou idêntico.

Agora a parte que quase ninguém testa. O que ganha quando a variável está nos dois lugares?

bash
PORT=9999 node --env-file=.env config.js
porta: 9999 banco: postgres://vet:senha_local@localhost:5432/agenda clinica: Pata Amiga

O ambiente real venceu o arquivo. O .env só preenche o que ainda não existe. Essa precedência é o que faz o mesmo projeto funcionar na sua máquina e no servidor: lá o .env nem existe, e a plataforma de deploy injeta a PORT dela direto no ambiente.

Se o arquivo apontado não existir, o Node se recusa a subir:

bash
node --env-file=.env.producao config.js
node: .env.producao: not found

O processo termina com código de saída 9, o que derruba o npm start junto — bom comportamento, porque subir sem configuração é pior do que não subir. Para o caso em que o arquivo é opcional, existe a variante tolerante:

bash
node --env-file-if-exists=.env.producao config.js
.env.producao not found. Continuing without it. porta: undefined banco: undefined clinica: undefined

Na prática a flag vive no package.json, para ninguém precisar lembrar dela:

json
{
  "name": "agenda-pata-amiga",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "node --watch --env-file=.env servidor.js",
    "start": "node servidor.js"
  }
}

O dev carrega o .env e reinicia sozinho a cada salvamento. O start, que é o comando de produção, não carrega arquivo nenhum: lá as variáveis vêm do ambiente de verdade.

Quando o dotenv ainda faz sentido no projeto

O dotenv é o pacote que resolvia isso antes de o Node resolver. Ele continua existindo e funciona assim:

js
import 'dotenv/config';

console.log('porta:', process.env.PORT);

A pergunta honesta é: o parser nativo é tão bom quanto o dele? Testei os casos que costumam quebrar parser caseiro — comentário, aspas com cerquilha dentro, prefixo export e valor de várias linhas:

bash
# credenciais do provedor de SMS
SMS_REMETENTE="Pata Amiga # 24h"
export SMS_API_KEY=sk_teste_9f3c1b7a
CHAVE_ASSINATURA="-----INICIO-----
linha-do-meio
-----FIM-----"
js
console.log('REMETENTE:', JSON.stringify(process.env.SMS_REMETENTE));
console.log('API_KEY  :', JSON.stringify(process.env.SMS_API_KEY));
console.log('ASSINATURA:', JSON.stringify(process.env.CHAVE_ASSINATURA));

Primeiro pelo Node, depois pelo dotenv 17.4.2:

bash
node --env-file=.env.dificil dificil.js
DOTENV_CONFIG_PATH=.env.dificil node -r dotenv/config dificil.js
REMETENTE: "Pata Amiga # 24h" API_KEY : "sk_teste_9f3c1b7a" ASSINATURA: "-----INICIO-----\nlinha-do-meio\n-----FIM-----" REMETENTE: "Pata Amiga # 24h" API_KEY : "sk_teste_9f3c1b7a" ASSINATURA: "-----INICIO-----\nlinha-do-meio\n-----FIM-----"

Byte por byte igual. O # dentro das aspas não virou comentário, o export foi ignorado como prefixo e as três linhas da chave viraram um valor só, com \n no meio.

E onde os dois falham junto? Em interpolação:

bash
DB_HOST=localhost
DB_USUARIO=vet
DATABASE_URL=postgres://${DB_USUARIO}@${DB_HOST}:5432/agenda
bash
node --env-file=.env.expand -p "process.env.DATABASE_URL"
postgres://${DB_USUARIO}@${DB_HOST}:5432/agenda

Nenhum dos dois expande ${} — isso é coisa de shell, e no .env você recebe o texto cru. Quem precisa disso instala o dotenv-expand; quem não precisa escreve a URL inteira e segue a vida.

situação use por quê
API que sobe por npm run dev --env-file zero dependência, e o arquivo é lido antes do seu código
projeto que ainda roda em Node 18 dotenv a flag nativa não existe nessa versão
carregar .env no meio do código, condicionalmente dotenv é função, dá para chamar quando você quiser
precisa de ${VAR} dentro do .env dotenv + dotenv-expand nenhum parser faz isso sozinho
produção nenhum dos dois as variáveis já vêm do ambiente da plataforma

Tudo em process.env é string, inclusive a palavra “false”

Este é o bug que aparece em toda API que tem uma chave liga-desliga. A clínica tem uma: LEMBRETE_SMS=false, porque em desenvolvimento ninguém quer disparar SMS de verdade.

js
console.log('valor:', process.env.LEMBRETE_SMS);
console.log('tipo:', typeof process.env.LEMBRETE_SMS);

if (process.env.LEMBRETE_SMS) {
  console.log('enviando SMS de lembrete para os tutores...');
} else {
  console.log('lembrete por SMS desligado');
}
valor: false tipo: string enviando SMS de lembrete para os tutores...

A variável vale false e o if entrou mesmo assim. O motivo é simples quando você olha a segunda linha: o valor é a string 'false', e qualquer string com pelo menos um caractere é um valor truthy. 'false', '0' e 'nao' entram todos no if.

O mesmo vale para número:

bash
node --env-file=.env -p "process.env.PORT + 1"
44771

A conta deu 44771 porque + entre string e número concatena em vez de somar: o Node juntou '4477' com '1'. Em vez de lembrar disso em cada uso, converta tudo uma vez só, num módulo de configuração:

js
const bool = (valor) => valor === 'true';

export const config = {
  porta: Number(process.env.PORT),
  clinica: process.env.NOME_CLINICA,
  lembreteSms: bool(process.env.LEMBRETE_SMS),
  consultasPorDia: Number(process.env.CONSULTAS_POR_DIA),
};

console.log(config);
console.log('porta + 1 =', config.porta + 1);
console.log('vagas restantes =', config.consultasPorDia - 18);
{ porta: 4477, clinica: 'Pata Amiga', lembreteSms: false, consultasPorDia: 24 } porta + 1 = 4478 vagas restantes = 6

Agora porta é número, lembreteSms é booleano de verdade e o resto do projeto importa config em vez de tocar em process.env. Repare no bool: ele trata como ligado a palavra exata true. Qualquer outra coisa — false, 0, vazio, um erro de digitação — desliga. Num interruptor que dispara SMS para clientes, errar para o lado desligado é a escolha certa.

Validando as variáveis no boot, antes de a API subir

Se a SMS_API_KEY estiver faltando, o melhor momento para descobrir isso é o primeiro segundo da aplicação — não a primeira consulta marcada às três da manhã. O módulo de configuração vira, então, o portão de entrada:

js
const obrigatorias = ['PORT', 'DATABASE_URL', 'SMS_API_KEY'];
const faltando = obrigatorias.filter((nome) => !process.env[nome]);

if (faltando.length > 0) {
  console.error(`[config] a API não subiu: falta ${faltando.join(', ')}`);
  console.error('[config] copie o .env.example para .env e preencha os valores');
  process.exit(1);
}

export const config = {
  porta: Number(process.env.PORT),
  clinica: process.env.NOME_CLINICA ?? 'Clínica sem nome',
  bancoUrl: process.env.DATABASE_URL,
  smsApiKey: process.env.SMS_API_KEY,
  lembreteSms: process.env.LEMBRETE_SMS === 'true',
  consultasPorDia: Number(process.env.CONSULTAS_POR_DIA ?? 20),
};

O !process.env[nome] pega os dois casos que interessam: a variável ausente (undefined) e a variável declarada e vazia (SMS_API_KEY=), que é o erro mais comum de quem copiou o .env.example e esqueceu de preencher.

O servidor só importa a configuração pronta:

js
import express from 'express';
import { config } from './env.js';

const app = express();

app.get('/saude', (req, res) => {
  res.json({
    clinica: config.clinica,
    consultasPorDia: config.consultasPorDia,
    lembreteSms: config.lembreteSms,
  });
});

app.listen(config.porta, () => {
  console.log(`[api] ${config.clinica} ouvindo na porta ${config.porta}`);
});

Esquecendo o --env-file, o resultado é uma mensagem que diz o que fazer, e não um stack trace de driver de banco:

bash
node --run start
[config] a API não subiu: falta PORT, DATABASE_URL, SMS_API_KEY [config] copie o .env.example para .env e preencha os valores

Com um .env pela metade, a mensagem fica ainda mais precisa — ela nomeia exatamente a variável que ficou para trás:

bash
node --env-file=.env.parcial servidor.js
[config] a API não subiu: falta SMS_API_KEY [config] copie o .env.example para .env e preencha os valores

E com tudo no lugar:

bash
node --env-file=.env servidor.js &
curl -s http://localhost:4477/saude
[api] Pata Amiga ouvindo na porta 4477 {"clinica":"Pata Amiga","consultasPorDia":24,"lembreteSms":false}

Oito linhas de validação, e o erro de configuração passou a se explicar sozinho. Quando a lista de variáveis cresce e você quer validar formato — que DATABASE_URL seja uma URL, que PORT seja um número entre 1 e 65535 —, vale trocar o filter por um schema, com a mesma biblioteca que valida a entrada da API.

.env, .env.example e a linha do .gitignore

O .env fica na sua máquina e nunca entra no Git. Mas quem clona o projeto precisa saber o que preencher — e é esse o trabalho do .env.example, que vai versionado, com as mesmas chaves, valores fictícios ou em branco, e um comentário em cada uma:

bash
# Porta em que a API sobe. Em produção quem define é a plataforma.
PORT=4477
# Nome que aparece no rodapé do e-mail e no /saude
NOME_CLINICA=Pata Amiga
# Conexão com o Postgres da agenda
DATABASE_URL=postgres://usuario:senha@localhost:5432/agenda
# Chave do provedor de SMS — peça a sua no painel
SMS_API_KEY=
# Servidor de e-mail para a confirmação de consulta
SMTP_HOST=

Os dois arquivos desandam com o tempo: alguém adiciona uma variável no .env e esquece de documentar no exemplo. Vinte linhas resolvem, usando o mesmo node:fs da lição sobre ler e escrever arquivo:

js
import { readFileSync } from 'node:fs';

const chaves = (arquivo) =>
  readFileSync(arquivo, 'utf8')
    .split('\n')
    .map((linha) => linha.trim())
    .filter((linha) => linha && !linha.startsWith('#'))
    .map((linha) => linha.split('=')[0]);

const doExemplo = chaves('.env.example');
const doLocal = chaves('.env');

console.log('faltam no seu .env :', doExemplo.filter((c) => !doLocal.includes(c)).join(', ') || 'nada');
console.log('não documentadas   :', doLocal.filter((c) => !doExemplo.includes(c)).join(', ') || 'nada');
faltam no seu .env : SMTP_HOST não documentadas : LEMBRETE_SMS, CONSULTAS_POR_DIA

Ele achou as duas divergências reais do meu projeto. Rodando isso no dev antes do servidor, a desatualização vira um aviso na sua tela em vez de um “aqui não funciona” no chat do time.

Falta a linha mais importante do projeto inteiro:

bash
.env
.env.local

Essas duas linhas vão no .gitignore antes do primeiro commit. A próxima seção mostra o que acontece com quem coloca depois.

NODE_ENV: o que ele muda de verdade e o que é lenda

Começa com a parte que surpreende: o Node não define NODE_ENV e não olha para ela. Para o runtime, é uma variável comum, sem significado especial. Quem dá sentido a ela são as bibliotecas — o Express, entre elas.

js
import express from 'express';
const app = express();
const porta = Number(process.env.PORT);
app.get('/consultas', () => { throw new Error('banco de dados indisponível'); });
console.log('NODE_ENV =', process.env.NODE_ENV, '| app.get(env) =', app.get('env'));
app.listen(porta, () => console.log('ouvindo em', porta));

Subindo o mesmo arquivo duas vezes, uma com a variável e outra sem:

bash
PORT=4481 node amb2.js &
PORT=4482 NODE_ENV=production node amb2.js &
NODE_ENV = undefined | app.get(env) = development ouvindo em 4481 NODE_ENV = production | app.get(env) = production ouvindo em 4482

Sem a variável, o Express assume development sozinho. Agora a rota que estoura de propósito, nas duas portas:

bash
curl -s http://localhost:4481/consultas | wc -c
curl -s http://localhost:4482/consultas | wc -c
1005 148

Oitocentos e cinquenta e sete bytes de diferença, e eles são o stack trace:

bash
curl -s http://localhost:4481/consultas | tr '>' '>\n' | sed -n '/<pre/,$p' | head -c 200
<pre>Error: banco de dados indisponível<br> &nbsp; &nbsp;at file:///private/tmp/vet/amb2.js:4:37<br> &nbsp; &nbsp;at Layer.handleRequest (/private/tmp/vet/node_modules/router/lib/layer.js:152:17)<br>

Com NODE_ENV=production, a mesma requisição devolve só isto:

bash
curl -s http://localhost:4482/consultas | sed -n '8p'
<pre>Internal Server Error</pre>

Caminho de arquivo, nome de dependência e linha de código — tudo isso ia para qualquer pessoa que soubesse quebrar sua rota, só porque a variável estava vazia. Essa é a mudança concreta, e ela vale a pena.

O que é lenda: NODE_ENV=production não deixa o Node mais rápido, não muda nada em fs, http ou no event loop, e não é um interruptor de “modo produção” do runtime. Fora do Express, quem também olha para ela é o npm (npm ci --omit=dev não instala dependências de desenvolvimento) e os empacotadores de front-end, que usam o valor para escolher o build.

Segredo commitado por engano: o que fazer agora

Acontece com todo mundo uma vez. O git add . no primeiro dia do projeto, o .gitignore que ainda não existia, e o .env de produção dentro do commit:

bash
git add .
git commit -m "primeira versao da API da agenda"
git ls-files
.env servidor.js

O reflexo é criar o .gitignore na hora. Só que ele não desfaz nada — ele só vale para arquivos que o Git ainda não rastreia:

bash
echo ".env" > .gitignore
git add .gitignore && git commit -m "adiciona .gitignore"
git status --short
M .env

O .env continua rastreado, e o Git continua vendo as alterações dele. Para tirar do rastreamento sem apagar o arquivo da sua máquina:

bash
git rm --cached .env
git commit -m "tira o .env do versionamento"
git ls-files
.gitignore servidor.js

Parece resolvido. Não está:

bash
git log --oneline
git show ca94c60:.env
7606fff tira o .env do versionamento 3708b0c adiciona .gitignore ca94c60 primeira versao da API da agenda PORT=4477 DATABASE_URL=postgres://vet:senha_real@db.patamiga.com.br:5432/agenda SMS_API_KEY=sk_live_8a41f2c9d0

A senha do banco e a chave do provedor de SMS continuam ali, legíveis, em um commit antigo — e em cada clone que alguém já fez do repositório. Reescrever o histórico ajuda, mas não alcança as cópias que já saíram.

Por isso a ordem de prioridade é esta, e nesta ordem:

  1. Revogue a chave. Gere uma nova no painel do provedor e troque a senha do banco. Enquanto o segredo antigo funcionar, ele é um segredo vazado.
  2. Coloque .env no .gitignore e rode git rm --cached .env.
  3. Só então cuide do histórico, se o repositório for privado e pequeno.
  4. Adicione o .env.example no lugar, para o próximo a clonar não repetir.

O passo 1 é o único que resolve de fato. Os outros três impedem a próxima vez.

O que vem depois

Com a configuração fora do código, a API da clínica está pronta para sair da sua máquina. O próximo passo natural é entender o servidor HTTP por baixo do Express, montar a primeira rota de verdade e, quando estiver de pé, colocar a API no ar — onde a PORT deixa de vir do .env e passa a vir da plataforma. O mapa completo está no guia de Node.js e na trilha de Node.

  • node
  • env
  • process.env
  • dotenv
  • configuracao
  • seguranca

Perguntas frequentes

Preciso instalar o dotenv no Node 24?
Não para o caso comum. O Node lê o arquivo com a flag --env-file, e o parser nativo aguenta aspas, comentários, prefixo export e valor de várias linhas. O dotenv continua útil quando você precisa carregar a configuração no meio do código, e não na linha de comando.
O .env deve ir para o repositório?
Não. O .env fica no .gitignore e nunca é versionado. Quem vai para o repositório é o .env.example, com as mesmas chaves e os valores em branco, para quem clonar o projeto saber o que precisa preencher.
Como passo variável de ambiente no deploy, sem arquivo .env?
A plataforma injeta as variáveis direto no ambiente do processo, por um painel ou por um comando da CLI. Como o ambiente real tem precedência sobre o arquivo, o mesmo código funciona nos dois lugares sem if.
Posso mudar uma variável com o servidor já rodando?
Não dá. O process.env é uma fotografia tirada no boot: alterar a variável no terminal depois disso não muda nada dentro do processo que já subiu. Para valer, reinicie a aplicação.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. Node.js — CLI: --env-file — nodejs.org
  2. Node.js — process.env — nodejs.org
  3. The Twelve-Factor App — Config — 12factor.net

Continue por aqui