2

Como eu agendo posts num site 100% estático de graça sem servidor, sem CMS, sem banco em runtime

Por um tempo eu acreditei que as duas coisas que eu queria eram incompatíveis:

  1. Um site totalmente estático. Cloudflare Pages, adapter-static, tudo pré-renderizado, zero servidores pra cuidar, free tier pra sempre.
  2. Um blog onde eu pudesse escrever um post hoje e ele entrar no ar terça que vem às 7h, sem eu precisar estar acordado pra apertar um botão.

Estático significa que o HTML é congelado no build. Agendar significa que o conteúdo aparece de acordo com o relógio. Isso parece o oposto um do outro. A resposta de sempre é "põe um CMS" ou "renderiza o blog no servidor". Eu não queria nenhum dos dois. Então fui atrás de uma terceira opção, e ela acabou sendo mais simples do que eu esperava e custou R$ 0.

Essa é a arquitetura na qual eu cheguei pro Quick Tools, e o porquê de cada peça ser do jeito que é.

A ideia central: o banco é uma entrada de build, não uma dependência de runtime

O truque é parar de pensar no banco como algo com que o site conversa, e começar a pensar nele como algo com que o build conversa.

  • O conteúdo do blog vive no Neon Postgres (uma linha por par (slug, lang)).
  • Um script Node lê esse banco uma vez, durante o build, e grava os posts em disco como arquivos meta.json + {lang}.md.
  • O resto do build (o SSG do SvelteKit, sitemap, RSS, imagens OG) lê esses arquivos como se eles sempre tivessem estado lá.
  • O site publicado tem zero código de banco. Não existe connection string no bundle do cliente, não existe rota de API, nada. O banco poderia nem existir depois que o build termina.

Então o site continua 100% estático. O banco é só onde eu guardo meus rascunhos e o agendamento uma planilha chique que o pipeline de build por acaso lê.

Materialização: transformando linhas em arquivos

A regra de publicação inteira vive numa única cláusula WHERE do SQL:

const rows = await sql`
  SELECT slug, lang, title, excerpt, body, cat, published_at, /* ... */
  FROM blog_posts
  WHERE status IN ('scheduled', 'published')
    AND published_at <= now()
  ORDER BY slug, lang
`;

Esse é o motor de agendamento inteiro. Um post fica visível quando o status é scheduled ou published e o published_at está no passado. Um post datado pra terça que vem simplesmente não volta dessa query hoje então nunca é gravado em disco, então nunca entra no build.

Aí é só escrever as linhas como arquivos:

for (const [slug, rows] of grouped) {
  const dir = join(BLOG_DIR, slug);
  mkdirSync(dir, { recursive: true });
  const { meta, contents } = rowsToFiles(slug, rows);
  writeFileSync(join(dir, 'meta.json'), stringifyMeta(meta), 'utf-8');
  for (const [lang, body] of Object.entries(contents))
    writeFileSync(join(dir, `${lang}.md`), body, 'utf-8');
}

Esse script roda primeiro no pipeline de build, antes da geração de sitemap/RSS/OG — todos eles leem os arquivos materializados. Nada lá na frente sabe ou se importa que o conteúdo veio do Postgres. Tudo o que se vê é uma pasta cheia de markdown.

O relógio: quem dispara o build quando ninguém deu push?

Um site estático só muda quando faz rebuild. Um push pra main faz o rebuild — mas eu não vou dar push num commit às 7h todo dia só pra colocar um post no ar. Então o build precisa disparar por agendamento também.

Cron do GitHub Actions, três vezes por dia:

on:
  push:
    branches: [main]
  workflow_dispatch:
  schedule:
    # Rebuild pra publicar posts agendados que venceram. ~90 builds/mês — dentro
    # do free tier do Cloudflare Pages (500/mês). Horários em UTC.
    - cron: '0 10 * * *' # 07h BRT
    - cron: '0 15 * * *' # 12h BRT
    - cron: '0 22 * * *' # 19h BRT

