Este blog é um site estático gerado com Astro e MDX, servido por um container Caddy numa VPS,
atrás do Cloudflare, no caminho iriry.com/blog, enquanto o site institucional continua no
Cloudflare Pages. O objetivo de negócio era um só: tráfego orgânico no domínio da empresa.
Cada decisão técnica abaixo existe por causa dele, e várias foram registradas como ADRs no
repositório.
O objetivo: autoridade no domínio da empresa
Um blog corporativo serve para duas coisas: atrair quem busca os problemas que a empresa resolve e dar autoridade ao domínio. As duas dependem de o blog somar forças com o site, e não competir com ele. Isso guiou a primeira decisão.
Por que /blog e não blog.
Subdomínio é o caminho fácil: um registro DNS apontando para outro servidor. Mas um
subdomínio tende a ser tratado como um site à parte, e os backlinks que os posts recebem
ficam lá. Em iriry.com/blog, tudo soma no mesmo domínio.
O problema: o site está no Cloudflare Pages e o blog roda numa VPS. A solução foi deixar o
Cloudflare rotear por caminho. Uma regra encaminha /blog/* para a VPS, e o resto continua no
Pages. Nenhum dos dois projetos precisou migrar.
Servir de um subdiretório tem consequências no build. No Astro:
// astro.config.mjs
export default defineConfig({
site: "https://iriry.com",
base: "/blog",
trailingSlash: "always",
});
E no container, o Caddy serve os arquivos em /srv/blog, então o caminho da URL bate com o do
disco sem reescrita:
:8080 {
encode zstd gzip
root * /srv
redir /blog /blog/ 308
@hashed path /blog/_astro/*
header @hashed Cache-Control "public, max-age=31536000, immutable"
file_server
handle_errors 404 {
rewrite * /blog/404.html
file_server
}
}
Um efeito colateral bom: como blog e site estão na mesma origem, eles compartilham o
localStorage. O aviso de cookies usa a mesma chave nos dois, então quem aceitou no site não
vê o aviso de novo no blog, e o GA4 é uma propriedade só.
Astro + MDX, e o schema como guardião de SEO
A referência que estudamos usa Hugo. Escolhemos Astro por um motivo concreto: MDX. Queríamos componentes dentro dos textos, como o CTA contextual, e o Hugo não suporta MDX. O Astro também entrega zero JavaScript por padrão, o que ajuda nos Core Web Vitals.
O recurso que mais vale, porém, são as content collections com schema. O frontmatter de todo post é validado no build:
// src/content.config.ts (trecho)
const seo = {
title: z.string().min(10).max(70),
description: z.string().min(70).max(160),
keyword: z.string(),
};
const posts = defineCollection({
loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/posts" }),
schema: ({ image }) =>
z.object({
...seo,
pubDate: z.coerce.date(),
autor: reference("autores"),
categoria: z.enum([...PILAR_IDS, "engenharia"]),
pilar: z.enum(PILAR_IDS).optional(),
// ...
}).refine((p) => p.categoria !== "engenharia" || p.pilar, {
message: "Post de Engenharia precisa de `pilar`",
path: ["pilar"],
}),
});
Na prática, um post sem description, com título longo demais para o Google ou sem autor não
chega a ser publicado: o build falha no PR. As regras editoriais deixam de depender de alguém
lembrar delas. O refine garante outra regra da nossa estratégia: todo post técnico aponta
para um dos pilares de negócio, que é para onde vai o link interno e o CTA.
Posts agendados num site estático
Site estático não tem servidor decidindo o que mostrar agora. Um post com data no futuro só aparece num build posterior. A solução tem duas partes. O filtro de publicação:
function isLive(data: { draft: boolean; pubDate: Date }) {
if (SHOW_DRAFTS) return true;
return !data.draft && data.pubDate.getTime() <= Date.now();
}
E um cron no GitHub Actions que refaz o build e o deploy todo dia às 6h de Brasília:
on:
push:
branches: [main]
schedule:
- cron: "0 9 * * *"
Escrever, revisar, fazer merge com a data certa, e o post entra no ar sozinho.
Dados estruturados ligados ao site
O site já declara a empresa em JSON-LD, com um @id estável. O blog não repete a empresa:
referencia o mesmo @id. Cada página do blog emite um grafo com Blog, BlogPosting (ou
Article nos guias), Person para o autor e BreadcrumbList, e todos apontam para a
Organization do site como publisher. Para os buscadores, blog e site são uma entidade só.
Por que trocamos o plugin de sitemap
Começamos com o plugin oficial de sitemap e trocamos por um endpoint próprio por dois motivos:
- Ele não preenchia
lastmodcom a data real de atualização dos posts. - Ele incluía páginas marcadas como
noindex, como tags e categorias ainda vazias. Mandar para o Google páginas que você pede para não indexar é um sinal contraditório.
O endpoint próprio tem cerca de 40 linhas. Ele percorre posts, guias, categorias com conteúdo
e autores e usa updatedDate ?? pubDate como lastmod.
Imagens de compartilhamento geradas no build
Cada post ganha uma imagem Open Graph de 1200×630 gerada no build com Satori e Resvg, usando as cores e fontes da marca. Duas pegadinhas apareceram:
- Satori não lê woff2. As fontes do site são variáveis em woff2. Convertemos para TTF
estático com o
instancerdo fontTools, fixando o peso usado em cada uma. - Centralização com padding. Com padding horizontal no contêiner, o texto centralizado saía deslocado para a direita, na mesma medida do padding. Tiramos o padding horizontal e demos largura fixa ao bloco do título.
Design copiado, não compartilhado
O blog segue o design system do site, que é HTML e CSS puros, sem build. Compartilhar o CSS em produção acoplaria os deploys e traria regras que o blog não usa. A saída foi um script que copia do CSS do site só as seções que o blog usa, identificadas pelos comentários de seção:
const KEEP = ["Botões", "Header", "Footer", "WhatsApp flutuante", "Aviso de cookies", "Animações de entrada", "Páginas legais"];
O arquivo gerado nunca é editado à mão. Se o design do site mudar, um comando atualiza o blog, e o script falha se uma seção esperada sumir.
O pipeline e o resultado
Todo PR passa por checagem de tipos, lint do Markdown, build, um verificador de links internos de 30 linhas e o Lighthouse CI, que reprova se SEO, acessibilidade ou boas práticas ficarem abaixo de 95. No merge, o GitHub Actions gera a imagem Docker (Node para o build, Caddy para servir), publica no GitHub Container Registry e atualiza a VPS por SSH.
No primeiro build completo, a home e a página de autor marcaram 100 em desempenho, acessibilidade, boas práticas e SEO no Lighthouse. O mérito é menos de alguma otimização esperta e mais de não colocar nada que atrapalhe: HTML estático, fontes locais com preload, quase nenhum JavaScript.
O que faríamos diferente
- Busca. Usamos a interface padrão do Pagefind. Na versão 1.5, a recomendação para novos projetos passou a ser a interface de componentes. Vale migrar quando a busca ganhar importância.
- Design copiado tem prazo de validade. A cópia é a escolha certa para dois projetos, mas se surgir um terceiro, um pacote de tokens compartilhado passa a compensar.
- Cron do GitHub. Workflows agendados são pausados depois de 60 dias sem atividade no repositório. Com quatro posts por mês isso não acontece, mas é o tipo de detalhe que derruba a publicação agendada sem aviso.
As decisões que fecham ou abrem caminhos estão registradas como ADRs, junto com um glossário dos termos editoriais. Num projeto que vai durar anos, o porquê de cada escolha vale tanto quanto o código. Para a visão de negócio de quando vale construir algo sob medida, veja o guia de software sob medida.

