Despliegue continuo · Claude + GitHub Actions + WordPress
Deja de copiar y pegar comandos: despliega tu WordPress automáticamente con Claude y GitHub Actions
Si programas con ayuda de una IA y todavía subes archivos con WinSCP y ejecutas scripts en PuTTY, esta guía es para ti: un pipeline donde la IA programa, GitHub despliega y tú solo revisas el resultado en la web. Arquitectura completa, seguridad explicada y comandos para replicarlo.
El problema: tú eres el cable entre la IA y el servidor
Es el ciclo habitual al programar con cualquier IA: le pides un cambio, te da el código, lo descargas, lo subes al servidor con WinSCP, entras por PuTTY a ejecutar el script, copias lo que sale en la terminal y lo pegas de vuelta en el chat para que te diga el siguiente paso. Cada cambio pequeño son siete pasos manuales, y en cada uno puedes equivocarte de carpeta, pegar un bloque incompleto o perder el hilo.
La IA escribe el código, pero quien lo mueve de un lado a otro eres tú. Ese es el cuello de botella.
Sin automatizar · por cada cambio
- Pides el cambio en el chat
- La IA te da el código
- Lo descargas a tu PC
- Lo subes con WinSCP
- Lo ejecutas en PuTTY
- Copias la salida de la terminal
- La pegas en el chat y vuelta a empezar
Con este pipeline · por cada cambio
- Le dices a Claude qué necesitas
- Claude escribe el código y lo sube a GitHub
- GitHub Actions lo despliega solo en el VPS
- Revisas el resultado en tu web
Qué vas a montar
Tiene nombre en la industria: un pipeline de despliegue continuo (la «CD» de CI/CD). Cada vez que el código cambia en la rama principal de GitHub, un proceso automático lo lleva al servidor sin intervención humana. Esta guía le suma una segunda pieza que casi nunca aparece en tutoriales de WordPress: migraciones de base de datos con WP-CLI, para que los cambios de contenido —categorías, extractos, ajustes— también viajen por el mismo camino y queden registrados en git.
Las piezas son tres: Claude (la IA de Anthropic) con acceso a tu repositorio de GitHub, GitHub Actions como motor de despliegue y tu VPS con una puerta de entrada diseñada para hacer una sola cosa.
El recorrido de un cambio
Así viaja un cambio desde que lo pides hasta que aparece en la web. Claude nunca toca el servidor directamente: su acceso termina en GitHub.
git pushmaingit pull + migraciones WP-CLILas piezas y dónde vive cada una
| Pieza | Qué hace | Dónde vive |
|---|---|---|
| Repositorio | Guarda la carpeta wp-content: el tema hijo, los scripts de despliegue y las migraciones | GitHub (privado) |
| Workflow | Define cuándo desplegar y cómo conectarse al servidor | .github/workflows/deploy.yml en el repo |
| Secrets | IP, puerto, llave privada y huella del servidor, cifrados | Configuración del repo en GitHub |
| Usuario de despliegue | La única puerta por la que entra GitHub; no tiene contraseña ni shell | VPS |
| Script de despliegue | Hace el git pull, corrige permisos y corre las migraciones pendientes | VPS, fuera del repo, propiedad de root |
| Migraciones | Scripts WP-CLI numerados que cambian la base de datos una sola vez | ops/migrations/ en el repo |
El workflow de GitHub Actions
Este es el archivo completo. Se dispara solo cuando cambia el tema o los scripts de ops/, y también se puede lanzar a mano desde la pestaña Actions. Los valores sensibles nunca aparecen en el archivo: se leen de los secrets.
name: Deploy theme to VPS
on:
push:
branches: [main]
paths:
- 'themes/mi-tema-hijo/**'
- 'ops/**'
workflow_dispatch:
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Configurar SSH
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "${{ secrets.VPS_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Desplegar en el VPS
run: |
ssh -i ~/.ssh/deploy_key -p "${{ secrets.VPS_PORT }}" \
-o StrictHostKeyChecking=yes -o BatchMode=yes \
deploy-github@"${{ secrets.VPS_HOST }}" deploy
- name: Verificar que el sitio responde
run: |
code=$(curl -s -o /dev/null -w '%{http_code}' https://tusitio.com/blog/)
test "$code" = "200"
concurrency evita que dos despliegues se pisen, StrictHostKeyChecking=yes impide conectarse a un servidor falso y el último paso hace que el job falle si la web deja de responder.
wp-content y no todo WordPress? Porque el núcleo de WordPress se actualiza solo y wp-config.php tiene las contraseñas de la base de datos. Versionar únicamente wp-content deja fuera lo que nunca debe estar en git, y además un .gitignore excluye uploads/ y los backups.Darle a una máquina la llave de tu servidor
Automatizar un despliegue significa que un sistema externo —GitHub— puede entrar a tu servidor sin que estés mirando. La pregunta correcta no es «¿funciona?», sino «¿qué es lo peor que puede pasar si esa llave se filtra?». Todo el diseño gira alrededor de que la respuesta sea «casi nada».
La idea se llama principio de mínimo privilegio: cada pieza recibe exactamente el permiso que necesita para su tarea, y ni uno más.
Ocho capas entre GitHub y tu servidor
- Secrets cifradosLa llave privada vive cifrada en GitHub. No aparece en el código ni en los logs.
- Huella del servidor verificadaGitHub solo se conecta si el servidor presenta la huella registrada (
known_hosts). Si alguien suplanta el servidor, la conexión se corta. - Usuario dedicado sin contraseñaGitHub no entra como root ni como tu usuario: entra como
deploy-github, que tiene la contraseña bloqueada y solo acepta esa llave. - Llave con
restrictSin terminal, sin túneles, sin reenvío de puertos. Aunque la llave se filtre, no sirve para abrir una sesión. - Comando forzadoLo que sea que pida quien se conecte, el servidor ejecuta siempre lo mismo: el script de despliegue. Nada más.
- sudo para un solo comando, sin argumentosEl usuario de despliegue puede elevar permisos únicamente para ese script y sin pasarle parámetros.
- Script de root fuera del repositorioQuien pueda escribir en GitHub no puede cambiar lo que corre como root. Ese script se instala a mano en el servidor.
- Migraciones como
www-dataLos cambios de base de datos corren con el mismo usuario que ya usa WordPress, nunca como root.
El error de diseño más común
La tentación es poner el script de despliegue dentro del repositorio y ejecutarlo como root. Funciona, pero tiene un problema serio: cualquiera con permiso de escritura en el repo —incluido alguien que robe un token de GitHub— puede modificar ese script y en el siguiente despliegue ejecutar lo que quiera como root. Escribir en el repo equivale a ser root en el servidor.
Además, si tu servidor tiene el acceso de root por SSH deshabilitado (PermitRootLogin no, como debería), ese diseño ni siquiera funciona. El diseño correcto es un usuario dedicado, un único comando con sudo y el script de root viviendo fuera del repo. Así, lo peor que puede hacer alguien con acceso al repo es lo mismo que ya podría hacer el código PHP del tema: actuar como www-data.
203.0.113.10, puerto 2222, usuario deploy-github, dominio tusitio.com). Reemplázalos por los tuyos y guárdalos solo en los secrets de GitHub.shred. No queda guardada en ningún lugar más que cifrada en GitHub.www-data y WordPress en /var/www/tusitio.com, con wp-content ya clonado desde GitHub. Cambia los valores de ejemplo por los tuyos.Crear el usuario de despliegue
Un usuario solo para GitHub, con la contraseña bloqueada. El último comando muestra tu configuración de SSH: si tienes AllowUsers o AllowGroups, agrega ahí el usuario nuevo.
id deploy-github 2>/dev/null || useradd -m -s /bin/bash deploy-github
passwd -l deploy-github
sshd -T | grep -iE '^(port|permitrootlogin|pubkeyauthentication|allowusers|allowgroups) '
Instalar el script de despliegue y la regla de sudo
Este es el corazón del sistema. Hace tres cosas: git pull como root, devolver a www-data los archivos que trajo el pull y correr una sola vez cada migración pendiente como www-data. Todo va dentro de main() para que bash lea el archivo completo antes de que el pull lo pueda cambiar.
#!/usr/bin/env bash
main() {
set -euo pipefail
umask 022
local WP_ROOT=/var/www/tusitio.com
local REPO=$WP_ROOT/wp-content
local STATE=/var/lib/sitio-deploy/applied
cd "$REPO"
local before after
before=$(git rev-parse HEAD)
git pull --ff-only origin main
after=$(git rev-parse HEAD)
# Lo que trajo el pull vuelve a ser de www-data
if [ "$before" != "$after" ]; then
local f d
while IFS= read -r -d '' f; do
chown www-data:www-data -- "$f"
d=$(dirname -- "$f")
while [ "$d" != "." ]; do chown www-data:www-data -- "$d"; d=$(dirname -- "$d"); done
done < <(git diff --name-only -z --diff-filter=AMR "$before" "$after")
fi
# Migraciones pendientes, una sola vez cada una, como www-data
mkdir -p "$(dirname "$STATE")"; touch "$STATE"
local m name
for m in $(ls "$REPO"/ops/migrations/*.sh 2>/dev/null | sort); do
name=$(basename -- "$m")
grep -qxF "$name" "$STATE" && continue
echo "=== migración $name ==="
sudo -u www-data env HOME=/tmp bash -c 'cd "$1" && bash "$2"' _ "$WP_ROOT" "$m"
echo "$name" >> "$STATE"
done
}
main "$@"
exit
Lo instalas fuera del repo, validas la regla de sudo con visudo antes de activarla (un sudoers con errores puede romper sudo) y confirmas que el usuario solo pueda correr ese comando:
install -o root -g root -m 755 sitio-deploy.sh /usr/local/sbin/sitio-deploy
echo 'deploy-github ALL=(root) NOPASSWD: /usr/local/sbin/sitio-deploy ""' > /tmp/deploy.sudoers
visudo -cf /tmp/deploy.sudoers
install -o root -g root -m 440 /tmp/deploy.sudoers /etc/sudoers.d/deploy-github
rm /tmp/deploy.sudoers
sudo -l -U deploy-github
El "" al final de la regla significa «sin argumentos». El último comando debe mostrar una sola línea con el script.
Instalar WP-CLI verificando su firma
WP-CLI es la línea de comandos oficial de WordPress. Antes de instalarlo comprobamos su SHA-512 contra el que publica el propio proyecto: si no coincide, no se instala.
curl -fsSL -o /tmp/wp-cli.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
echo "$(curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar.sha512) /tmp/wp-cli.phar" | sha512sum -c
install -o root -g root -m 755 /tmp/wp-cli.phar /usr/local/bin/wp && rm /tmp/wp-cli.phar
sudo -u www-data env HOME=/tmp wp --path=/var/www/tusitio.com core version
Crear la llave de GitHub y probar la cadena completa
La llave se autoriza con restrict (sin terminal ni túneles) y un command= que fuerza a ejecutar siempre el script. Luego obtienes la huella del servidor para GitHub y haces una prueba real, entrando por la misma vía que usará Actions.
install -d -m 700 /root/deploy-key
ssh-keygen -t ed25519 -N "" -C "github-actions" -f /root/deploy-key/id_ed25519 -q
install -d -o deploy-github -g deploy-github -m 700 /home/deploy-github/.ssh
printf 'restrict,command="sudo -n /usr/local/sbin/sitio-deploy" %s\n' \
"$(cat /root/deploy-key/id_ed25519.pub)" > /home/deploy-github/.ssh/authorized_keys
chown deploy-github:deploy-github /home/deploy-github/.ssh/authorized_keys
chmod 600 /home/deploy-github/.ssh/authorized_keys
ssh-keyscan -p 2222 203.0.113.10 # huella para el secret VPS_KNOWN_HOSTS
ssh -i /root/deploy-key/id_ed25519 -p 2222 deploy-github@127.0.0.1 deploy # prueba real
Si la prueba responde con el git pull y las migraciones, la cadena funciona. La salida se ve así (ejemplo real, sin datos sensibles):
=== git pull ===
Already up to date.
=== migración 001-categorias-blog.sh (como www-data) ===
Success: Term updated.
Success: Term updated.
Success: Created category 1251.
Success: Created category 1252.
Success: Set term.
Success: Added term.
Success: Added term.
Success: Updated post 6262.
=== migraciones aplicadas: 1 ===
Cargar los secrets en GitHub y borrar la llave del servidor
En tu repositorio: Settings → Secrets and variables → Actions → New repository secret. Ojo: es la configuración del repositorio, no la de tu cuenta.
| Secret | Valor |
|---|---|
VPS_HOST | La IP pública del servidor. La IP, no el dominio: si el dominio pasa por el proxy de Cloudflare, SSH no llega. |
VPS_PORT | Tu puerto SSH |
VPS_KNOWN_HOSTS | Todas las líneas que imprimió ssh-keyscan |
VPS_SSH_KEY | La llave privada completa, con las líneas BEGIN y END |
Con los cuatro cargados, la llave privada ya no tiene nada que hacer en el servidor:
shred -u /root/deploy-key/id_ed25519 && rm -f /root/deploy-key/id_ed25519.pub && rmdir /root/deploy-key
Por último, en la pestaña Actions del repo eliges el workflow y le das Run workflow. Si todo está bien, el despliegue pasa en verde en unos segundos.
El código no es lo único que cambia
Desplegar archivos es la mitad del trabajo. En WordPress, muchos cambios viven en la base de datos: categorías, extractos, opciones, menús. Si esos cambios se hacen a mano en el panel, se rompe la idea de tener todo automatizado y registrado.
La solución viene del desarrollo con frameworks como Laravel: migraciones. Cada cambio de base de datos es un script numerado en ops/migrations/, escrito con WP-CLI. El script de despliegue ejecuta cada migración una sola vez y guarda el registro de las aplicadas en el servidor, fuera de la web.
Migración 001: categorías del blog
En vez de entrar al panel a renombrar, crear y reasignar categorías, el cambio quedó en un archivo. Es idempotente: si se ejecutara dos veces, el resultado sería el mismo.
#!/usr/bin/env bash
# 001 — Categorías oficiales del blog. Idempotente.
set -euo pipefail
ensure_cat() { # ensure_cat <slug> <Nombre> [<categoría antigua a renombrar>]
local slug=$1 name=$2 legacy=${3:-} id
id=$(wp term list category --slug="$slug" --field=term_id)
if [ -z "$id" ] && [ -n "$legacy" ]; then
id=$(wp term list category --name="$legacy" --field=term_id)
fi
if [ -n "$id" ]; then
wp term update category "$id" --name="$name" --slug="$slug"
else
wp term create category "$name" --slug="$slug"
fi
}
ensure_cat sistemas Sistemas "Gestion de Servicios"
ensure_cat hardware Hardware "Computadoras"
ensure_cat servidores Servidores
ensure_cat software Software
wp post term set 6262 category servidores --by=slug
wp post update 6262 --post_excerpt="Serie en 2 partes con video real del proceso completo..."
Corregir también es más rápido
Con este flujo, un error deja de ser un problema de horas. Ejemplo real: al probar un filtro del blog, una categoría con un solo artículo mostraba la tarjeta estirada a todo el ancho. Basta con mandarle la captura a Claude: identifica la causa (la grilla usaba auto-fit), la cambia a auto-fill, hace el push y GitHub lo despliega. Recargas la página y está corregido. Lo mismo con contenido: una segunda migración puede reescribir extractos sin entrar al panel.
git revert, que también se despliega solo; para la base de datos, la red de seguridad sigue siendo un backup reciente (por ejemplo, WPvivid sincronizado a Google Drive) antes de cualquier migración grande.restrict y un comando forzado. Para hacer daño real necesitaría además acceso de escritura al repositorio.git revert, que también se despliega solo. Y la revisión final en la web sigue siendo tuya antes de dar algo por terminado.composer install, php artisan migrate --force y limpiar cachés, en lugar de WP-CLI.Glosario
- CI/CD
- Integración continua / despliegue continuo. Prácticas para que los cambios de código se prueben y lleguen a producción de forma automática.
- Pipeline
- La secuencia de pasos automáticos por la que pasa un cambio desde el repositorio hasta el servidor.
- GitHub Actions
- El servicio de automatización de GitHub. Ejecuta tareas en máquinas temporales cuando ocurre un evento, como un push.
- Workflow
- Archivo YAML en
.github/workflows/que define cuándo y cómo corre una automatización. - Runner
- La máquina temporal de GitHub que ejecuta el workflow y se destruye al terminar.
- Secret
- Valor cifrado guardado en la configuración del repositorio. El workflow lo usa, pero nadie puede volver a leerlo.
- Push
- Enviar commits del equipo local al repositorio remoto en GitHub.
- Fast-forward (
--ff-only) - Actualizar solo si no hay cambios en conflicto. Si el servidor tuviera cambios propios, el pull se detiene en vez de mezclar.
- SSH
- Protocolo cifrado para conectarse a un servidor remoto.
- Llave ed25519
- Tipo moderno de llave SSH: corta, rápida y segura. La privada se guarda en secreto, la pública se autoriza en el servidor.
known_hosts- Lista de huellas de servidores conocidos. Evita conectarse a un servidor que se hace pasar por el tuyo.
- Comando forzado (
command=) - Opción de
authorized_keysque obliga a una llave a ejecutar siempre el mismo comando, sin importar lo que pida. restrict- Opción de
authorized_keysque quita terminal, túneles y reenvíos a una llave. - sudoers
- Configuración que define qué usuario puede ejecutar qué comando con permisos elevados.
- Mínimo privilegio
- Principio de seguridad: cada pieza recibe solo los permisos que necesita para su tarea.
www-data- El usuario con el que corren Apache y PHP en Ubuntu. WordPress trabaja con sus permisos.
- WP-CLI
- La línea de comandos oficial de WordPress: permite gestionar posts, categorías, plugins y ajustes sin entrar al panel.
- Migración
- Script versionado que aplica un cambio a la base de datos una sola vez.
- Idempotente
- Una operación que, ejecutada varias veces, deja el mismo resultado que ejecutada una vez.
git revert- Crea un commit nuevo que deshace otro anterior, sin borrar el historial.
