GitOps casi siempre se presenta con Kubernetes de fondo: ArgoCD, Flux, manifests por centenares. El resultado es que muchos equipos que despliegan en un simple VPS con Docker Compose asumen que el modelo no va con ellos. Siguen desplegando por SSH: git pull, docker compose up -d, cruzando los dedos para que nadie se salte un paso.
Es una lástima, porque el núcleo de GitOps no tiene nada que ver con Kubernetes. Y desde hace un tiempo existe una herramienta que cubre exactamente ese hueco: doco-cd, un motor de despliegue continuo para Docker Compose. Combinado con SOPS y age para cifrar los secretos directamente en el repo, se obtiene GitOps sin Kubernetes: un pipeline completo donde desplegar se reduce a un git push. Este artículo explica el principio y luego recorre la puesta en marcha en un proyecto Django con varios ficheros compose.
GitOps, el principio sin la jerga
GitOps se resume en tres ideas:
- Git es la fuente de verdad. El estado deseado de la infraestructura (qué servicios, qué imágenes, qué configuración) está descrito en un repo. No en la cabeza del ops, no en un runbook de Notion.
- El despliegue sigue un modelo pull. Un agente corre en el servidor de destino y vigila el repo. Cuando el estado declarado cambia, el agente aplica la diferencia. Nadie empuja comandos hacia el servidor.
- Todo cambio pasa por un commit. Desplegar es mergear. Volver atrás es
git revert. El historial de despliegues es el historial de Git, con autor, fecha y diff.
El contraste con el despliegue push clásico (una CI que se conecta por SSH y ejecuta comandos) es real. En modo push, la CI posee credenciales SSH hacia producción, y el estado del servidor diverge silenciosamente en cuanto alguien ejecuta un comando a mano. En modo pull, el servidor no se abre a nadie: es él quien va a buscar la información, y reaplica el estado declarado con cada cambio.
Nada en estas tres ideas exige Kubernetes. Solo hace falta un agente capaz de leer un repo y reconciliar el estado de un host Docker. Eso es exactamente lo que hace doco-cd.
doco-cd: el modelo de ArgoCD aplicado a Docker Compose
doco-cd (Docker Compose Continuous Deployment) es un agente GitOps escrito en Go, distribuido como imagen distroless mínima, con un consumo de memoria y CPU reducido. Se presenta a sí mismo como una alternativa simple a Portainer o ArgoCD para Docker. Su funcionamiento:
- Vigila uno o varios repos Git, ya sea por webhook (el proveedor Git le notifica en cada push) o por polling (comprueba el repo a intervalos regulares).
- Con cada cambio detectado, clona el repo, lee un fichero de configuración
.doco-cd.yamlen la raíz y ejecuta el equivalente de undocker compose up -dcon los ficheros compose declarados. - Habla directamente con el socket Docker del host. Sin daemon adicional, sin base de datos.
Dos puntos lo distinguen de los scripts caseros:
El descifrado SOPS es nativo. En el momento del despliegue, doco-cd inspecciona los ficheros del proyecto (ficheros compose, ficheros .env, configs, secrets, volúmenes montados) y descifra al vuelo cualquier fichero cifrado con SOPS. La detección se basa en el contenido, no se requiere ninguna convención de nombres.
La configuración de despliegue vive en el repo de la aplicación. El fichero .doco-cd.yaml versionado junto al código describe qué desplegar y cómo. El agente en el servidor apenas tiene configuración propia.
Los secretos, el verdadero problema de GitOps
Si Git es la fuente de verdad, los secretos también tienen que estar ahí. Si no, se vuelve al problema inicial: un fichero .env depositado a mano en el servidor, fuera de todo versionado, que nadie sabe reconstruir el día que la máquina muere.
Pero commitear POSTGRES_PASSWORD=hunter2 en claro queda evidentemente descartado. La respuesta clásica del ecosistema se llama SOPS (Secrets OPerationS), una herramienta que cifra los valores de un fichero estructurado (YAML, JSON, dotenv, INI) dejando las claves legibles. El fichero cifrado sigue siendo diffable: en una PR se ve que POSTGRES_PASSWORD cambió, sin ver nunca su valor.
SOPS delega el cifrado en un backend. Históricamente PGP, pero hoy la recomendación es age: una herramienta de cifrado moderna, sin servidores de claves ni web of trust, con claves cortas y un formato mínimo. Un par de claves age cabe en dos líneas de texto.
El flujo completo queda así:
- Los desarrolladores cifran los secretos con la clave pública age. Puede commitearse, cualquiera puede cifrar.
- Solo el servidor posee la clave privada. doco-cd la usa para descifrar en el momento del despliegue.
- El repo lo contiene todo, pero no revela nada.
El proyecto de ejemplo: Django, Celery y tres ficheros compose
Tomemos un proyecto Django realista: una API con su base PostgreSQL, un worker Celery con Redis, y un fichero compose separado para la parte del worker de modo que pueda evolucionar de forma independiente. Es la topología típica en cuanto se procesan tareas asíncronas, por ejemplo el relay del patrón Transactional Outbox. La estructura:
django-shop/
├── .doco-cd.yaml # config de despliegue doco-cd
├── .sops.yaml # reglas de cifrado SOPS
├── src/ # el código Django
│ ├── manage.py
│ └── config/
└── deploy/
├── compose.yaml # web + postgres
├── compose.worker.yaml # celery + redis
└── .env # secretos, cifrado con SOPS
El fichero deploy/compose.yaml:
services:
web:
image: ghcr.io/miorg/django-shop:${IMAGE_TAG:-latest}
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000
env_file: .env
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
retries: 10
restart: unless-stopped
volumes:
pgdata:
Y deploy/compose.worker.yaml, fusionado con el primero en el despliegue:
services:
worker:
image: ghcr.io/miorg/django-shop:${IMAGE_TAG:-latest}
command: celery -A config worker --loglevel info
env_file: .env
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
restart: unless-stopped
Fíjate en el ${IMAGE_TAG:-latest}: la imagen de la aplicación la construye la CI (fuera del alcance de este artículo) y la publica en un registro. Desplegar una versión nueva consiste en cambiar IMAGE_TAG en el .env y empujar el commit. El repo describe el estado deseado, doco-cd lo aplica.
Paso 1: generar el par de claves age
En tu máquina:
age-keygen -o age.key
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
El fichero age.key contiene la clave privada (una línea que empieza por AGE-SECRET-KEY-). Nunca entra en el repo: irá al servidor, y a un gestor de contraseñas como copia de seguridad. La clave pública que aparece en el comentario, en cambio, puede circular libremente.
Paso 2: configurar SOPS y cifrar el .env
El fichero .sops.yaml en la raíz del repo declara qué reglas de cifrado se aplican a qué rutas:
creation_rules:
- path_regex: deploy/.*\.env$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
Después se escribe el deploy/.env en claro, en local:
DJANGO_SECRET_KEY=django-insecure-cambiame
DJANGO_ALLOWED_HOSTS=shop.example.com
POSTGRES_DB=shop
POSTGRES_USER=shop
POSTGRES_PASSWORD=una-contrasena-de-verdad
CELERY_BROKER_URL=redis://redis:6379/0
IMAGE_TAG=1.4.2
Y se cifra in situ:
sops encrypt --in-place deploy/.env
El fichero sigue siendo estructuralmente un dotenv válido, pero cada valor queda sustituido por un bloque ENC[AES256_GCM,...], y SOPS añade sus metadatos (incluida la clave pública age utilizada):
POSTGRES_PASSWORD=ENC[AES256_GCM,data:8Zw1p9X...,iv:...,tag:...,type:str]
Ese fichero cifrado es el que se commitea. Para modificarlo más adelante, sops edit deploy/.env lo abre descifrado en el editor y lo vuelve a cifrar al guardar. El diff de Git muestra qué claves cambiaron, nunca los valores.
Paso 3: declarar el despliegue en .doco-cd.yaml
En la raíz del repo:
name: django-shop
working_dir: deploy
compose_files:
- compose.yaml
- compose.worker.yaml
Tres líneas de configuración útil: el nombre del proyecto compose, el directorio de trabajo y la lista de ficheros compose a fusionar. Es el equivalente del -f compose.yaml -f compose.worker.yaml que se teclearía a mano. Un tercer fichero compose (monitorización, exporters, tareas cron) se añade con una línea.
Paso 4: instalar doco-cd en el servidor
El único componente que se instala manualmente en el servidor, una sola vez. Un fichero compose dedicado:
services:
doco-cd:
image: ghcr.io/kimdre/doco-cd:latest
restart: unless-stopped
environment:
TZ: Europe/Madrid
GIT_ACCESS_TOKEN: ${GIT_ACCESS_TOKEN} # token de solo lectura sobre el repo
SOPS_AGE_KEY_FILE: /run/secrets/age_key
POLL_CONFIG: |
- url: https://github.com/miorg/django-shop.git
reference: main
interval: 180
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- data:/data
secrets:
- age_key
healthcheck:
test: ["CMD", "/doco-cd", "healthcheck"]
interval: 30s
secrets:
age_key:
file: ./age.key
volumes:
data:
Dos decisiones merecen explicación.
La clave age pasa por un secret de compose, montado en el contenedor y referenciado vía SOPS_AGE_KEY_FILE, en lugar de la variable SOPS_AGE_KEY en claro en el entorno. Un docker inspect sobre el contenedor no expondrá la clave privada.
Polling en lugar de webhook, aquí cada 180 segundos sobre la rama main. doco-cd también soporta webhooks (variable WEBHOOK_SECRET, un endpoint HTTP que exponer al proveedor Git), lo que da despliegues instantáneos. Pero el webhook obliga a exponer doco-cd en Internet detrás de un reverse proxy. Para un servidor que no tiene por qué recibir tráfico entrante del proveedor Git, el polling es más simple y sobra: una latencia de tres minutos en un despliegue rara vez es un problema.
Se arranca:
docker compose up -d
A partir de ahí, cada push a main que modifique el proyecto dispara un redespliegue. doco-cd clona el repo, detecta que deploy/.env está cifrado (el contenido contiene los marcadores SOPS), lo descifra con la clave age, interpola las variables, fusiona los dos ficheros compose y aplica el estado.
Las migraciones de Django
Un docker compose up no lanza las migraciones. Lo más simple en este modelo es integrarlas en el arranque del contenedor web, mediante un entrypoint:
#!/bin/sh
set -e
python manage.py migrate --noinput
exec "$@"
Es el compromiso habitual de los despliegues single-host: aceptable mientras solo un contenedor web arranque a la vez. Si escalas el servicio web a varias réplicas, saca las migraciones a un servicio one-shot dedicado para evitar ejecuciones concurrentes.
Lo que cambia en el día a día
El cambio parece modesto. No lo es.
Desplegar se convierte en un no-evento. Cambiar IMAGE_TAG=1.4.3, abrir una PR, mergear. Sin ventana de despliegue, sin “¿quién tiene la mano sobre el servidor?”.
El rollback es un revert. El estado anterior es un commit de Git. git revert, y tres minutos después el servidor ejecuta la versión antigua. Ningún procedimiento específico que documentar.
La auditoría es gratis. ¿Quién cambió la config de Redis en producción, cuándo y por qué? git log deploy/. La pregunta “¿qué está corriendo en prod?” tiene una respuesta exacta: lo que describe main.
El servidor se vuelve reemplazable. Aprovisionar un servidor nuevo se reduce a instalar Docker, depositar la clave age y arrancar doco-cd. Todo lo demás se deriva del repo.
Los límites que conviene conocer
doco-cd gestiona un host Docker (o un clúster Swarm), no una flota. Sin scheduling multi-nodo, sin autoscaling, sin despliegue progresivo nativo: si necesitas eso, es la señal de que Kubernetes y ArgoCD empiezan a ser pertinentes. El interés de doco-cd es precisamente no pagar ese coste mientras no haga falta.
La clave age privada en el servidor es el punto sensible del montaje. Quien la posea puede descifrar todos los secretos del repo, incluidos los del historial de Git. Dos consecuencias prácticas: restringir el acceso al servidor como se restringiría el acceso a un vault, y aceptar que una rotación de clave no borra el pasado (un secreto comprometido se cambia, no se “descifra” retroactivamente).
Por último, SOPS cifra ficheros, no accesos. No hay control de acceso por secreto, ni registro de consultas, ni generación dinámica de credenciales. Para un equipo de tres personas en un VPS, es exactamente el nivel de complejidad adecuado. Para necesidades más finas, doco-cd también sabe consultar gestores de secretos externos (OpenBao, AWS Secrets Manager, Bitwarden, 1Password), lo que ofrece una trayectoria de evolución sin cambiar de herramienta de despliegue.
Conclusión
GitOps no es una tecnología, es un contrato: Git describe el estado deseado, un agente lo aplica, todo cambio pasa por un commit. Kubernetes popularizó el modelo, pero un VPS con Docker Compose también tiene derecho a él. doco-cd aporta el agente, SOPS y age resuelven la cuestión de los secretos, y el resultado cabe en unos pocos ficheros versionados: dos compose, un .env cifrado, un .doco-cd.yaml, un .sops.yaml. El día que el proyecto supere este marco, los reflejos adquiridos (estado declarado, modelo pull, secretos cifrados en el repo) se trasladan tal cual a Flux o ArgoCD. Mientras tanto, desplegar vuelve a ser lo que siempre debió ser: un git push.
