Pular para o conteúdo
Node.js

Cloudflare Pages: publicando um site do GitHub

Paloma Macetko
Ilustração de uma nuvem mágica ligada por um feixe de luz a um baú cheio de pergaminhos de código, com placas de rua apontando caminhos, um castelo e um unicórnio de crina luminosa

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.exitCodeanota 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