Tutoriales de VPS
Montar un blog Ghost en un VPS (2026): Docker Compose, MySQL, HTTPS con Caddy, correo y copias de seguridad
Despliega un blog Ghost 6 con MySQL 8 en un VPS usando Docker Compose, configura HTTPS con Caddy, correo transaccional, persistencia de contenido, actualizaciones y restauración de copias, ideal para blogs personales y pequeños equipos de contenido.

Montar un blog Ghost en un VPS (2026): Docker Compose, MySQL, HTTPS con Caddy, correo y copias de seguridad
Si quieres un blog independiente que cargue rápido, tenga un panel de administración limpio y una experiencia de escritura cómoda, Ghost suele dar menos problemas que un WordPress lleno de plugins. Incluye membresías, suscripciones, temas y una API de contenido, y el frontend no arrastra un montón de scripts de administración que no se usan.
Pero Ghost no es algo que puedas levantar en un contenedor y poner en producción con tranquilidad. Un entorno de producción necesita MySQL 8, una URL pública correcta, un proxy inverso con HTTPS, un directorio de contenido persistente y copias de seguridad de la base de datos que se puedan restaurar. Si el correo no está bien configurado, los enlaces de inicio de sesión de miembros, el restablecimiento de contraseña y las invitaciones también fallarán.
Este tutorial usa la imagen oficial de Docker ghost:6-alpine y MySQL 8, gestiona los servicios con Docker Compose y deja que Caddy se encargue del HTTPS automático. El puerto 2368 de Ghost solo escucha en 127.0.0.1, y MySQL no se mapea en absoluto a la red pública del host.
Ghost es más una "herramienta de publicación de contenido" que un framework universal para crear sitios.
| Necesidad | Opción más adecuada | Motivo |
|---|---|---|
| Centrarse en escribir artículos, suscripciones de miembros, newsletters | Ghost | El editor y el sistema de membresías están integrados de forma más directa |
| Tienda, foro, ecosistema de plugins complejo | WordPress | Más opciones de plugins y temas |
| Documentación pura, coste muy bajo, sin panel de administración | Hugo / Astro | Pueden generar archivos estáticos, menor superficie de ataque |
| Necesidad de exponer contenido a una app o frontend | Ghost | API de contenido integrada, utilizable como CMS headless |
La ventaja de Ghost es que el camino de la escritura a la publicación es corto; la desventaja es que su forma de extenderse no es tan libre como la de WordPress. Cuando necesitas formularios complejos, comercio electrónico o decenas de plugins de negocio, forzar Ghost a ser un CMS universal encarece el mantenimiento.
El stack de producción oficial de Ghost requiere MySQL 8, con una base mínima de 1GB de memoria. Cuando Docker ejecuta Ghost, MySQL y Caddy en el host al mismo tiempo, recomiendo empezar con 2 núcleos, 2GB de RAM y 30GB SSD; con muchas imágenes, crecimiento de miembros o envíos de correo frecuentes, 2 núcleos y 4GB será mucho más holgado.
| Escenario | Configuración recomendada |
|---|---|
| Blog nuevo, poco tráfico | 2 núcleos 2GB, 30GB SSD |
| Varios autores, funciones de membresía, muchas imágenes | 2-4 núcleos 4GB, 50GB SSD |
| Gran archivo de contenido histórico o alta concurrencia | Desde 4 núcleos 8GB, evaluando base de datos y almacenamiento de objetos por separado |
El sistema de ejemplo es Ubuntu 24.04 LTS. Apunta con antelación los registros A / AAAA de blog.example.com al VPS, y abre solo SSH, 80 y 443 en el grupo de seguridad y en UFW.
Instala Docker Engine y el plugin Compose desde el repositorio oficial de Docker. Al terminar, confirma que usas Compose v2:
docker --version
docker compose version
Si el comando solo funciona con guion, docker-compose, significa que sigues usando el antiguo Compose v1; actualiza antes de continuar.
Crea el directorio de despliegue:
sudo mkdir -p /opt/ghost/backups
sudo chown -R "$USER":"$USER" /opt/ghost
cd /opt/ghost
Ghost usa una cuenta de base de datos dedicada; no dejes que la aplicación use la contraseña root de MySQL. Genera dos valores aleatorios distintos:
openssl rand -hex 32
openssl rand -hex 32
nano .env
chmod 600 .env
Escribe los dos valores aleatorios en .env respectivamente:
GHOST_URL=https://blog.example.com
MYSQL_ROOT_PASSWORD=替换成第一组随机值
MYSQL_PASSWORD=替换成第二组随机值
GHOST_URL debe ser la dirección HTTPS completa que los lectores visitarán finalmente. Afecta a las redirecciones del panel, las URL canónicas, los enlaces de correo y las direcciones de recursos; no pongas primero una IP y luego olvides cambiarla al publicar.
Crea /opt/ghost/compose.yaml:
services:
db:
image: mysql:8.0
container_name: ghost-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}"
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: "${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}"
volumes:
- ghost_mysql:/var/lib/mysql
healthcheck:
test:
- CMD-SHELL
- >-
mysqladmin ping -h 127.0.0.1 -uroot
-p$$MYSQL_ROOT_PASSWORD --silent
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
ghost:
image: ghost:6-alpine
container_name: ghost
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:2368:2368"
environment:
NODE_ENV: production
url: "${GHOST_URL:?set GHOST_URL in .env}"
database__client: mysql
database__connection__host: db
database__connection__port: 3306
database__connection__user: ghost
database__connection__password: "${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}"
database__connection__database: ghost
volumes:
- ghost_content:/var/lib/ghost/content
healthcheck:
test:
- CMD
- node
- -e
- >-
require('http').get('http://127.0.0.1:2368/ghost/',
r=>process.exit(r.statusCode<500?0:1))
.on('error',()=>process.exit(1))
interval: 30s
timeout: 10s
retries: 10
start_period: 60s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
ghost_mysql:
name: ghost_mysql
ghost_content:
name: ghost_content
Aquí ghost:6-alpine está fijado, de modo que las actualizaciones siguen los parches y versiones menores de Ghost 6, pero no saltan de repente a la siguiente versión mayor al reconstruir el contenedor. MySQL también está fijado a la versión mayor 8.0, porque el entorno de producción de Ghost 6 solo admite MySQL 8; no lo sustituyas por MariaDB o PostgreSQL por tu cuenta.
Antes de arrancar, haz que Compose compruebe las variables y el YAML:
docker compose config >/dev/null
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 ghost
La primera inicialización de MySQL y la migración de la base de datos de Ghost tardarán un poco. running no significa que ya esté listo; espera a que ambos servicios aparezcan como healthy en docker compose ps.
Tras instalar Caddy, edita /etc/caddy/Caddyfile:
blog.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:2368
request_body {
max_size 50MB
}
}
El reverse_proxy de Caddy establece los X-Forwarded-For, X-Forwarded-Host y X-Forwarded-Proto que Ghost necesita. Si cambias a una configuración manual de Nginx, omitir X-Forwarded-Proto https provoca fácilmente una redirección infinita.
Comprueba y carga la configuración:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo systemctl status caddy --no-pager
Si aún no dominas los certificados y la configuración multidominio, consulta la guía completa de Caddy como proxy inverso en un VPS.
Confirma que el backend de Ghost solo escucha en local:
sudo ss -lntp | grep ':2368'
curl -I http://127.0.0.1:2368
curl -I https://blog.example.com
La primera línea debe mostrar 127.0.0.1:2368, no 0.0.0.0:2368. MySQL no tiene configuración ports, y un escaneo público tampoco debería ver el 3306.
Abre en el navegador:
https://blog.example.com/ghost/
Sigue las indicaciones de la página para crear el propietario del sitio. Al terminar, comprueba tres cosas:
- La URL del sitio en Settings coincide con
GHOST_URL; - Sube una imagen de prueba y confirma que sigue mostrándose tras refrescar;
- Publica un borrador y confirma que tanto el enlace del frontend como la URL canónica usan el dominio HTTPS.
El contenido principal de Ghost está en MySQL, mientras que las imágenes, los temas, routes.yaml y otros archivos están en /var/lib/ghost/content. Si cualquiera de las dos partes no es persistente, la copia de seguridad está incompleta.
El entorno de producción de Ghost necesita correo transaccional funcional para invitaciones de personal, restablecimiento de contraseñas, registro de miembros y enlaces de inicio de sesión. El SMTP normal puede encargarse de estos correos; las newsletters masivas son otra cosa: el Ghost autoalojado actualmente solo admite el envío masivo a través de la API de Mailgun.
Añade la configuración SMTP a .env:
MAIL_FROM='My Blog <[email protected]>'
MAIL_HOST=smtp.example.com
MAIL_PORT=465
MAIL_SECURE=true
MAIL_USER=替换成SMTP用户名
MAIL_PASSWORD=替换成SMTP密码
Luego añade los siguientes campos a ghost.environment:
mail__transport: SMTP
mail__from: "${MAIL_FROM}"
mail__options__host: "${MAIL_HOST}"
mail__options__port: "${MAIL_PORT}"
mail__options__secure: "${MAIL_SECURE}"
mail__options__auth__user: "${MAIL_USER}"
mail__options__auth__pass: "${MAIL_PASSWORD}"
Reconstruye el contenedor de Ghost y revisa los registros:
docker compose up -d ghost
docker compose logs --tail=100 ghost
No te limites a probar que "el puerto SMTP es accesible". Invita de verdad a un miembro de prueba y recorre un restablecimiento de contraseña o un enlace de inicio de sesión de miembro. El dominio de envío también necesita SPF, DKIM y DMARC configurados; de lo contrario, la aplicación puede indicar éxito mientras el correo sigue cayendo en spam.
Esta configuración de Compose ofrece el blog Ghost principal, MySQL y HTTPS. Las funciones completas de Web Analytics autoalojado, ActivityPub y similares de Ghost 6 dependen de la nueva cadena de herramientas oficial de Docker Compose, aún marcada como preview, que despliega además Caddy, Tinybird y servicios relacionados.
Si solo escribes un blog, usas temas, membresías y la API de contenido, no necesitas meter todos los componentes a la fuerza por "tener todas las funciones". Cuando de verdad quieras análisis autoalojado o Social Web, evalúa por separado recursos, migración y reversión según la documentación oficial de Docker preview de Ghost; no mezcles las dos orquestaciones directamente en un mismo archivo.
Primero crea el directorio de copias de seguridad:
cd /opt/ghost
mkdir -p backups
Haz un volcado consistente con la cuenta root de MySQL y comprímelo en el host:
docker compose exec -T db sh -c \
'mysqldump --single-transaction --quick --routines --triggers \
--databases "$MYSQL_DATABASE" -uroot -p"$MYSQL_ROOT_PASSWORD"' \
| gzip > "backups/ghost-db-$(date +%F-%H%M%S).sql.gz"
Luego haz copia del volumen de contenido donde están las imágenes, los temas y la configuración de rutas:
docker run --rm \
-v ghost_content:/source:ro \
-v "$PWD/backups:/backup" \
alpine sh -c \
'tar -czf /backup/ghost-content-$(date +%F-%H%M%S).tar.gz -C /source .'
Guarda además copias cifradas de compose.yaml y .env. Si el directorio de copias sigue en el mismo VPS, un fallo del disco lo perderá todo; cópialo a otra máquina o a almacenamiento de objetos y define un periodo de retención.
Puedes probar la restauración primero en un VPS que no sea de producción. El volcado de la base de datos incluye la sentencia de creación de la base, así que detén Ghost antes de importar:
docker compose stop ghost
gzip -dc backups/ghost-db-YYYY-MM-DD-HHMMSS.sql.gz \
| docker compose exec -T db sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD"'
docker compose start ghost
La restauración del volumen de contenido sobrescribe los archivos existentes, así que debes confirmar que usas la copia correcta y un entorno de prueba. Tras restaurar, abre realmente el panel, las imágenes de los artículos y las páginas de temas; no te limites a comprobar que el comando de importación devolvió 0. Para un simulacro más completo en otra máquina, consulta la guía de simulacros de copia y restauración en VPS.
Haz copia de la base de datos y del volumen de contenido incluso antes de las actualizaciones menores:
cd /opt/ghost
docker compose pull ghost
docker compose up -d ghost
docker compose ps
docker compose logs --tail=200 ghost
Como la imagen está fijada a ghost:6-alpine, este conjunto de comandos solo actualiza dentro de Ghost 6. Cuando prepares un salto de versión mayor, actualiza primero la versión mayor actual a su última versión menor, revisa los breaking changes de Ghost y valida los temas personalizados con GScan. No cambies 6 por latest y reconstruyas el sitio de producción sin más.
La aceptación posterior a la actualización debe incluir al menos: inicio de sesión en el panel, edición de artículos, subida de imágenes, correo de inicio de sesión de miembros, portada del tema e integraciones de API. Que el contenedor no entre en bucle de reinicio solo demuestra que el proceso arrancó, no que el blog funcione por completo.
docker compose ps
docker compose logs --tail=200 db
docker compose exec db mysqladmin ping -h 127.0.0.1 -uroot -p
Primero comprueba si MySQL está healthy y luego verifica .env con la cuenta de base de datos de Ghost. Una vez inicializado el volumen de datos de MySQL, cambiar MYSQL_PASSWORD no modifica automáticamente la contraseña antigua de la base de datos; no creas que reconstruir el contenedor recrea la cuenta.
docker compose logs --tail=200 ghost
curl -I http://127.0.0.1:2368
sudo journalctl -u caddy --since '15 minutes ago' --no-pager
Si el 2368 no responde ni en local, el problema está en Ghost o MySQL; si en local funciona pero el dominio devuelve 502, revisa Caddy y el firewall. No reinstales todo el stack de inmediato.
Primero confirma que GHOST_URL en .env usa https://, y comprueba si el proxy inverso pasa X-Forwarded-Proto: https. Caddy lo gestiona por defecto; con una segunda capa de CDN u otro proxy delante, sigue revisando las cabeceras de protocolo del upstream.
El ejemplo de Caddy limita el cuerpo de la petición a 50MB. Si el archivo es mayor, confirma primero que sea razonable y luego ajusta a la vez los límites del proxy y de Ghost. No lo pongas sin límite por comodidad: un panel público aún puede agotar disco y memoria con peticiones grandes.
- Tanto
dbcomoghostmuestran healthy; - El 2368 solo escucha en
127.0.0.1, y el 3306 no tiene puerto público; - HTTP redirige automáticamente a HTTPS, sin bucles de redirección en el panel;
- Puedes crear un administrador, publicar artículos, subir imágenes y cambiar de tema;
- El correo transaccional llega de verdad y las comprobaciones SPF, DKIM y DMARC pasan;
- Ghost y MySQL se recuperan automáticamente tras reiniciar el VPS;
- La base de datos y el volumen de contenido se han copiado a otra máquina y se ha completado una prueba de restauración.
Para las comprobaciones de salud en producción, los límites de registros y los principios de reversión, sigue con la lista de configuración de producción de Docker Compose en VPS.
Si tu objetivo es centrarte en escribir y gestionar membresías, un VPS de 2 núcleos y 2GB puede ejecutar Ghost; lo que realmente determina si este blog sirve a largo plazo no es lo rápido que cargue la portada, sino si las URL, el correo, MySQL, el volumen de imágenes, las actualizaciones y la restauración funcionan juntos. Valida estas cadenas antes de migrar el dominio oficial y los artículos históricos.