Rexvalentia

Markr (4): infraestructura y despliegue en Dokploy

7 min de lectura

Después del motor y de la aplicación web, toca ponerlo en marcha en un servidor real. Esta entrada va de infraestructura: qué piezas hay, cómo se despliegan en Dokploy y por qué reutilicé servicios en lugar de levantar unos nuevos.

El punto de partida: un VPS de 7 € al mes

Tengo un VPS con Dokploy, una plataforma self-hosted tipo Heroku o Vercel que gestiona contenedores Docker, dominios y certificados con Traefik. Ahí viven ya este blog y otros proyectos, y hay un PostgreSQL y un Redis que comparten varios servicios.

Para Markr la regla era clara: no duplicar servicios. Ni un PostgreSQL ni un Redis nuevos, solo para esto.

Las piezas

                  Traefik (HTTPS, dominio)
                          │
                          ▼
   ┌──────────────── Compose de Markr ────────────────┐
   │  web     gunicorn + Django + WhiteNoise          │
   │  worker  Celery (correcciones) + beat (lotes)    │
   └──────────────────────┬───────────────────────────┘
                          │  dokploy-network
             ┌────────────┴─────────────┐
             ▼                          ▼
   PostgreSQL compartido        Redis compartido
   base de datos "markr"        base de datos /3
   usuario "markr"              cola "markr", prefijo "markr:"
                                         │
                                         ▼
                                  API de Anthropic
  • web: gunicorn sirviendo Django. Los estáticos los sirve la propia aplicación con WhiteNoise, compilados dentro de la imagen.
  • worker: Celery, que ejecuta las correcciones en segundo plano. Lleva también el planificador (--beat) que revisa cada dos minutos las correcciones en lote (lo cuento en la próxima entrada).
  • Una sola imagen para los dos: solo cambia el comando de arranque. Menos cosas que construir y la garantía de que web y worker ejecutan exactamente el mismo código.

Una nota sobre memoria, que era lo que hacía caer la versión de 2025: ahora el worker solo carga el modelo pequeño de spaCy y el fichero MCER, unos 400 MB por corrección simultánea. Con dos de concurrencia, alrededor de 1 GB. Ya no hay embeddings ni modelos gigantes, así que el límite de concurrencia lo marca la API, no la RAM.

Tipo de servicio en Dokploy: Docker Compose

Dokploy ofrece varios tipos de servicio y elegir mal da problemas:

  • Application levanta un solo contenedor: tendríamos la web, pero no el worker.
  • Stack (Docker Swarm) no permite construir la imagen desde el repositorio.
  • Compose → Docker Compose es el correcto: construye la imagen y levanta los dos servicios.

Además hay que dejar desactivado "Isolated Deployments": el worker necesita estar en la red dokploy-network para llegar al PostgreSQL y al Redis compartidos. El dominio se asigna al servicio web, puerto 8000, con HTTPS de Let's Encrypt.

services:
  web:
    build: .
    command: ["sh", "/app/docker/web.sh"]
    expose: ["8000"]
    env_file: .env
    restart: unless-stopped
    networks: [dokploy-network]

  worker:
    build: .
    command: ["sh", "/app/docker/worker.sh"]
    env_file: .env
    restart: unless-stopped
    networks: [dokploy-network]

networks:
  dokploy-network:
    external: true

Para probar el despliegue completo en local hay un segundo fichero, docker-compose.local.yml, que añade un PostgreSQL y un Redis propios. Con él se comprobó el circuito entero (registro, corrección viajando de la web a Redis, al worker y de vuelta a PostgreSQL) antes de subir nada al servidor.

PostgreSQL compartido: base de datos y usuario propios

Web y worker son contenedores distintos, así que SQLite queda descartado: los dos necesitan la misma base de datos. Pero el PostgreSQL lo comparten otros proyectos Django, y todos tienen tablas con los mismos nombres (auth_user, django_session, django_migrations…).

La solución es una base de datos y un usuario propios para Markr, creados una sola vez:

CREATE ROLE markr LOGIN PASSWORD '...' NOSUPERUSER NOCREATEDB NOCREATEROLE;
CREATE DATABASE markr OWNER markr;

El usuario markr no tiene privilegios de administrador y no puede ni conectarse a las bases de los demás proyectos. Se probó simulando un PostgreSQL con otro proyecto ya instalado: las migraciones funcionan con ese usuario limitado y el intento de entrar en la otra base falla con "User does not have CONNECT privilege".

Si algún día no se pudiera crear una base de datos, la alternativa es un esquema propio dentro de una existente, indicado en la propia URL:

DATABASE_URL=postgres://markr:CLAVE@HOST:5432/basededatos?options=-c%20search_path%3Dmarkr

Un detalle a vigilar en un servidor compartido es el número de conexiones: Markr mantiene unas cinco abiertas (tres procesos de gunicorn y dos del worker). Si el servidor va justo, DATABASE_CONN_MAX_AGE=0 hace que se abran y cierren en cada petición.