Cada execução do cron é um rebuild completo. O build re-roda aquela query SQL contra o now(), e qualquer post cujo published_at já passou desde o último build é materializado e publicado. Sem commit pós-go-live, sem deploy manual.

O trade-off honesto

O go-live é a próxima execução do cron depois do published_at, não o minuto exato. Se eu agendo um post pras 07:30 BRT, ele entra no ar no build das 12h, não às 07:30 cravado. Pra um blog, "no ar dentro de algumas horas do horário agendado" é totalmente aceitável — e essa imprecisão é o preço de continuar estático. Eu decidi isso de cara, e três builds por dia mantêm o pior caso de atraso em poucas horas.

A conta do orçamento de build também importa: 3 builds/dia ≈ 90 builds/mês, confortavelmente dentro do free tier do Cloudflare Pages de 500/mês. Eu tenho agendamento e nunca vejo uma fatura.

A parte que me pegou: não deixe um banco vazio apagar seu blog

Esse é o modo de falha fácil de não enxergar. O build lê o banco e sobrescreve o diretório blog/. Então o que acontece no dia em que o Neon tem um soluço, ou uma conexão dá timeout, ou uma query ruim retorna zero linhas?

A versão ingênua desse script limpa o blog/, não recebe nada do banco, escreve nada — e alegremente publica um blog vazio. Uma instabilidade passageira do banco vira um apagamento de conteúdo em produção. É o tipo de coisa que você descobre no pior momento possível.

A solução é tratar os arquivos commitados como um snapshot last-known-good e se recusar a destruí-los num read ruim:

async function main() {
  let rows;
  try {
    rows = await fetchRows();
  } catch (err) {
    const existing = listPostDirs(BLOG_DIR).length + listPostDirs(NEWS_DIR).length;
    if (existing > 0) {
      console.warn(`⚠  Não consegui falar com o Neon (${err.message}). Mantendo snapshot commitado de ${existing} posts.`);
      return; // segue com o conteúdo last-known-good
    }
    throw new Error(`Neon inacessível e nenhum snapshot commitado existe`);
  }

  if (rows.length === 0) {
    console.warn(`⚠  Query retornou 0 posts. Mantendo snapshot commitado em vez de zerar blog/.`);
    return;
  }

  writePosts(groupBySlug(rows));
}

Dois guards, ambos apontando na mesma direção: um read ruim ou vazio nunca apaga conteúdo bom. Se o Neon está inacessível, a gente mantém o que está commitado no git e loga um aviso. Se a query genuinamente retorna zero linhas (suspeito eu sempre tenho posts publicados), mesma coisa. O blog só é reescrito quando o banco devolve dados de verdade.

É por isso que os arquivos de blog/ ficam commitados no repo. Eles não são a fonte da verdade o Neon é, mas são um fallback durável que torna todo deploy seguro mesmo quando a fonte da verdade está temporariamente fora. A materialização é idempotente: um build limpo contra dados inalterados produz um diff byte a byte idêntico, então commitar o snapshot continua sem ruído.

Autoria: escrever e agendar sem encostar em SQL

O fluxo do dia a dia nunca envolve escrever SQL na mão. Existe um script de upsert que lê um diretório de post (o mesmo formato meta.json + {lang}.md) e o grava no Neon com um status e um timestamp:

# agenda um post pra entrar no ar num horário específico (status = scheduled)
pnpm blog:upsert .articles/meu-post --at 2026-06-20T10:00:00Z

# publica na hora
pnpm blog:upsert .articles/meu-post --status published

Então eu escrevo markdown localmente, rodo um comando pra empurrar pro agendamento, e esqueço. O próximo build do cron depois daquele timestamp coloca no ar. O formato de arquivos em disco e as linhas do banco são duas visões da mesma coisa, e um módulo de mapeamento puro converte entre elas sem nenhum código de banco dentro o que mantém a migração, o build e o script de autoria todos compartilhando uma única fonte da verdade pro formato de um post.

Por que isso ganha das alternativas óbvias

