Más tarde que temprano, pero hace ya un tiempo, había movido todos mis entornos locales a Docker. Las dos grandes ventajas que obtuve fue el poder disponer de diferentes versiones y configuraciones del stack de software, y el poder encender y apagar los entornos a medida que necesitaba (esto hizo que el consumo de recursos bajara considerablemente en mi equipo local).

Con el correr del tiempo, el siguiente paso lógico (o razonable) era el de llevar esto a Producción (de esto hemos charlado hace unos años con Josevi). Parecería entonces que ese momento finalmente me llegó.

Como siempre, lo primero es tener un poco de contexto.

Los servidores que migré/voy a migrar corresponden a pequeños sitios en WordPress, como este blog y alcanzan hasta pequeñas aplicaciones o tiendas. Además, pensar en saltar a Kubernetes es algo que me queda extremadamente grande (por la necesidad y por el conocimiento).

Otro detalle, este blog había alcanzado un punto crítico con respecto al stack de software, por lo que en lugar de instalar un nuevo servidor desde cero, me pareció un momento oportuno para ser mi propia rata de laboratorio.

Una vez iniciado el experimento, todos los caminos me llevaron a Docker, pero de allí en más hubieron algunas variantes que exploré en alguna medida. La opción ganadora ha sido la de implementar Traefik como proxy reverso y detrás los containers que mi proyecto/sitio necesite.

A continuación, mi primer receta.

Ingredientes

  • Debian 12
  • Docker
  • Traefik

Debian es la distro que elegí como host para el servidor. La idea es tener una distro e instalación lo más limpia posible. Allí, además de algunas configuraciones de seguridad y monitoreo, instalaremos Docker.

Antes de avanzar, la única sugerencia de seguridad que haré aquí (porque dependiendo de algunas cuestiones puede ser un problema más adelante) será la de configurar UFW (al menos) de la siguiente manera:

ufw default deny incoming
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp

Más adelante volveremos sobre este tema.

Luego, con Docker levantaremos Traefik (como container) y más tarde el resto de los servicios y aplicaciones que vayamos a usar (siempre como containers).

Preparación

La siguiente receta funcionará como una guía inicial, la cual no es copia fiel de la que tengo funcionando aquí (al final del post agregaré algunos detalles adicionales).

Una vez instalado el sistema operativo host (Debian), instalamos un mínimo de paquetes relacionados con seguridad, monitoreo y mantenimiento.

apt-get install ufw fail2ban logwatch unattended-upgrades apache2-utils

Luego de aplicar al menos las configuraciones básicas para esos paquetes, nos encontraremos con un sistema operativo bastante limpio, que debería ser fácil de mantener y actualiza a lo largo del tiempo.

El segundo paso es el que hoy más nos interesa.

apt-get install docker docker-compose

Ahora si, es el turno de preparar los archivos necesarios para Traefik. Aquí necesitaremos al menos 2 archivos: traefik.yml y docker-compose.yml.

traefik.yml es el archivo de configuración de Traefik y un ejemplo básico podría ser este:

entryPoints:
  web:
    address: :80
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: :443
api:
  dashboard: true
  insecure: false
providers:
  docker:
    watch: true
    network: web
    exposedByDefault: false
accessLog:
  filePath: /var/log/access.log
  filters:
    statusCodes:
      - 400-499
    retryAttempts: true
  fields:
    names:
      StartUTC: drop
certificatesResolvers:
  letsencrypt:
    acme:
      email: tu_email@dominio.com.ar
      storage: /etc/traefik/letsencrypt/acme.json
      caServer: https://acme-v02.api.letsencrypt.org/directory
      tlsChallenge: true

¿Qué significa todo esto?.

  • En las sección entryPoints estamos definiendo los puertos en los que vamos a escuchar y estoy indicando la redirección del tráfico a HTTPS.
  • En api defino que quiero que el dashboard esté disponible/navegable.
  • providers son… son parte de la infraestructura que se utilizará. En este caso un motor de containers (Docker) (quizás quieran leer un poquito aquí).
  • El caso de accessLog es para indicar dónde estará el log de acceso al dashboard de Traefik (que luego lo mapeo hacia afuera para integrarlo con Fail2Ban).
  • certificatesResolvers es la sección en donde configuramos los certificados SSL, pero además contamos con la posibilidad de configurar la generación y renovación automática de los mismos, usando un proveedor ACME.

