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):

  1. docker recibe todos los argumentos después de docker.
  2. El subcomando compose run procesa sus propias opciones (--rm, --no-deps, …) hasta encontrar el primer token que no empiece con -. Ese token se considera el SERVICE.
  3. 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.
  4. 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 ExecStartPost para 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=yes cuando el proceso es de tipo oneshot y 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

  1. Recargar daemon

    sudo systemctl daemon-reload
    
  2. Iniciar la unidad manualmente

    sudo systemctl start certbot-renew.service
    
  3. Comprobar estado

    sudo systemctl status certbot-renew.service
    
    • Active: active (exited) indica éxito.
    • En caso de error, revisa journalctl -u certbot-renew.service para ver si Docker sigue reclamando flags.
  4. Probar el timer

    sudo systemctl start certbot-renew.timer
    sudo systemctl list-timers | grep certbot-renew
    
  5. Validar certificado
    Accede a https://ca.home.lab y verifica que la fecha de expir