Redis compartido: que las tareas no se mezclen

Con Redis el riesgo es más sutil. Si dos proyectos usan Celery con la configuración por defecto, los dos escuchan la cola celery, y el worker de un proyecto puede recoger las tareas del otro. Markr se aísla en tres niveles:

  • Un número de base de datos libre en la URL (redis://...:6379/3).
  • Una cola propia, markr, en lugar de celery.
  • Un prefijo en todas sus claves, markr:. En la prueba local se ve que las claves salen como markr:_kombu.binding.markr.

Además, los resultados de las tareas no se guardan en Redis: el estado de cada corrección vive en PostgreSQL, que es donde la consulta la web.

Todo por variables de entorno

Tal como estaba al principio, la aplicación no habría funcionado en Dokploy: usaba SQLite dentro del contenedor (que se pierde en cada despliegue), no servía los estáticos y, detrás del HTTPS de Traefik, todos los formularios habrían fallado con un 403 de CSRF.

La configuración quedó así:

  • Una sola variable, DJANGO_ENV=production, activa el modo seguro: sin DEBUG, cookies seguras y la cabecera del proxy HTTPS. La imagen la trae fijada, así que es imposible arrancar en modo desarrollo por olvido.
  • Si falta algo obligatorio, no arranca y dice qué falta. Es preferible un contenedor que no levanta a uno que funciona con una clave secreta de desarrollo.
  • DATABASE_URL y CELERY_BROKER_URL configuran los servicios; CSRF_TRUSTED_ORIGINS se deduce del dominio.
  • Todo lo demás tiene valores por defecto razonables y está documentado en .env.example, con las obligatorias marcadas.
  • La imagen corre con un usuario sin privilegios.

LibreOffice para los .doc de toda la vida

Los .docx se leen con Python, pero los .doc de Word 97-2003 necesitan LibreOffice. Se instala en el Dockerfile desde los paquetes oficiales de Debian, en la variante sin interfaz gráfica y solo con el procesador de textos:

ARG INSTALL_LIBREOFFICE=true
RUN if [ "$INSTALL_LIBREOFFICE" = "true" ]; then \
      apt-get update \
      && apt-get install -y --no-install-recommends libreoffice-writer-nogui \
      && rm -rf /var/lib/apt/lists/*; \
    fi

El precio es el tamaño: la imagen pasa a unos 1,9 GB. Por eso es opcional con un argumento de construcción; sin LibreOffice, la aplicación lo detecta y pide .docx o PDF. Cada conversión usa un perfil temporal que se borra al terminar y tiene un límite de 25 segundos.

El primer despliegue: un bucle de reinicios

El primer despliegue falló, como era de esperar. El contenedor web se reiniciaba una y otra vez con una traza de Django. No era un fallo de la aplicación: era la protección actuando. DJANGO_SECRET_KEY no llegaba al contenedor, y en producción la aplicación se niega a arrancar sin ella.

Las causas típicas en Dokploy, por orden de probabilidad:

  1. Variables en el sitio equivocado. Tienen que estar en la pestaña Environment del propio servicio Compose, no en las del proyecto (esas habría que referenciarlas con ${{project.VARIABLE}}).
  2. No volver a desplegar. Dokploy escribe el .env al desplegar: hay que pulsar Deploy, no basta con Restart.
  3. El formato del valor. Y aquí hay una trampa: las claves que genera get_random_secret_key() de Django pueden contener $, y Docker Compose lo interpreta como una variable y recorta la clave.

Para generarla sin $ y sin que aparezca en pantalla:

python3 -c "import secrets; print(secrets.token_urlsafe(50), end='')" | pbcopy

Claude en producción: API, no suscripción

Durante el desarrollo, Markr llama a Claude a través de Claude Code instalado en mi máquina (claude -p), con mi sesión. No gasta créditos de la API y para probar es comodísimo. El backend se elige con MARKR_BACKEND, y el cliente de Claude Code se lanza sin herramientas, sin persistencia de sesión y sin cargar mis ajustes ni mis CLAUDE.md.

En producción, todas las correcciones fallaron. La imagen usa MARKR_BACKEND=api (en un contenedor no hay sesión de Claude Code) y la cuenta de la API no tenía saldo.

Pregunté si podía usar mi suscripción también en el servidor. Técnicamente sí, pero no: las suscripciones de Claude son para uso personal, y una web con registro abierto es dar servicio a terceros. Además, el token quedaría guardado en el servidor con acceso a toda mi cuenta, y los límites de uso se agotarían con pocos usuarios a la vez. En local, Claude Code; en producción, la API con créditos. Es solo una variable de entorno.

Eso abrió la pregunta que ocupa la siguiente entrada: si cada corrección se paga, ¿cuánto cuesta exactamente corregir una redacción, y cómo se abarata?

Esc

Pulsa Intro para buscar en todo el blog.