
Durante un año y medio, este blog vivió en Vercel. El workflow era cómodo: push a main, build automático, deploy en edge, certificado SSL incluido. No había contenedores que configurar, ni servidores que mantener, ni Dockerfiles que escribir. Funcionaba y no daba problemas.
Hasta que empezamos a necesitar cosas que un PaaS no te deja hacer. Una base de datos SQLite persistente en disco. Un servicio de almacenamiento S3 propio. Headers de seguridad con control total. Y sobre todo, la posibilidad de desplegar diez proyectos en un mismo VPS sin pagar diez suscripciones independientes.
Este artículo explica cómo montamos la infraestructura actual con Dokploy, por qué lo elegimos frente a Coolify y qué ganamos (y perdimos) al dejar Vercel.
No fue una decisión ideológica. Vercel es un producto excelente y sigue siéndolo. Para proyectos frontend estáticos o Next.js sin estado propio, es difícil de superar. Pero nuestro caso se fue complicando.
El blog usa SQLite como base de datos, con un archivo .db que necesita persistir en disco entre despliegues. Vercel no ofrece almacenamiento persistente de filesystem. La alternativa es usar una base de datos externa (PlanetScale, Turso, Neon), lo que añade latencia de red a cada consulta y una dependencia más que gestionar. Con SQLite en local, la latencia de lectura es prácticamente cero.
Después llegaron más proyectos. JMO Labs, ScamDetector, un portfolio, herramientas internas. Cada uno en Vercel era un proyecto separado con sus propios límites de uso. La factura crecía linealmente con cada servicio añadido.
Y por último, el control. Vercel abstrae la infraestructura para que no tengas que pensar en ella. Eso es una ventaja hasta que necesitas pensar en ella. Headers de seguridad personalizados, certificados propios, redes internas entre servicios, cron jobs, volúmenes persistentes. Cada una de estas necesidades requería un workaround o directamente no tenía solución dentro de la plataforma.
Una vez decidimos que necesitábamos un PaaS self-hosted, las dos opciones serias eran Coolify y Dokploy.
Coolify es el proyecto más maduro. Tiene una comunidad grande, soporte para múltiples lenguajes y frameworks, builds con Nixpacks y una interfaz con más funcionalidades out-of-the-box. Si vienes de Heroku o Vercel y quieres algo que se parezca lo máximo posible, Coolify es la opción obvia.
Dokploy es más minimalista. Usa Docker Compose y Docker Swarm como base, no reinventa el sistema de builds (usas Dockerfiles estándar), y su interfaz es más simple y directa. No tiene tantas integraciones predefinidas, pero lo que hace lo hace de forma predecible.
Elegimos Dokploy por tres razones concretas.
Primero, Docker nativo. En Coolify, el sistema de builds con Nixpacks a veces genera imágenes con comportamientos inesperados. Con Dokploy escribes tu propio Dockerfile y sabes exactamente qué hay dentro de tu imagen. Si algo falla, el problema está en tu Dockerfile, no en una capa de abstracción intermedia.
Segundo, Docker Compose como ciudadano de primera clase. Si ya tienes un docker-compose.yml para desarrollo local, desplegarlo en Dokploy es copiar el contenido en su panel y darle a deploy. Coolify también soporta compose, pero Dokploy está construido alrededor de ese concepto.
Y tercero, menor complejidad operativa. Dokploy consume menos recursos del servidor y tiene menos partes móviles. En un VPS con 12 GB de RAM donde corren diez servicios, cada megabyte de overhead del panel de administración importa.
No es que Coolify sea peor. Para equipos más grandes o con necesidades más diversas (builds en múltiples lenguajes sin Docker, bases de datos gestionadas, integraciones predefinidas), Coolify puede ser la mejor opción. Para nuestro caso, un desarrollador con proyectos Docker-first, Dokploy encaja mejor.
Todo corre en un VPS con Debian, gestionado por Dokploy. La arquitectura tiene estas piezas.
Dokploy como panel de gestión. Despliega aplicaciones, gestiona dominios, configura SSL y expone los logs de cada servicio. Por debajo usa Docker Swarm para orquestar los contenedores y Traefik como reverse proxy. Como caso práctico, en desplegar OpenClaw con Docker y Dokploy lo aplicamos a un proyecto real.
Traefik se encarga del routing HTTP/HTTPS, la terminación SSL con Let's Encrypt y el balanceo de carga. Cada aplicación registra sus dominios en Traefik automáticamente a través de labels de Docker.
SQLite como base de datos del blog y de otros proyectos que necesitan persistencia local. El archivo .db vive en un bind mount persistente que sobrevive a redeploys y actualizaciones del contenedor.
PostgreSQL como base de datos relacional para los proyectos que requieren funcionalidades más avanzadas (RLS, funciones SQL, relaciones complejas). Según el proyecto, se consume a través de InsForge (nuestro BaaS self-hosted) o como instancia independiente gestionada directamente en Dokploy.
MinIO como almacenamiento S3-compatible. Corre como un servicio independiente dentro de un proyecto de infraestructura en Dokploy, accesible desde s3.josemanuelortega.me. Las imágenes de portada y los archivos subidos van ahí.
Infisical como gestor de secretos centralizado. También dentro del mismo proyecto de infraestructura, comparte la red Docker con el resto de servicios. Los contenedores se autentican internamente a través de http://infisical-backend:8080 sin exponer tráfico al exterior. Si quieres profundizar, en la gestión de secretos con Infisical lo cubrimos en detalle.
El Dockerfile del blog usa un pipeline de tres etapas. No es un capricho de optimización, cada etapa tiene un propósito claro.
La primera etapa instala las dependencias. Instala pnpm con versión fijada, copia el lockfile y ejecuta pnpm install --frozen-lockfile --ignore-scripts. El flag --ignore-scripts bloquea los scripts postinstall de todas las dependencias, cerrando el vector de supply chain que explicamos en un artículo anterior. Después, solo recompilamos los módulos nativos que necesitamos explícitamente.
FROM node:22-alpine AS deps
ARG PNPM_VERSION=10.32.0
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --ignore-scripts && \
pnpm rebuild better-sqlite3La segunda etapa es el build. Copia las dependencias de la etapa anterior, crea una base de datos temporal para que Next.js pueda pre-renderizar las páginas estáticas, ejecuta las migraciones y construye la aplicación en modo standalone.
La tercera etapa produce la imagen final, el runner. Copia solo lo estrictamente necesario del build: el bundle standalone de Next.js, los assets estáticos, las migraciones SQL y los scripts de arranque. Elimina npm, corepack y yarn del runtime porque no se necesitan en producción. La aplicación corre con un usuario sin privilegios (nextjs:1001).
FROM node:22-alpine AS runner
# Strip npm, corepack, yarn — not needed at runtime
RUN rm -rf /usr/local/lib/node_modules/npm \
/usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx \
/usr/local/bin/corepack
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
USER nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./publicEl resultado es una imagen de producción limpia, sin herramientas de desarrollo, sin dependencias innecesarias y con un usuario que no puede escalar privilegios.
El flujo de despliegue es deliberadamente simple. Sin GitHub Actions, sin pipelines de CI externos, sin registros de imágenes remotos.
Haces git push origin main desde tu máquina local.
GitHub envía un webhook a Dokploy.
Dokploy clona el repositorio, ejecuta docker build con el Dockerfile multi-stage y registra la imagen localmente.
Docker Swarm actualiza el servicio con la nueva imagen. El contenedor anterior se apaga y el nuevo arranca.
El entrypoint ejecuta tres pasos antes de iniciar la aplicación: checkpoint del WAL de SQLite, migraciones pendientes de Drizzle ORM y arranque del servidor Node.js.
#!/bin/sh
node .docker/wal-checkpoint.cjs # Limpieza del journal de SQLite
node drizzle/migrate.cjs # Migraciones pendientes
exec "$@" # node server.jsTodo el proceso tarda entre dos y cuatro minutos, dependiendo de si Docker puede reutilizar capas cacheadas. Si solo cambió código de la aplicación (no las dependencias), la etapa de pnpm install se salta completamente gracias al cache de capas de Docker.
Las imágenes de portada y los archivos subidos al blog se guardan en MinIO, un servicio de almacenamiento compatible con la API de S3 que corre en el mismo servidor.
La configuración es un docker-compose dentro del proyecto de infraestructura en Dokploy. MinIO expone el puerto 9000 para la API de S3 y el 9001 para la consola de administración. Traefik enruta cada uno a su dominio correspondiente con SSL automático. Para entender la infraestructura que hay detrás, en el stack técnico del blog lo contamos todo.
El bucket blog-media tiene política de lectura pública. Las imágenes se sirven directamente desde https://s3.josemanuelortega.me/blog-media/posts/{slug}/cover.jpg sin pasar por la aplicación. Next.js las optimiza en el componente Image usando la configuración de remotePatterns.
La alternativa sería usar Cloudflare R2, AWS S3 o un servicio similar. La ventaja de MinIO self-hosted es que no hay costes de egress, no hay límites de peticiones y los datos están en tu servidor. La desventaja es que no tienes CDN global y si el servidor cae, el almacenamiento cae con él.
Una de las razones del cambio fue tener control total sobre la postura de seguridad. En Vercel puedes añadir algunos headers, pero no todos, y la configuración CSP tiene limitaciones prácticas. Con Dokploy, la configuración de seguridad vive en next.config.ts y se despliega como parte del código.
Estos son los headers que aplicamos a todas las rutas excepto las de contenido estático.
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload" },
{ key: "Content-Security-Policy", value: csp },
];La CSP es estricta. Solo permite scripts del propio dominio y del servicio de analytics. Frames solo de YouTube y Vimeo para embeds de vídeo. Conexiones externas solo a OpenRouter para el asistente de IA y al analytics. object-src: 'none' y base-uri: 'self' por defecto.
Además del hardening HTTP, el Dockerfile aplica medidas de supply chain que detallamos en un artículo anterior: verificación de checksums en binarios descargados, bloqueo de scripts de npm durante la instalación y eliminación del package manager de la imagen final.
La autenticación del panel de administración usa Better Auth con contraseñas de mínimo 12 caracteres, soporte para 2FA con TOTP y cookies seguras (httpOnly, SameSite=strict, Secure en producción). Las sesiones expiran a los 7 días con refresco silencioso cada 24 horas. Hay rate limiting en los endpoints de autenticación y en la API de administración.
Ser honesto con los trade-offs es tan importante como explicar los beneficios. Este es el balance después de meses con el setup.
Tenemos control total sobre la infraestructura. Cada decisión de configuración está en código versionado. Los headers de seguridad, la política CSP, el Dockerfile, el entrypoint, las migraciones. Si algo falla, el historial de git tiene la respuesta.
El coste es predecible. Un VPS con 12 GB de RAM aloja el blog, MinIO, Infisical y varios proyectos más. El coste es fijo al mes, independientemente del tráfico o el número de despliegues. En Vercel, la factura escalaba con cada proyecto y cada función serverless.
SQLite corre en disco. Lecturas con latencia de microsegundos. Sin conexiones de red a bases de datos externas. Sin cold starts. El archivo .db se respalda diariamente con un cron que rota las últimas siete copias.
Los servicios se comunican por red interna. El blog habla con MinIO e Infisical a través de la red Docker interna. El tráfico nunca sale del servidor. No hay que gestionar firewalls ni listas de IP para conexiones entre servicios.
Perdimos el edge computing. Vercel despliega tu aplicación en cientos de nodos distribuidos geográficamente. Con Dokploy, todo corre en un servidor en una ubicación. Si un usuario está lejos del servidor, la latencia de red es mayor. Para un blog con audiencia principalmente española, no es un problema real. Para un servicio global sí lo sería.
Los deploys sin configuración también se fueron. En Vercel, desplegar una aplicación Next.js es conectar un repositorio y hacer push. Con Dokploy necesitas un Dockerfile, un entrypoint, bind mounts configurados, variables de entorno en el panel y entender Docker lo suficiente como para diagnosticar problemas.
No hay escalado automático. Si mañana un artículo se hace viral y el tráfico se multiplica por diez, Vercel escala automáticamente. Nuestro VPS no. Tendríamos que escalar verticalmente (más RAM, más CPU) o añadir un CDN delante. Es un riesgo asumido para el volumen de tráfico que manejamos.
El uptime ahora es responsabilidad nuestra. Si el servidor cae a las tres de la mañana, nadie lo levanta automáticamente excepto el watchdog de Docker. En Vercel, su equipo de infraestructura se encarga de eso. Aquí la responsabilidad es nuestra.
Un PaaS self-hosted no es más barato en tiempo. Es más barato en dinero, sí. Pero el tiempo que ahorras en facturación lo inviertes en configuración, mantenimiento y diagnóstico de problemas que en Vercel simplemente no existen. La ecuación solo sale a cuenta si disfrutas del proceso o si el control es un requisito real, no un capricho.
Docker Compose resultó ser el mejor contrato entre desarrollo y producción. Si tu docker-compose.yml funciona en local, funciona en Dokploy. No hay sorpresas de entorno, no hay "en mi máquina sí funciona". El contenedor es la unidad de despliegue, y punto.
Los bind mounts de SQLite necesitan atención. El directorio tiene que existir antes del primer despliegue, con los permisos correctos (1001:1001). Si los permisos están mal, el contenedor arranca, la migración falla silenciosamente y la aplicación no tiene base de datos. Es un problema que solo pasa una vez, pero es confuso la primera vez que lo ves.
Mantén Traefik fuera de tus preocupaciones diarias. Dokploy configura Traefik automáticamente para cada dominio. No intentes personalizar sus configuraciones a mano salvo que tengas una razón muy concreta. Cada vez que tocamos Traefik directamente, algo se rompió de una forma creativa.
Haz backups automáticos desde el día uno. No después de que algo falle. Nuestro cron ejecuta un checkpoint del WAL, copia la base de datos y rota las últimas siete copias. Cuesta cinco minutos de configurar y es la diferencia entre perder un artículo y perder todo.
Migrar de un PaaS gestionado a infraestructura propia no es un upgrade ni un downgrade. Es un cambio de modelo. Cambias comodidad por control, escalabilidad automática por coste predecible y abstracciones por decisiones explícitas. La clave es saber qué estás comprando con cada opción y que lo que compras sea lo que realmente necesitas.

OpenClaw no solo sirve para verificar integridad: regresión visual, monitorización de endpoints, análisis de logs, smoke tests post-deploy y auditoría de seguridad continua. Casos de uso reales para testing y QA.

Las variables de entorno en texto plano son cómodas hasta que dejan de ser seguras. Explicamos cómo desplegamos Infisical como gestor de secretos self-hosted dentro de Dokploy y cómo conectamos nuestras aplicaciones para que lean las credenciales de forma cifrada y auditable.

Cada decisión técnica tiene un porqué. Elegimos Next.js sobre Astro, SQLite sobre PostgreSQL y Docker con Dokploy sobre Vercel. Aquí explicamos las razones y lo que aprendimos por el camino.