Este sitio llevaba años con el dominio propio enlazado a Google Sites (de la época de Google Apps). Cuando el servicio dejó de estar disponible, terminé dejando https://www.ixam.net aparcado. Decidí renovarlo y aprovechar para darle un uso real: quería algo serverless y que no dependiera de que un servicio tipo Google Sites desapareciera. Así que me lancé a intentarlo con la ayuda de ChatGPT.

Pensé que sería fácil con solo ChatGPT, pero no. Se dejaba cabos sueltos en los requisitos básicos, el troubleshooting era un bucle infinito… Al final tuve que buscar repositorios en GitHub, como https://github.com/patrickgrey/website/, y con eso y trabajo en equipo logramos terminarlo.

La sensación fue la de guiar a un subordinado con talento técnico pero lleno de malentendidos hasta que el proyecto salió adelante. De alguna manera me sorprendió que la IA haya llegado a ese nivel. Y cuando las cosas se complican, saca su lado desesperante y comete errores absurdos que ponen a prueba la paciencia. Quizá eso también sea muy humano.

Por ahora parece que funciona mejor no pretender que entienda todo el contexto tras una charla larga, sino que cuando la conversación se enreda, conviene que la persona haga un resumen, empiece un hilo nuevo y dé instrucciones frescas. Aun así, sinceramente creo que, sin la IA, yo solo no habría podido con el volumen de investigación, trabajo y aprendizaje. La productividad de la IA generativa es una locura.


Objetivo

  • Mostrar el blog en https://www.example.com/
  • Poder iniciar sesión en DecapCMS desde https://www.example.com/admin/ y crear artículos
  • Que los artículos se confirmen en GitHub y Cloudflare los despliegue de forma automática
  • Por cierto, a día de hoy no hay gastos adicionales de operación (el dominio ya estaba contratado)

0. Supuestos y estructura de carpetas

0-1. Supuestos

  • El dominio (example.com) ya está adquirido

  • El sistema operativo puede ser Windows o macOS (las órdenes se muestran para ambos)

  • Servicios a utilizar (basta con las capas gratuitas)

0-2. Estructura principal del repositorio (ejemplo)

hugo.toml
content/
  blog/
  posts/
data/
functions/
  api/
    auth.js
    callback.js
layouts/
  _default/
    baseof.html
    index.html
    list.html
    single.html
static/
  _headers
  admin/
    config.yml
    index.html
  css/
    main.css

