Cómo desplegué este blog con Astro, Caddy, Docker y Ansible
Un recorrido paso a paso por las decisiones, el despliegue y las comprobaciones de seguridad que hay detrás de En PRE funcionaba.
Este blog no necesitaba una base de datos, un panel de administración ni un proceso de Node ejecutándose permanentemente. Necesitaba algo más sencillo: contenido versionado, una compilación reproducible, HTTPS automático y una superficie pública pequeña.
El resultado es el sitio que estás leyendo. Astro genera los archivos estáticos, Caddy los sirve y renueva los certificados, Docker encapsula tanto la construcción como la ejecución y Ansible mantiene el estado del VPS.
Esta entrada explica el proceso completo. Los ejemplos omiten deliberadamente direcciones IP, usuarios, claves, tokens y cualquier otro dato que no deba formar parte de una publicación.
1. Definir el estado final antes de elegir herramientas
Los requisitos iniciales fueron estos:
- entradas escritas en Markdown y almacenadas en Git;
- ninguna base de datos en producción;
- ningún runtime de Node instalado en el VPS;
- HTTPS y renovaciones automáticas;
- únicamente los puertos web accesibles desde Internet;
- despliegue repetible e idempotente;
- capacidad de volver a una versión anterior.
Astro encajaba bien porque el resultado de la compilación es un directorio de archivos estáticos. Caddy resolvía la parte de servidor web y TLS sin añadir un panel ni otro sistema de configuración. Docker permitía que Node existiera únicamente durante la compilación.
La decisión importante no fue «usar Astro». Fue decidir que producción solo necesitaba servir archivos.
2. Organizar el proyecto
La fuente del blog vive dentro del mismo repositorio que describe la infraestructura del VPS:
blog/
├── Dockerfile
├── Caddyfile
├── astro.config.mjs
├── package.json
├── public/
└── src/
├── components/
├── content/posts/
├── layouts/
├── pages/
└── styles/
playbooks/
└── 15_blog.yml
roles/blog/
└── tasks/main.yml
Las entradas son ficheros Markdown con metadatos validados por una colección de contenido de Astro:
---
title: "Título de la entrada"
description: "Resumen que aparecerá en el archivo y el RSS."
publishedAt: 2026-10-07
tags: [Astro, Docker]
draft: false
---
Un borrador utiliza draft: true. La consulta que genera las páginas, las etiquetas y el RSS excluye esos documentos, así que un borrador puede viajar en Git sin aparecer públicamente.
3. Compilar y servir en una imagen multietapa
El Dockerfile tiene dos etapas. La primera instala las dependencias y ejecuta la compilación. La segunda recibe únicamente el resultado estático y la configuración de Caddy.
FROM node:24-alpine AS build
WORKDIR /app
ENV ASTRO_TELEMETRY_DISABLED=1
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY astro.config.mjs tsconfig.json ./
COPY public ./public
COPY src ./src
RUN npm run build
FROM caddy:2.10-alpine AS production
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv/site
EXPOSE 80 443
Esto deja dos propiedades útiles:
- Node y npm no forman parte de la imagen final.
- El contenido publicado es inmutable: cambiar una entrada implica construir una versión nueva.
Para el desarrollo local también se usa Docker Compose. Las dependencias se guardan en un volumen y no se instalan en el equipo anfitrión:
docker compose up --build
4. Configurar Caddy y HTTPS
Caddy sirve el directorio generado, comprime las respuestas y obtiene automáticamente certificados públicos. El subdominio www redirige al dominio canónico conservando la ruta solicitada.
Una versión resumida de la configuración es:
{
admin off
servers {
protocols h1 h2
}
}
www.example.com {
redir https://example.com{uri} permanent
}
example.com {
root * /srv/site
encode zstd gzip
file_server
}
HTTP/3 se dejó desactivado. No había una necesidad concreta que justificara publicar también UDP 443, así que el borde público se limita a TCP 80 y 443.
La configuración real añade cabeceras como HSTS, CSP, X-Content-Type-Options, Referrer-Policy y Permissions-Policy. Los datos ACME de Caddy se guardan en volúmenes Docker para que los certificados sobrevivan a la sustitución del contenedor.
5. Publicar Docker sin confiar ciegamente en UFW
Este fue el punto más delicado del despliegue.
Docker crea reglas de NAT y FORWARD cuando se publica un puerto. Dependiendo del orden de las reglas, ese tráfico puede no recorrer la política de entrada de UFW como lo haría un proceso nativo. Por eso el VPS mantiene una cadena propia delante de las reglas de Docker.
El esquema es:
Internet
│
▼
VPS-PUBLIC-IN
├── conexiones establecidas → permitir
├── destino público original 80/443 → permitir
└── cualquier otro tráfico nuevo → descartar
Hay un detalle fácil de pasar por alto. Docker aplica DNAT antes de llegar a FORWARD. Un contenedor publicado como 8080:80 ya aparece con destino 80 dentro de esa cadena. Una regla basada solamente en --dport 80 haría público también ese contenedor.
La solución es comprobar el puerto de destino original guardado por conntrack:
iptables -A VPS-PUBLIC-IN \
-p tcp \
-m conntrack --ctstate NEW --ctorigdstport 80 \
-j RETURN
iptables -A VPS-PUBLIC-IN \
-p tcp \
-m conntrack --ctstate NEW --ctorigdstport 443 \
-j RETURN
Después de aplicarlo se verificó que el blog respondía, mientras otros puertos publicados por Docker seguían inaccesibles desde Internet.
6. Resolver un conflicto inesperado con k3s
La primera petición HTTP no llegó a Caddy. El servicio estaba sano, Docker mostraba 80 y 443 publicados y la regla de firewall existía, pero los contadores NAT de Docker permanecían en cero.
La inspección del ruleset reveló que el Traefik incluido por defecto en k3s había registrado antes la IP pública y los puertos 80/443. No existía ningún Ingress ni carga de usuario: el clúster estaba instalado para pruebas futuras, pero estaba interceptando el tráfico real.
Como k3s no alojaba ningún servicio, quedó instalado pero detenido y deshabilitado. Su configuración conserva además Traefik desactivado para evitar que vuelva a reclamar el borde web cuando el clúster se reactive:
k3s_enabled: false
k3s_disable_components:
- traefik
No se borraron el binario ni los datos del clúster. Reactivarlo será un cambio explícito de estado, no una reinstalación improvisada.
7. Convertir el despliegue en estado declarativo
El rol de Ansible realiza cuatro operaciones principales:
- copia la fuente a un directorio de versión;
- construye una imagen Docker con un tag inmutable;
- arranca el contenedor con los volúmenes persistentes de Caddy;
- espera a que el
healthchecksea satisfactorio.
Cada publicación incrementa una variable de versión:
blog_enabled: true
blog_image_version: "YYYY.MM.DD-N"
El directorio de construcción y el tag de la imagen comparten esa versión. Esto permite volver atrás seleccionando una versión anterior y reaplicando el playbook.
El contenedor limita también el crecimiento de logs:
log_driver: json-file
log_options:
max-size: 10m
max-file: "3"
8. Verificar desde fuera
Una ejecución correcta de Ansible no demuestra por sí sola que el servicio sea alcanzable ni que los demás hayan quedado protegidos. La validación se hizo desde una red externa.
# HTTP debe redirigir a HTTPS
curl -I http://example.com
# HTTPS debe responder y validar el certificado
curl -I https://example.com
# www debe redirigir al dominio canónico
curl -I https://www.example.com/ruta-de-prueba
# Los paneles internos deben seguir sin responder públicamente
curl --connect-timeout 5 http://PUBLIC_IP:INTERNAL_PORT
También se comprobaron:
- el estado saludable del contenedor;
- la emisión del certificado de producción;
- las cabeceras de seguridad;
- el RSS y el sitemap;
- la ausencia de exposición en otros puertos;
- una segunda ejecución de Ansible sin cambios.
La última comprobación importa: si una segunda ejecución reconstruye o reinicia todo sin motivo, todavía no tenemos una buena definición del estado deseado.
9. Cómo se publica a partir de ahora
El flujo cotidiano queda reducido a esto:
- crear o editar una entrada Markdown;
- levantar la previsualización con Docker;
- construir la imagen local como validación;
- incrementar la versión declarada;
- aplicar el playbook;
- comprobar el resultado desde Internet.
No hay un panel de administración que proteger, ni plugins que actualizar dentro de una base de datos, ni credenciales editoriales en producción. La complejidad que permanece —Docker, Caddy, firewall y Ansible— responde a límites concretos y está documentada junto al código que la aplica.
Eso era exactamente lo que buscaba: un blog pequeño por fuera y explícito por dentro.