Cloudflare Pages: publicando um site do GitHub
Olá meus Unicórnios! 🦄✨
Sabe quando você tem um site pronto no GitHub e só quer que ele fique no ar? 😅 Pois é. Eu já tinha feito isso pelo painel da Cloudflare umas dez vezes — clica aqui, conecta o repositório ali, escreve o domínio acolá — até o dia em que precisei fazer isso várias vezes, para vários sites, sem ninguém clicando em nada.
Aí não tem jeito: é API. E foi aí que descobri que a API da Cloudflare tem um jeito bem particular de dizer que deu errado. 🤯
Neste artigo eu monto, do zero, um script de Node.js que faz três
coisas: cria o projeto no Cloudflare Pages,
liga ele ao repositório do GitHub e
aponta os domínios. Sem framework, sem biblioteca, sem
nada instalado — só o Node e o fetch que já vem embutido.
🔑 O que você precisa antes de começar
Duas coisinhas, e as duas moram no painel da Cloudflare:
O Account ID aparece na barra lateral do painel, na página inicial da sua conta. É um monte de letras e números.
O token de API você cria em My Profile → API Tokens → Create Token. Escolha criar um token personalizado e dê a ele a permissão Cloudflare Pages: Edit. Só isso — não precisa dar acesso ao resto da conta.
E agora o mais importante: esses dois valores nunca entram no
código. 🙏 Nunca. Nem "só para testar", nem "depois eu tiro". Um
token da Cloudflare com permissão de Pages consegue apagar os seus
projetos, e um git push distraído deixa ele publicado para
sempre no histórico do repositório.
Então eles vão por variável de ambiente, e o script lê assim:
// A URL base da API pode ser trocada por variavel de ambiente. E o que
// permite apontar o script para um servidor de teste sem editar o codigo.
const API = process.env.CF_API_URL || 'https://api.cloudflare.com/client/v4';
const TOKEN = process.env.CF_API_TOKEN;
const CONTA = process.env.CF_ACCOUNT_ID;
No Windows, antes de rodar o script:
set CF_API_TOKEN=seu_token_aqui
set CF_ACCOUNT_ID=seu_account_id_aqui
No Linux ou no Mac:
export CF_API_TOKEN="seu_token_aqui"
export CF_ACCOUNT_ID="seu_account_id_aqui"
😤 A armadilha que me pegou primeiro: o erro que vem com HTTP 200
Se você só for ler um pedaço deste artigo, leia este. 🙏
A API da Cloudflare responde tudo dentro de um envelope. Um envelope que é sempre igual, deu certo ou deu errado:
{ "success": true, "errors": [], "messages": [], "result": { ... } }
Repare no success. Ele existe por um motivo:
a Cloudflare devolve HTTP 200 com success: false
dentro. Isso mesmo — o código HTTP diz que tudo correu bem, e o
corpo da resposta diz que não. 😳
Eu escrevi a primeira versão do jeito que todo mundo escreve:
// NAO faca assim: deixa passar erro como se fosse sucesso
const resposta = await fetch(url, opcoes);
if (!resposta.ok) {
throw new Error('deu ruim');
}
E o script seguiu feliz da vida criando domínio num projeto que nunca tinha sido criado. Nenhum erro, nenhum aviso, e um site que simplesmente não existia no fim. O erro era meu, e a correção é conferir as duas coisas:
// ARMADILHA: a Cloudflare responde HTTP 200 com "success": false.
// Conferir so o resposta.ok deixa passar erro como se fosse sucesso.
if (!resposta.ok || !dados.success) {
let motivo = 'erro desconhecido';
if (dados.errors && dados.errors.length > 0) {
motivo = dados.errors[0].code + ': ' + dados.errors[0].message;
}
throw new Error('Cloudflare respondeu erro -> ' + motivo);
}
Aquele || !dados.success é a linha mais importante do
arquivo inteiro. Sem ela, todo erro da Cloudflare vira
sucesso silencioso — token errado, permissão faltando, nome de projeto
inválido, tudo.
📡 A função que conversa com a Cloudflare
Como toda chamada é igual (mesma URL base, mesmo cabeçalho, mesmo envelope), uma função só resolve a vida inteira. Todo o resto do script apenas monta o corpo e chama ela:
// Uma unica funcao conversa com a Cloudflare. Todo o resto do arquivo
// so monta o corpo e chama aqui.
async function chamarCloudflare(caminho, metodo, corpo) {
const url = API + '/accounts/' + CONTA + caminho;
const opcoes = {
method: metodo,
headers: {
'Authorization': 'Bearer ' + TOKEN,
'Content-Type': 'application/json'
}
};
if (corpo) {
opcoes.body = JSON.stringify(corpo);
}
const resposta = await fetch(url, opcoes);
const dados = await resposta.json();
// ARMADILHA: a Cloudflare responde HTTP 200 com "success": false.
// Conferir so o resposta.ok deixa passar erro como se fosse sucesso.
if (!resposta.ok || !dados.success) {
let motivo = 'erro desconhecido';
if (dados.errors && dados.errors.length > 0) {
motivo = dados.errors[0].code + ': ' + dados.errors[0].message;
}
throw new Error('Cloudflare respondeu erro -> ' + motivo);
}
return dados.result;
}
Repare que a URL sempre começa com
/accounts/<sua conta>. Isso é fixo na API do Pages, e
é por isso que o Account ID é obrigatório junto com o token.
🔗 Criando o projeto e ligando ao GitHub
Esta é a parte que o artigo promete, e ela é uma chamada só. O
segredo está todo dentro do source:
async function criarProjetoPages(nomeProjeto, donoGithub, repositorio, ramo) {
const corpo = {
name: nomeProjeto,
source: {
type: 'github',
config: {
owner: donoGithub,
repo_name: repositorio,
production_branch: ramo,
// Com false, ligar o repositorio NAO faz a Cloudflare
// publicar sozinha a cada push. Quem manda publicar e o
// seu script. Deixe true se quiser o deploy automatico.
production_deployments_enabled: false,
preview_deployment_setting: 'all'
}
},
build_config: {
build_command: 'npm run build',
destination_dir: 'dist',
root_dir: ''
}
};
Três campos merecem explicação, porque eu errei nos três. 😅
production_branch é o ramo que a
Cloudflare considera "o site de verdade". Costuma ser main.
Se o seu repositório ainda usa master, é aqui que se diz.
production_deployments_enabled: false é
a opção que mais confunde. Com ela em false, ligar o
repositório não faz a Cloudflare publicar sozinha a cada
git push — quem manda publicar é você. Eu quis assim porque
o meu script controla a hora do deploy. Se você quer o
comportamento clássico — empurrou, publicou — troque para
true e pode ignorar a seção do deploy mais abaixo.
build_config é o que a Cloudflare roda
para gerar o site. O build_command é o comando, e o
destination_dir é a pasta que sai dele. Para um site em HTML
puro, sem etapa de build, deixe o build_command vazio e
aponte o destination_dir para a pasta dos arquivos.
🔁 Criar ou atualizar? O projeto que já existe
Rodei o script duas vezes seguidas e tomei um erro
8000009: um projeto com esse nome já existe. Faz sentido —
mas eu queria um script que pudesse rodar de novo sem quebrar.
A solução é perguntar antes. E aqui mora um detalhe bonito: quando o
projeto não existe, a API responde erro
8000007. Ou seja, "não existe" chega como erro, e eu preciso
que ele vire um simples null em vez de derrubar o script:
// Devolve o projeto se ele existir, ou null se nao existir.
// O "nao existe" chega como erro 8000007, entao ele vira null aqui em
// vez de derrubar o script.
async function buscarProjeto(nomeProjeto) {
try {
const projeto = await chamarCloudflare('/pages/projects/' + nomeProjeto, 'GET', null);
return projeto;
} catch (erro) {
return null;
}
}
Com isso, a escolha entre criar e atualizar fica óbvia — e é a única parte do script que decide alguma coisa:
const jaExiste = await buscarProjeto(nomeProjeto);
// ARMADILHA: criar um projeto que ja existe devolve erro 8000009.
// Quando ele ja esta la, o certo e atualizar com PATCH.
if (jaExiste) {
console.log('Projeto ja existe, atualizando: ' + nomeProjeto);
return await chamarCloudflare('/pages/projects/' + nomeProjeto, 'PATCH', corpo);
}
console.log('Criando projeto: ' + nomeProjeto);
return await chamarCloudflare('/pages/projects', 'POST', corpo);
O PATCH é o que faz a segunda execução ser inofensiva.
Ele atualiza o que mudou e deixa o resto quieto, em vez de estourar.
🚀 Mandando publicar
Como eu desliguei o deploy automático lá em cima, preciso pedir a publicação na mão. É a chamada mais simples do arquivo — e o corpo vazio é proposital:
async function publicar(nomeProjeto) {
console.log('Mandando publicar...');
// O corpo vazio e proposital: a Cloudflare pega o codigo mais novo
// do ramo de producao que voce configurou acima.
return await chamarCloudflare('/pages/projects/' + nomeProjeto + '/deployments', 'POST', {});
}
Não se manda o código nessa chamada. A Cloudflare vai sozinha no
GitHub, pega o que estiver no ramo de produção que você configurou, roda
o build_command e publica. O seu script só dá a ordem.
🌐 Apontando os domínios
Assim que o projeto nasce, ele já responde num endereço grátis
terminado em .pages.dev — que é o subdomain que
a API devolve. Mas você provavelmente quer o seu domínio de verdade:
async function adicionarDominio(nomeProjeto, dominio) {
console.log('Adicionando dominio: ' + dominio);
return await chamarCloudflare('/pages/projects/' + nomeProjeto + '/domains', 'POST', {
name: dominio
});
}
E agora o detalhe cruel, que me custou uns bons minutos de confusão:
o www é um domínio separado. 🤯 Adicionar
exemplo.com.br não cobre www.exemplo.com.br, e
o contrário também não. São duas chamadas:
await adicionarDominio(nomeProjeto, dominio);
// O www e um dominio separado. Adicionar o dominio raiz nao
// cobre o www, e vice-versa.
await adicionarDominio(nomeProjeto, 'www.' + dominio);
⏳ Esperando o domínio ficar ativo
Aqui vem a parte que ninguém avisa: o domínio não
fica pronto na hora. Ele nasce pending e só vira
active quando o DNS aponta certo e a Cloudflare emite o
certificado. Prepare-se para esperar. ⏱️
Se o seu domínio já usa a Cloudflare como DNS, ela mesma cria o
registro e isso leva menos de um minuto. Se o DNS está em outro lugar,
você precisa criar um CNAME apontando para o endereço
.pages.dev do projeto — e aí depende do seu provedor.
Para o script não ficar preso para sempre, a espera tem duas saídas:
// Espera o dominio sair de "pending" e virar "active".
// Sempre com um limite de tentativas: sem isso, um DNS mal apontado
// deixa o script rodando para sempre.
async function esperarDominioAtivo(nomeProjeto, dominio, tentativas) {
for (let i = 1; i <= tentativas; i++) {
const info = await verDominio(nomeProjeto, dominio);
console.log(' tentativa ' + i + ': ' + dominio + ' esta ' + info.status);
if (info.status === 'active') {
return true;
}
// Este estado nao melhora sozinho: o DNS esta errado. Parar aqui
// evita gastar todas as tentativas a toa.
if (info.status === 'verification_failed') {
return false;
}
await new Promise(function (ok) { setTimeout(ok, 5000); });
}
return false;
}
async function main() {
const nomeProjeto = 'meu-site';
const donoGithub = 'minha-conta';
const repositorio = 'meu-site';
const dominio = 'exemplo.com.br';
As duas linhas que importam são as saídas antecipadas. O limite de
tentativas evita o laço infinito quando o DNS nunca aponta. E o
verification_failed é o mais útil dos dois: esse estado
não melhora sozinho — significa que o DNS está errado —
então insistir mais vinte vezes só faria perder tempo.
🧩 O script inteiro
São 175 linhas, e é só copiar. Salve como publicar.js,
ajuste as quatro variáveis lá no fim (nome do projeto, sua conta do
GitHub, o repositório e o domínio) e rode com
node publicar.js:
// publicar.js - cria um projeto no Cloudflare Pages ligado ao GitHub
// e aponta os dominios para ele.
//
// Rode com: node publicar.js
// Precisa de Node 18 ou mais novo (o fetch ja vem embutido).
// A URL base da API pode ser trocada por variavel de ambiente. E o que
// permite apontar o script para um servidor de teste sem editar o codigo.
const API = process.env.CF_API_URL || 'https://api.cloudflare.com/client/v4';
const TOKEN = process.env.CF_API_TOKEN;
const CONTA = process.env.CF_ACCOUNT_ID;
// Uma unica funcao conversa com a Cloudflare. Todo o resto do arquivo
// so monta o corpo e chama aqui.
async function chamarCloudflare(caminho, metodo, corpo) {
const url = API + '/accounts/' + CONTA + caminho;
const opcoes = {
method: metodo,
headers: {
'Authorization': 'Bearer ' + TOKEN,
'Content-Type': 'application/json'
}
};
if (corpo) {
opcoes.body = JSON.stringify(corpo);
}
const resposta = await fetch(url, opcoes);
const dados = await resposta.json();
// ARMADILHA: a Cloudflare responde HTTP 200 com "success": false.
// Conferir so o resposta.ok deixa passar erro como se fosse sucesso.
if (!resposta.ok || !dados.success) {
let motivo = 'erro desconhecido';
if (dados.errors && dados.errors.length > 0) {
motivo = dados.errors[0].code + ': ' + dados.errors[0].message;
}
throw new Error('Cloudflare respondeu erro -> ' + motivo);
}
return dados.result;
}
// Devolve o projeto se ele existir, ou null se nao existir.
// O "nao existe" chega como erro 8000007, entao ele vira null aqui em
// vez de derrubar o script.
async function buscarProjeto(nomeProjeto) {
try {
const projeto = await chamarCloudflare('/pages/projects/' + nomeProjeto, 'GET', null);
return projeto;
} catch (erro) {
return null;
}
}
async function criarProjetoPages(nomeProjeto, donoGithub, repositorio, ramo) {
const corpo = {
name: nomeProjeto,
source: {
type: 'github',
config: {
owner: donoGithub,
repo_name: repositorio,
production_branch: ramo,
// Com false, ligar o repositorio NAO faz a Cloudflare
// publicar sozinha a cada push. Quem manda publicar e o
// seu script. Deixe true se quiser o deploy automatico.
production_deployments_enabled: false,
preview_deployment_setting: 'all'
}
},
build_config: {
build_command: 'npm run build',
destination_dir: 'dist',
root_dir: ''
}
};
const jaExiste = await buscarProjeto(nomeProjeto);
// ARMADILHA: criar um projeto que ja existe devolve erro 8000009.
// Quando ele ja esta la, o certo e atualizar com PATCH.
if (jaExiste) {
console.log('Projeto ja existe, atualizando: ' + nomeProjeto);
return await chamarCloudflare('/pages/projects/' + nomeProjeto, 'PATCH', corpo);
}
console.log('Criando projeto: ' + nomeProjeto);
return await chamarCloudflare('/pages/projects', 'POST', corpo);
}
async function publicar(nomeProjeto) {
console.log('Mandando publicar...');
// O corpo vazio e proposital: a Cloudflare pega o codigo mais novo
// do ramo de producao que voce configurou acima.
return await chamarCloudflare('/pages/projects/' + nomeProjeto + '/deployments', 'POST', {});
}
async function adicionarDominio(nomeProjeto, dominio) {
console.log('Adicionando dominio: ' + dominio);
return await chamarCloudflare('/pages/projects/' + nomeProjeto + '/domains', 'POST', {
name: dominio
});
}
async function verDominio(nomeProjeto, dominio) {
return await chamarCloudflare('/pages/projects/' + nomeProjeto + '/domains/' + dominio, 'GET', null);
}
// Espera o dominio sair de "pending" e virar "active".
// Sempre com um limite de tentativas: sem isso, um DNS mal apontado
// deixa o script rodando para sempre.
async function esperarDominioAtivo(nomeProjeto, dominio, tentativas) {
for (let i = 1; i <= tentativas; i++) {
const info = await verDominio(nomeProjeto, dominio);
console.log(' tentativa ' + i + ': ' + dominio + ' esta ' + info.status);
if (info.status === 'active') {
return true;
}
// Este estado nao melhora sozinho: o DNS esta errado. Parar aqui
// evita gastar todas as tentativas a toa.
if (info.status === 'verification_failed') {
return false;
}
await new Promise(function (ok) { setTimeout(ok, 5000); });
}
return false;
}
async function main() {
const nomeProjeto = 'meu-site';
const donoGithub = 'minha-conta';
const repositorio = 'meu-site';
const dominio = 'exemplo.com.br';
if (!TOKEN || !CONTA) {
console.error('Faltou definir CF_API_TOKEN e CF_ACCOUNT_ID.');
process.exitCode = 1;
return;
}
try {
const projeto = await criarProjetoPages(nomeProjeto, donoGithub, repositorio, 'main');
console.log('Projeto pronto. Endereco gratuito: ' + projeto.subdomain);
await publicar(nomeProjeto);
await adicionarDominio(nomeProjeto, dominio);
// O www e um dominio separado. Adicionar o dominio raiz nao
// cobre o www, e vice-versa.
await adicionarDominio(nomeProjeto, 'www.' + dominio);
const ativou = await esperarDominioAtivo(nomeProjeto, dominio, 3);
if (ativou) {
console.log('Pronto! O site ja responde em https://' + dominio);
} else {
console.log('O dominio ainda nao ativou. Confira o DNS e rode de novo.');
}
} catch (erro) {
console.error('Deu errado: ' + erro.message);
// process.exitCode em vez de process.exit(1): o exit(1) mata o
// processo com a conexao do fetch ainda aberta, e o Node reclama.
// Assim ele fecha tudo sozinho e mesmo assim sai com erro.
process.exitCode = 1;
}
}
main();
▶️ Rodando
Com as variáveis de ambiente definidas, o caminho feliz sai assim:
Criando projeto: meu-site
Projeto pronto. Endereco gratuito: meu-site.pages.dev
Mandando publicar...
Adicionando dominio: exemplo.com.br
Adicionando dominio: www.exemplo.com.br
tentativa 1: exemplo.com.br esta pending
tentativa 2: exemplo.com.br esta pending
tentativa 3: exemplo.com.br esta active
Pronto! O site ja responde em https://exemplo.com.br
E rodando de novo, com o projeto já criado, a primeira linha muda —
é o PATCH entrando no lugar do POST:
Projeto ja existe, atualizando: meu-site
Projeto pronto. Endereco gratuito: meu-site.pages.dev
Com um token errado, o erro aparece em português e o script sai com código 1, em vez de seguir fingindo que deu certo:
Criando projeto: meu-site
Deu errado: Cloudflare respondeu erro -> 10000: Authentication error
🐛 O bug que eu mesma coloquei no fim do script
Este é o meu preferido, porque foi erro meu e só apareceu quando eu forcei o caminho da falha. 😳
Eu tinha escrito o catch do jeito automático, com
process.exit(1). O erro era tratado, a mensagem saía
bonitinha em português — e logo depois dela o Node cuspia isto:
Deu errado: Cloudflare respondeu erro -> 10000: Authentication error
Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 94
O process.exit() mata o processo na hora,
com a conexão do fetch ainda aberta — e o Node reclama de um
jeito assustador, que não tem nada a ver com a Cloudflare. Pior: o código
de saída virava 127, quando eu queria 1.
A correção é uma linha, e ela vale para qualquer script que use
fetch:
} catch (erro) {
console.error('Deu errado: ' + erro.message);
// process.exitCode em vez de process.exit(1): o exit(1) mata o
// processo com a conexao do fetch ainda aberta, e o Node reclama.
// Assim ele fecha tudo sozinho e mesmo assim sai com erro.
process.exitCode = 1;
}
O process.exitCode só anota o código de saída.
O Node fecha as conexões pendentes sozinho e termina em paz, ainda
saindo com erro para quem chamou o script.
📌 Onde cada coisa mora na API
Um resumo dos endereços usados aqui, todos abaixo de
/accounts/<conta>:
criar projeto POST /pages/projects
ver projeto GET /pages/projects/<projeto>
atualizar projeto PATCH /pages/projects/<projeto>
publicar POST /pages/projects/<projeto>/deployments
adicionar dominio POST /pages/projects/<projeto>/domains
ver dominio GET /pages/projects/<projeto>/domains/<dominio>
E os códigos de erro que aparecem no caminho:
8000007 é projeto não encontrado, 8000009 é
projeto com nome repetido e 10000 é problema de
autenticação — token errado ou sem a permissão de Pages.
Foi um daqueles casos em que a parte difícil não era nenhuma das três
tarefas do título, e sim descobrir que um 200 podia estar
mentindo. 😅
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.