Esta estructura cumple con los siguientes roles:

  • hugo.toml … Configuración global del sitio (sustituye baseURL por la URL de producción)
  • functions/api/*.js … Cloudflare Pages Functions (para el OAuth de GitHub en /api/auth y /api/callback)
  • layouts/_default/*.html … Plantillas de Hugo
  • static/admin/ … Pantalla y configuración de DecapCMS
  • static/css/main.css … Apariencia (CSS)
  • static/_headers … Configuración de cabeceras HTTP en Cloudflare Pages (opcional)

Nota: en este manual, los lugares que requieren sustituciones según el entorno se marcan como “puntos de reemplazo”.


1. Preparación previa

1-1. Crear una cuenta de GitHub

  • Abre GitHub en el navegador y regístrate

1-2. Crear una cuenta de Cloudflare

  • Accede a Cloudflare desde el navegador y regístrate

1-3. Instalar Git y Hugo

  • Windows (PowerShell)

    PS C:\Users\alice> winget install Git.Git
    PS C:\Users\alice> winget install Hugo.Hugo.Extended
    
  • macOS (Terminal)

    mac:~ dev$ brew install git
    mac:~ dev$ brew install hugo
    

2. Procedimiento principal (hasta el despliegue)

2-1. Preparar el repositorio local

  1. Crea un repositorio vacío en GitHub

    • Navegación: GitHub > New repository
    • Ejemplo de nombre: my-hugo-blog
  2. Clónalo en local

    • Windows

      PS C:\work> git clone https://github.com/<YOUR_GH_USERNAME>/my-hugo-blog.git
      PS C:\work> cd .\my-hugo-blog
      
    • macOS

      mac:~/work dev$ git clone https://github.com/<YOUR_GH_USERNAME>/my-hugo-blog.git
      mac:~/work dev$ cd my-hugo-blog
      
  3. Coloca todas las carpetas y archivos anteriores en la raíz de ese repositorio

Puntos de reemplazo (no lo dejes sin modificar)

  • hugo.toml

    baseURL = "https://www.example.com"   # ← Sustituye por la URL de producción (ej.: https://www.example.com)
    languageCode = "ja-jp"
    title = "Example Blog"
    publishDir = "public"
    
    [permalinks]
      blog = "/blog/:year/:month/:slug/"
    
  • static/admin/config.yml

    backend:
      name: github
      repo: <YOUR_GH_USERNAME>/my-hugo-blog   # ← Sustituye por tu repositorio en GitHub
      branch: master                          # ← Rama por defecto (main o master)
      base_url: https://www.example.com       # ← Sustituye por la URL de producción
      auth_endpoint: /api/auth                # ← Endpoint de Functions (fijo)
    media_folder: static/uploads
    public_folder: /uploads
    

Referencia: functions/api/auth.js y functions/api/callback.js pueden dejarse tal cual (usan url.origin, así que no hay valores dependientes del entorno escritos a mano).

  1. Primer commit y push

    • Windows

      PS C:\work\my-hugo-blog> git add -A
      PS C:\work\my-hugo-blog> git commit -m "Initial commit: Hugo + [DecapCMS](https://decapcms.org/) + CF Pages"
      PS C:\work\my-hugo-blog> git push -u origin master
      
    • macOS

      mac:~/work/my-hugo-blog dev$ git add -A
      mac:~/work/my-hugo-blog dev$ git commit -m "Initial commit: Hugo + [DecapCMS](https://decapcms.org/) + CF Pages"
      mac:~/work/my-hugo-blog dev$ git push -u origin master
      

2-2. Crear la aplicación OAuth de GitHub (para el inicio de sesión en DecapCMS)

  1. Navegación: GitHub > Settings > Developer settings > OAuth Apps > New OAuth App

  2. Campos:

    • Application name: [DecapCMS](https://decapcms.org/) for my-hugo-blog
    • Homepage URL: https://www.example.com
    • Authorization callback URL: https://www.example.com/api/callbackImportante
  3. Guarda los valores mostrados:

    • Client ID
    • Client Secret (genera uno nuevo y consérvalo)

Estos valores se establecen como variables de entorno en Cloudflare Pages.


2-3. Crear el proyecto de Cloudflare Pages

  1. Navegación: Panel de Cloudflare > Pages > Create a project > Connect to Git

  2. Conecta con GitHub y selecciona el repositorio my-hugo-blog

  3. Configuración de build:

    • Framework preset: None (o deja que Cloudflare lo detecte)

    • Build command: hugo

    • Build output directory: public

    • Variables de entorno:

      • HUGO_VERSION = 0.128.0 (ejemplo. Conviene que coincida con tu Hugo local)
      • GITHUB_CLIENT_ID = (valor obtenido en la sección anterior)
      • GITHUB_CLIENT_SECRET = (valor obtenido en la sección anterior)
  4. Pulsa Save and Deploy y espera el primer despliegue

    • Si todo va bien, se generará una URL de vista previa *.pages.dev

2-4. Asignar el dominio propio www.example.com

  1. Navegación: Cloudflare > Pages > (proyecto correspondiente) > Custom domains > Set up a custom domain

  2. Introduce www.example.com y añádelo

  3. Si no transferiste el dominio a Cloudflare:

    • Anota los registros DNS que muestra Cloudflare (ej.: CNAME www -> <project>.pages.dev)
    • Ve al panel del registrador donde administras el dominio y configura los registros siguiendo esa información (consulta el procedimiento de cada registrador)
  4. Una vez propagados los cambios, verifica que https://www.example.com/ se abre correctamente

Consejo: si el dominio ya está añadido en Cloudflare, basta con un clic para crear los registros DNS necesarios.


2-5. Entrar al panel de DecapCMS

  1. Abre https://www.example.com/admin/ en el navegador
  2. Haz clic en el botón “Login with GitHub” o similar para autorizar el OAuth de GitHub
  3. En el primer intento GitHub preguntará si autorizas la aplicación: pulsa Authorize

Si el inicio de sesión falla, revisa el Callback URL del OAuth y las variables de entorno en Cloudflare (GITHUB_CLIENT_ID/SECRET).


3. Operación diaria (creación de artículos, sincronización local, edición de plantillas)

3-1. Crear artículos desde el CMS

  1. Accede a https://www.example.com/admin/
  2. En el menú lateral: BlogNew blog
  3. Rellena Title, Publish Date, Description, Body
  4. Al pulsar Publish, se realiza un commit automático en GitHub → Cloudflare despliega automáticamente

Los Markdown generados se guardan en content/blog/.

3-2. Actualizar el repositorio local (después de publicar desde el CMS)

  • Windows

    PS C:\work\my-hugo-blog> git pull origin master
    
  • macOS

    mac:~/work/my-hugo-blog dev$ git pull origin master
    

Así traes las entradas recientes al entorno local y puedes modificar plantillas o CSS con seguridad.

3-3. Ajustar el diseño o las plantillas en local

  1. Inicia el servidor de desarrollo en local

    • Windows

      PS C:\work\my-hugo-blog> hugo server -D
      
    • macOS

      mac:~/work/my-hugo-blog dev$ hugo server -D
      
    • Abre http://localhost:1313/ en el navegador para comprobar

  2. Puntos típicos de cambio

    • layouts/_default/baseof.html<head>, encabezado y pie de página
    • layouts/_default/index.html … Lista de “entradas recientes” de la portada
    • layouts/_default/single.html … Cuerpo de las páginas de artículo
    • static/css/main.css … Colores, tipografías, márgenes
  3. Haz commit y push de los cambios

    • Windows

      PS C:\work\my-hugo-blog> git add -A
      PS C:\work\my-hugo-blog> git commit -m "Update theme/layouts"
      PS C:\work\my-hugo-blog> git push
      
    • macOS

      mac:~/work/my-hugo-blog dev$ git add -A
      mac:~/work/my-hugo-blog dev$ git commit -m "Update theme/layouts"
      mac:~/work/my-hugo-blog dev$ git push
      
    • Pasados unos segundos o minutos, Cloudflare despliega automáticamente

3-4. Recomendaciones sobre el uso de ramas

  • Asegúrate de que la rama por defecto (main o master) coincide entre config.yml de DecapCMS y la configuración de Cloudflare
  • Si quieres previsualizar cambios, crea un Pull Request en GitHub: Cloudflare Pages generará automáticamente un entorno de Preview

4. Consejos útiles (opcional)

4-1. Ejemplo de static/_headers (no cachear la consola de administración)

/admin/*
  Cache-Control: no-store

4-2. Robots y sitemap (según necesites)

  • Crea static/robots.txt para controlar los rastreadores
  • Puedes ampliar las salidas en hugo.toml añadiendo outputs y generar RSS o sitemap

5. Problemas frecuentes y cómo resolverlos

  • El login del CMS se queda cargando
    → Comprueba que el Callback URL de GitHub OAuth es https://www.example.com/api/callback y que las variables GITHUB_CLIENT_ID/SECRET en Cloudflare son correctas
  • La portada funciona pero los artículos dan 404
    → Asegúrate de que baseURL en hugo.toml coincide con el dominio real. Revisa en los registros de build de Cloudflare si se generó public/
  • /admin aparece en blanco
    → Comprueba que la carga de DecapCMS en static/admin/index.html no está bloqueada (por ejemplo, por extensiones del navegador). Desactiva extensiones y recarga

6. Resumen de reemplazos y ajustes (recordatorio)

Archivo/configuración Clave Valor a establecer (ejemplo)
hugo.toml baseURL https://www.example.com
static/admin/config.yml repo <YOUR_GH_USERNAME>/my-hugo-blog
static/admin/config.yml branch Ajusta a main o master según corresponda
static/admin/config.yml base_url https://www.example.com
Cloudflare Pages HUGO_VERSION Ej.: 0.128.0
Cloudflare Pages GITHUB_CLIENT_ID Client ID de la app OAuth
Cloudflare Pages GITHUB_CLIENT_SECRET Client Secret de la app OAuth
GitHub OAuth App Callback URL https://www.example.com/api/callback
Cloudflare Pages Custom domain Añade www.example.com

7. Apéndice: archivos de ejemplo (sustituye lo necesario)

7-1. functions/api/auth.js

// Cloudflare Pages Functions (/api/auth)
export async function onRequest(context) {
  const { request, env } = context;
  const client_id = env.GITHUB_CLIENT_ID;
  try {
    const url = new URL(request.url);
    const redirectUrl = new URL('https://github.com/login/oauth/authorize');
    redirectUrl.searchParams.set('client_id', client_id);
    redirectUrl.searchParams.set('redirect_uri', `${url.origin}/api/callback`);
    redirectUrl.searchParams.set('scope', 'repo user');
    redirectUrl.searchParams.set('state', crypto.getRandomValues(new Uint8Array(12)).join(''));
    return Response.redirect(redirectUrl.href, 302);
  } catch (err) {
    return new Response(String(err?.message || err), { status: 500 });
  }
}

7-2. functions/api/callback.js

function renderBody(status, content) {
  const html = `
  <script>
    const receiveMessage = (message) => {
      window.opener.postMessage(
        'authorization:github:${status}:${JSON.stringify(content)}',
        message.origin
      );
      window.removeEventListener("message", receiveMessage, false);
    }
    window.addEventListener("message", receiveMessage, false);
    window.opener.postMessage("authorizing:github", "*");
  </script>`;
  return new Blob([html]);
}

export async function onRequest(context) {
  const { request, env } = context;
  const client_id = env.GITHUB_CLIENT_ID;
  const client_secret = env.GITHUB_CLIENT_SECRET;
  try {
    const url = new URL(request.url);
    const code = url.searchParams.get('code');
    const response = await fetch('https://github.com/login/oauth/access_token', {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'user-agent': 'cf-pages-oauth', 'accept': 'application/json' },
      body: JSON.stringify({ client_id, client_secret, code }),
    });
    const result = await response.json();
    if (result.error) {
      return new Response(renderBody('error', result), { headers: { 'content-type': 'text/html;charset=UTF-8' }, status: 401 });
    }
    const token = result.access_token;
    const provider = 'github';
    return new Response(renderBody('success', { token, provider }), { headers: { 'content-type': 'text/html;charset=UTF-8' }, status: 200 });
  } catch (error) {
    return new Response(error.message, { headers: { 'content-type': 'text/html;charset=UTF-8' }, status: 500 });
  }
}

7-3. static/admin/config.yml

backend:
  name: github
  repo: <YOUR_GH_USERNAME>/my-hugo-blog
  branch: main
  base_url: https://www.example.com
  auth_endpoint: /api/auth

media_folder: static/uploads
public_folder: /uploads

collections:
  - name: 'blog'
    label: 'Blog'
    folder: 'content/blog'
    create: true
    slug: '{{slug}}'
    editor:
      preview: false
    fields:
      - { label: 'Title', name: 'title', widget: 'string' }
      - { label: 'Publish Date', name: 'date', widget: 'datetime' }
      - { label: 'Description', name: 'description', widget: 'string' }
      - { label: 'Body', name: 'body', widget: 'markdown' }


9. Puntos clave de SEO y rendimiento

Después de construir el blog, estas acciones ayudan a mejorar tanto el posicionamiento como la velocidad de carga.

  1. Configurar sitemap y robots.txt

    • Registra public/sitemap.xml, generado por Hugo, en Google Search Console
    • Añade reglas de rastreo en static/robots.txt
  2. Configurar OGP (Open Graph Protocol) y tarjetas de Twitter

    • Inserta etiquetas como <meta property="og:title"> o <meta property="og:image"> en layouts/_default/baseof.html
    • Mejora la apariencia cuando se comparte en redes sociales
  3. Convertir imágenes a WebP

    • Aprovecha resources/_gen/images/ y la canalización de Hugo para generar WebP automáticamente
    • Mejora la velocidad de carga
  4. Agregar funciones de categorías y etiquetas

    • Añade categories y tags en el Front Matter de content/blog/ y muéstralos en la plantilla como nube de etiquetas
  5. Introducir datos estructurados

    • Define información de los artículos en formato JSON-LD para aspirar a los rich results en buscadores

8. Conclusión

  • Crea el repositorio en GitHub, enlázalo con Cloudflare Pages, configura el OAuth de GitHub para DecapCMS y asigna tu dominio: con eso tendrás el entorno listo
  • Los artículos se crean desde /admin/ → se confirman en GitHub → Cloudflare despliega de forma automática
  • Ajusta plantillas y diseño en local con hugo server, y sube los cambios con Git

Así queda construido y operativo un entorno de blog serverless con Hugo + GitHub + Decap CMS + Cloudflare.

Con esto tenemos una base de sitio muy ligera en costos, pero aún falta pulir funcionalidades como la búsqueda interna, categorías o la nube de etiquetas. La comparación con otros blogs aún deja que desear, aunque es un compromiso asumible. Poco a poco seguiré ampliándolo en tándem con ChatGPT.