Hasta acá sólo hemos hecho un mínimo de configuraciones para Traefik. Ahora necesitamos la segunda parte: docker-compose.yml

version: '3'

services:
  traefik:
    restart: always
    image: traefik:2.10
    container_name: traefik
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock
      - ./letsencrypt:/etc/traefik/letsencrypt
      - ./traefik.yml:/etc/traefik/traefik.yml
      - ./log:/var/log
    labels:
      - traefik.enable=true
      - traefik.http.routers.traefik.rule=Host(`monitor.dominio.com.ar`)
      - traefik.http.routers.traefik.service=api@internal
      - traefik.http.routers.traefik.tls=true
      - traefik.http.routers.traefik.tls.certresolver=letsencrypt
      - traefik.http.routers.traefik.middlewares=auth-traefik
      - traefik.http.middlewares.auth-traefik.basicauth.users=usuario:contraseña_cifrada
    networks:
      - web

networks:
  web:
    external: true

Si ya estamos usando Docker, nos sentiremos familiarizados con el archivo. Quizás la novedad puedan ser los labels. ¿Qué necesito entender en un primer lugar?

  • En el segundo label, estoy definiendo el host al cual responderá, en este caso, el Traefik y su dashboard.
  • En la cuarta y quinta línea indicamos que usaremos TLS y agregamos también cómo se gestionará el certificado (volver a mirar el archivo traefik.yml).
  • La sexta y séptima línea es para definir una restricción de acceso con usuario y contraseña. Por este motivo instalamos apache2-utils al comienzo, porque necesitamos que esté cifrada. (Una aclaración extra, si ejecutaste algo como esto htpasswd -nb usuario mi_gran_contraseña el resulatdo será usuario:$apr1$iFr/2wdb$QDh5cppsfrN7Ex6HIfaj7/ y debemos recordar que los caracteres $ deben escaparse en el archivo docker-compose.yml)

Llega el momento de iniciar todo esto. Primero crearemos la red que hemos declarado.

docker network create web

Y ahora si, ejecutamos:

docker-compose up -d

Si todo salió bien, al acceder a la URL/host definido en el label traefik.http.routers.traefik.rule tendríamos que ver algo como esto.

Este es el dashboard de Traefik que nos permitirá visualizar las configuraciones y el estado de los containers.

Si hicimos todo bien (y esto está siendo ejecutado en internet y no en un entorno local, es probable que todo esté funcionando correctamente, incluso con un certificado válido). Al comienzo sugerí abrir los puertos 80 y 443 (a pesar que no es necesario ya que Docker se encarga de abrir esos puertos aún cuando los hayamos cerrado con UFW), pero si no los abrimos de forma explícita, el challenge del certificado no funcionará. Me costó un buen rato encontrar ese problema.

Lo que hemos (o debemos haber) logrado hasta aquí fue sumar un nuestro muy pelado/vacío servidor un proxy reverso con soporte para container y con SSL que se autorenueva. Lo siguiente será levantar los containers de mi (hipotético) sitio.

Podría decir que en términos de momentos, estamos igual que si hubiéramos instalado Apache/Nginx y PHP, pero aún nos falta configurar el vhost.

Supongamos entonces que quiero configurar un sitio en WordPress y al cual le voy a sumar PhpMyAdmin. Este podría ser el contenido del docker-compose.yml para usar.

version: "3"

networks:
  web:
    external: true
  internal:
    external: false