vs. um CMS de verdade / backend headless: Nenhum servidor pra rodar, atualizar ou pagar. Nenhum acoplamento de runtime se meu provedor de banco sumisse amanhã, o site atualmente publicado continua funcionando pra sempre, porque o conteúdo está embutido.

vs. renderizar o blog no servidor (SSR): SSR me daria agendamento no minuto exato, mas ao custo de um servidor rodando em toda requisição, cold starts, e um banco no caminho crítico do carregamento da página. Pra um blog de conteúdo, são muitas peças móveis pra comprar uma precisão de que eu não preciso.

vs. git puro (markdown no repo, sem banco): Essa é a alternativa mais próxima, e é ótima até você querer agendamento. O git não tem nenhum conceito de "publique isso na terça". Você voltaria a escrever um commit na hora do go-live exatamente o que eu estava tentando evitar. O banco me dá uma coluna published_at e um cron faz o resto.

Onde isso quebra e a saída de emergência

Nenhuma arquitetura é livre de limites, e o honesto é nomear onde essa aqui para de funcionar antes de bater nela.

O teto não é disco nem tempo de build é a contagem de arquivos por deploy. O Cloudflare Pages limita um deploy a 20.000 arquivos, e cada página prerenderizada sobe como um ou dois arquivos (o .html, mais um arquivo de dados separado se o SvelteKit não inlinar). Então arquivos ≈ (rotas × idiomas) + chunks JS/CSS + imagens. Num ritmo constante de publicação isso cresce linear, e lá pelos milhares de posts você começaria a namorar esse muro de 20k.

O instinto é "então vou ter que migrar pra SSR". Não vai, pelo menos não tudo. A virada de chave: uma página prerenderizada é um arquivo; uma página sob demanda (SSR) não é. Uma página renderizada no edge a cada requisição gera zero arquivos estáticos, então simplesmente não conta pro limite.

Isso transforma "estático vs. SSR" de um binário num botão de ajuste. O caminho de migração, quando o dia chegar:

  1. Primeiro, só enxugar. Parar de prerenderizar a cauda longa realmente descartável (no meu caso, news datadas em todos os idiomas). Sem mudança de arquitetura, ganha anos.
  2. Depois, virar híbrido. Trocar o adapter-static pelo adapter do Cloudflare e decidir por rota: manter prerenderizadas como arquivos estáticos as páginas que importam, tools, índice do blog, posts recentes e populares e deixar a cauda longa renderizar sob demanda no edge. Essas páginas de edge não produzem arquivo, então o orçamento de arquivos para de crescer pra elas. E o crucial: um crawler que bate numa página renderizada no edge recebe o mesmo HTML completo que receberia de um arquivo estático SSR no edge custa alguns milissegundos de compute, não o seu SEO.
  3. Só em escala extrema você cachearia as respostas SSR no edge com revalidação e esse é um problema que a maioria dos projetos nunca tem.

Então a resposta pra "vou acabar precisando de um servidor?" é: só pra fatia que realmente precisa, e mesmo assim é compute no edge num free tier, não uma máquina pra cuidar. O gatilho a observar é concreto o total de arquivos no build chegando a ~80% do limite. Você age sobre um número, não sobre um susto.

A lição

A virada de chave que destravou tudo: "estático" é uma propriedade do que você publica, não de onde o seu conteúdo mora. Um banco pode perfeitamente fazer parte da vida de um site estático desde que ele só converse com o build, nunca com o navegador.

Quando você aceita que builds são baratos e podem ser disparados por um relógio, "publicação agendada num site estático" deixa de ser uma contradição e vira um cron job mais uma cláusula WHERE. A única engenharia de verdade que sobra é garantir que um read ruim não derrube o seu conteúdo junto. E o melhor: a pilha inteira Cloudflare Pages, GitHub Actions, Neon roda no free tier. Custo mensal: R$ 0.


Quick easy Tools é uma plataforma de utilitários online. Confira em quickeasy.tools.

Carregando publicação patrocinada...