Problema
En entornos de homelab o servidores de producción es frecuente crear unidades systemd que invoquen contenedores Docker mediante docker compose run. El objetivo suele ser ejecutar una tarea puntual (renovación de certificados, migraciones de bases, backups, etc.).
El punto conflictivo aparece cuando el comando que se quiere ejecutar dentro del contenedor lleva flags propios (por ejemplo certbot renew --webroot-path …). systemd divide la línea de ExecStart en argumentos y los pasa directamente al binario docker. Si los flags del comando interno no están aislados, Docker los interpreta como sus propias opciones y falla con errores como:
docker: unknown flag: --webroot-path
El síntoma típico es un servicio que termina con código de salida 125 y registros que indican que Docker no reconoce los flags que, en realidad, pertenecen al proceso dentro del contenedor.
Causa
El comportamiento se debe a la parsing hierarchy de la CLI de Docker (y del plugin docker compose):
dockerrecibe todos los argumentos después dedocker.- El subcomando
compose runprocesa sus propias opciones (--rm,--no-deps, …) hasta encontrar el primer token que no empiece con-. Ese token se considera el SERVICE. - Todo lo que sigue al SERVICE se pasa a Docker como parte del comando del contenedor, pero Docker sigue aplicando su propio parser de opciones antes de delegar al subcomando
compose. - Si el primer token después del SERVICE empieza con
-, Docker lo interpreta como una opción suya y aborta.
En la práctica, cuando la línea ExecStart contiene:
/usr/bin/docker compose run --rm certbot renew --webroot-path /var/www/html …
Docker ve --webroot-path como una opción suya porque el parser no ha sido detenido antes de llegar a ella. La raíz del problema es la ausencia de un separador que indique a Docker que todos los argumentos posteriores pertenecen al proceso interno.
Solución
1. Utilizar el separador --
El estándar POSIX para indicar “fin de opciones” es --. Insertarlo justo después del nombre del SERVICE obliga a Docker a dejar de interpretar flags y a pasar el resto sin cambios al contenedor.
Ejemplo genérico:
docker compose run [OPTIONS] SERVICE -- COMMAND [ARG…]
Aplicado al caso de renovación de certificados:
/usr/bin/docker compose run --rm certbot -- renew --webroot-path /var/www/html --server https://ca.home.lab:8443/acme/acme/directory
2. Preferir la forma “exec” en la unidad
En una unidad systemd es recomendable usar la forma exec (sin /bin/sh -c) porque evita una capa extra de interpretación y permite a systemd gestionar mejor los procesos hijos. Con la forma exec, cada token se escribe tal cual y el separador -- sigue funcionando.
[Service]
Type=oneshot
ExecStart=/usr/bin/docker compose run --rm certbot -- renew --webroot-path /var/www/html --server https://ca.home.lab:8443/acme/acme/directory
3. Alternativa: script wrapper
Si la línea se vuelve demasiado larga o necesita lógica adicional (por ejemplo, comprobaciones previas), crear un pequeño script Bash y llamarlo desde ExecStart simplifica la unidad y facilita el debugging.
#!/usr/bin/env bash
set -euo pipefail
docker compose run --rm certbot -- renew \
--webroot-path /var/www/html \
--server https://ca.home.lab:8443/acme/acme/directory
Guardarlo en /usr/local/sbin/certbot-renew.sh, hacerlo ejecutable y referenciarlo:
ExecStart=/usr/local/sbin/certbot-renew.sh
4. Buenas prácticas adicionales
- Usar rutas absolutas para binarios y volúmenes; evita dependencias del
$PATH. - Definir WorkingDirectory solo si el contenedor necesita montar archivos relativos.
- Añadir
ExecStartPostpara recargar servicios dependientes (por ejemplo,nginx -s reload). - Limitar el tiempo de ejecución con
TimeoutStartSec=si la tarea puede colgarse. - Marcar la unidad como
RemainAfterExit=yescuando el proceso es de tipooneshoty deseas que el estado “active” persista.
Cuándo aplicar esta solución
- Servicios de una sola ejecución que invocan contenedores Docker y requieren pasar flags al proceso interno (certbot, alembic, mysqldump, etc.).
- Timers de systemd que disparan la unidad periódicamente (renovación de certificados, backups nocturnos).
- Entornos homelab o CI donde se prefiere evitar cron y aprovechar la gestión de dependencias de systemd.
- Casos donde el error indica “unknown flag” proveniente de Docker, pero el flag pertenece al comando dentro del contenedor.
No aplicar
- Cuando el contenedor ya está configurado con un entrypoint que acepta los flags directamente (no se necesita
docker compose run). - Si la lógica de la tarea depende de un shell complejo (pipes, redirecciones) que no pueden expresarse sin
/bin/sh -c. En ese caso, el wrapper script es la mejor opción.
Código
# Archivo: /etc/systemd/system/certbot-renew.service
[Unit]
Description=Renovación automática de certificados con Certbot en Docker
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
WorkingDirectory=/home/user/nginx-proxy
ExecStart=/usr/bin/docker compose run --rm certbot -- renew \
--webroot-path /var/www/html \
--server https://ca.home.lab:8443/acme/acme/directory
ExecStartPost=/usr/bin/docker exec nginx-proxy nginx -s reload
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
# Archivo de timer asociado
[Unit]
Description=Timer para certbot-renew.service
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
Verificación
-
Recargar daemon
sudo systemctl daemon-reload -
Iniciar la unidad manualmente
sudo systemctl start certbot-renew.service -
Comprobar estado
sudo systemctl status certbot-renew.serviceActive: active (exited)indica éxito.- En caso de error, revisa
journalctl -u certbot-renew.servicepara ver si Docker sigue reclamando flags.
-
Probar el timer
sudo systemctl start certbot-renew.timer sudo systemctl list-timers | grep certbot-renew -
Validar certificado
Accede ahttps://ca.home.laby verifica que la fecha de expir