services:
  blog:
    image: wordpress:6.3.0-apache
    container_name: blog
    restart: always
    env_file:
      - ./blog.env
    labels:
      - traefik.enable=true
      - traefik.http.routers.blog.rule=Host(`blog.dominio.com.ar`)
      - traefik.http.routers.blog.tls=true
      - traefik.http.routers.blog.tls.certresolver=letsencrypt
      - traefik.port=80
    cap_drop:
      - SETGID
      - SETUID
    networks:
      - internal
      - web
    depends_on:
      - database

  database:
    image: mariadb:10.11
    container_name: database
    restart: always
    env_file:
      - ./blog.env
    networks:
      - internal
    labels:
      - traefik.enable=false
    volumes:
      - ./mysql:/var/lib/mysql

  phpmyadmin:
    image: phpmyadmin/phpmyadmin:latest
    container_name: phpmyadmin
    restart: unless-stopped
    env_file:
      - ./blog.env
    labels:
      - traefik.enable=true
      - traefik.http.routers.phpmyadmin.rule=Host(`phpmyadmin.dominio.com.ar`)
      - traefik.http.routers.phpmyadmin.tls=true
      - traefik.http.routers.phpmyadmin.tls.certresolver=letsencrypt
      - traefik.port=80
    networks:
      - internal
      - web
    depends_on:
      - database

volumes:
  mysql:

¿Qué hicimos aquí?

  • En primer lugar, usando la imagen oficial de WordPress, levantamos un container que viene listo para usar (habría que hacer algunos ajustes para que las imágenes o los plugins estén en un volumen diferente, pero aquí no se trata de explicar la imagen de WordPress).
  • A ese container, con los labels, le definimos un host y le especificamos que use TLS también (de la misma manera que estaba configurado para Traefik).
  • Estoy usando un archivo para asignar contraseñas (que veremos a continuación) a variables que utilizan los distintos containers que estoy usando.
  • Also parecido he hecho con el container database. Aquí al diferencia con respecto al container blog, es que database sólo estará disponible en la red internal, por lo que no será accesible desde «afuera».
  • Finalmente, el container para PhpMyAdmin. Aquí también, hago uso de la imagen oficial y le asigno valores a las variables a través del archivo blog.env que tengo en el mismo directorio que mi archivo docker-compose.yml.
  • Dado que PhpMyAdmin será accesible desde internet, le indico el domino, el uso de certificado y el acceso a la red interna y externa.

¿Qué contiene el archivo blog.env de este ejemplo?

WORDPRESS_DB_USER=wordpress
WORDPRESS_DB_PASSWORD=secure_database_password
WORDPRESS_DB_HOST=database
MYSQL_ROOT_PASSWORD=secure_database_password
MYSQL_DATABASE=wordpress
MYSQL_USER=wordpress
MYSQL_PASSWORD=secure_database_password
MYSQL_USERNAME=wordpress
PMA_HOST=database

La lista de constantes y los valores que usan los distintos containers.

Nos queda entonces el paso final: levantar nuestros nuevos containers mientras Traefik está en funcionamiento (y aún sin saber nada de estas nuevas definiciones).

docker-compose up -d

Si todo se hizo correctamente, al acceder a phpmyadmin.dominio.com.ar debería ver:

Y si accedo a blog.dominio.com.ar tendría que poder configurar mi blog y/o acceder al sitio.

En ambos casos, ambos sitios/dominios/subdominios, estarán funcionando con HTTPS y un certificado válido. El otro detalle es que Traefik hará el descubrimiento de los nuevos containers de forma automática, por lo que no necesitaremos efectuar configuraciones adicionales o reiniciar su servicio.

Notas finales

Al dockerizar la infraestructura simplifiqué el mantenimiento del stack que uso. Si mañana necesito otra versión de base de datos, o de PHP, o de lo que fuera, bastará con dar de baja el container actual y crear el nuevo. El resto de los componentes seguirán funcionando. Y lo mejor de todo, es que sería cuestión de segundos.

Para cualquier sitio que estemos manteniendo y que por el momento funcione con instalaciones nativas, quizás esta alternativa nos resulte de fácil administración, nos brinde más flexibilidad y nos acerque a la dockerización de aplicación (y quizás esto haga que el salto futuro a Kubernetes, si aplica, sea menos traumático).

En mi caso, he modelizado una configuración de Traefik que voy instalando en servidores (siempre la misma, en la cual uso variables de entorno para definir aquellos valores que deben ser distintos) y luego tengo un repositorio por proyecto con el docker-compose.yml específico (si, cada proyecto/sitio lleva ahora al menos dos repositorios, uno para el código y otro para el entorno/infraestructura).

Para finalizar, no puedo olvidarme de dar las gracias a Josevi, Matias y Manuel; que me ayudaron con el debug y el pair programming.