abelitolll
Nota #12Blog

HTTPS con Certbot en Docker, backups y resiliencia

Con la app dockerizada y corriendo en el servidor nuevo, quedan los mismos cuidados de producción que en un servidor manual — HTTPS, backups, un flujo de despliegue y resistencia a reinicios — pero adaptados a que todo vive en contenedores.

1. Certificado dentro de Docker

En un servidor manual, Certbot instalado directamente edita Nginx solo. Aquí no puede, porque Nginx vive en un contenedor que se recrea desde cero cada vez. Así que se hace al revés: Certbot obtiene los certificados en un volumen compartido, y la configuración de Nginx los lee.

Para que Let’s Encrypt verifique que controlas el dominio, coloca un archivo en el servidor y lo consulta por HTTP. Ese es el primer ajuste.

1.1. Preparar Nginx para la verificación

En tu PC, edita nginx/default.conf y agrega este bloque antes del location /:

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

Esa es la ruta donde Certbot deja su archivo de prueba.

1.2. Agregar Certbot al compose

En docker-compose.yml, agrega este servicio después de nginx:

  certbot:
    image: certbot/certbot
    restart: unless-stopped
    volumes:
      - certbot_conf:/etc/letsencrypt
      - certbot_www:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done;'"

Ese entrypoint mantiene el contenedor vivo, intentando renovar cada 12 horas. Es el equivalente a la tarea automática que Certbot instala en un servidor manual.

Al servicio nginx, agrégale esos mismos volúmenes:

    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - certbot_conf:/etc/letsencrypt:ro
      - certbot_www:/var/www/certbot:ro

Y publica el 443:

    ports:
      - "80:80"
      - "443:443"

Por último, declara los volúmenes nuevos al final del archivo:

volumes:
  db_data:
  certbot_conf:
  certbot_www:

1.3. Subir los cambios y aplicarlos

En tu PC:

git add .
git commit -m "Preparar certbot"
git push

En el servidor:

cd ~/app
git pull
docker compose up -d

Comprueba que http://tudominio.com sigue funcionando.

2. Obtener el certificado

En el servidor:

cd ~/app
docker compose run --rm --entrypoint certbot certbot certonly --webroot -w /var/www/certbot -d tudominio.com -d www.tudominio.com

El --entrypoint certbot es imprescindible. El servicio certbot del compose tiene un entrypoint con un bucle infinito que renueva cada 12 horas; si no se sobrescribe, Docker ejecutaría ese bucle en lugar del comando y se vería “No renewals were attempted” con la terminal colgada. Los dos “certbot” seguidos no son un error: el primero es el programa a ejecutar, el segundo el nombre del servicio.

Te pedirá tres cosas:

  • Tu correo, para avisarte si un certificado va a vencer sin renovarse
  • Aceptar los términos: escribe Y
  • Compartir tu correo con EFF: N

Detrás de escena pasa lo mismo que en un servidor manual: Certbot coloca un archivo en /var/www/certbot, Let’s Encrypt entra al dominio por HTTP a buscarlo y, al encontrarlo, confirma que controlas el servidor. Por eso Nginx tenía que estar funcionando antes.

Debe terminar con “Successfully received certificate”.

El aviso final sobre configurar la renovación automática no aplica aquí: el servicio certbot del compose ya se encarga con su bucle de 12 horas.

2.1. Activar HTTPS en Nginx

A diferencia de un servidor manual, Certbot no edita la configuración acá. La escribes tú, y eso es una ventaja: queda versionada en git y se aplica sola en cualquier servidor nuevo.

En tu PC, reemplaza todo el contenido de nginx/default.conf por esto:

server {
    listen 80;
    server_name tudominio.com www.tudominio.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    http2 on;
    server_name tudominio.com www.tudominio.com;

    ssl_certificate /etc/letsencrypt/live/tudominio.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/tudominio.com/privkey.pem;

    client_max_body_size 5M;

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        limit_req zone=mizona burst=20 nodelay;

        proxy_hide_header X-Powered-By;

        proxy_pass http://app:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Ahora hay dos bloques. El del puerto 80 solo atiende la verificación de Certbot y redirige todo lo demás a HTTPS. El del 443 es el sitio real, con el certificado y las protecciones. Fíjate en que el server_name ya no es _ sino los dominios reales, igual que en un servidor manual.

Sube los cambios:

git add .
git commit -m "Activar HTTPS"
git push

En el servidor:

cd ~/app
git pull
docker compose restart nginx

Probar

Entra a https://tudominio.com y comprueba el candado. Prueba también con http:// para ver la redirección.

Si Nginx no arranca:

docker compose logs nginx --tail=30

3. Backups

Igual que en un servidor manual, esto es lo único que no se regenera. La diferencia es que ahora Postgres vive en un contenedor, así que el comando cambia.

Crea el script en el servidor:

nano /home/abel/backup-db.sh

Pega esto:

#!/bin/bash
set -e

FECHA=$(date +%Y-%m-%d_%H-%M)
DESTINO="/home/abel/backups"
PROYECTO="/home/abel/app"

mkdir -p "$DESTINO"

cd "$PROYECTO"
source .env

docker compose exec -T db pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" | gzip > "$DESTINO/todoapp_$FECHA.sql.gz"

chmod 600 "$DESTINO/todoapp_$FECHA.sql.gz"

find "$DESTINO" -name "todoapp_*.sql.gz" -mtime +7 -delete

echo "Backup completado: todoapp_$FECHA.sql.gz"

Lo nuevo frente a un servidor manual: docker compose exec -T db ejecuta pg_dump dentro del contenedor de la base. El -T desactiva la terminal interactiva, necesario para que funcione desde cron. Y source .env lee las credenciales del mismo archivo que usa Compose, así no se duplican.

Protégelo y pruébalo:

chmod 700 /home/abel/backup-db.sh
/home/abel/backup-db.sh
ls -lh /home/abel/backups

Y prográmalo:

crontab -e

Si es la primera vez, elige nano. Se abrirá un archivo lleno de comentarios. Baja hasta el final y ahí pega la línea:

0 3 * * * /home/abel/backup-db.sh >> /home/abel/backups/backup.log 2>&1

Guarda con Ctrl+O, Enter, y sal con Ctrl+X. Luego verifica:

crontab -l

Debe aparecer la línea al final.

4. El flujo de despliegue

Esto reemplaza al git pull + pm2 restart de un servidor manual. Ahora que la app va dentro de una imagen, hay que reconstruirla:

cd ~/app
git pull
docker compose up -d --build

Compose detecta qué cambió y recrea solo los contenedores afectados. Si solo se tocó la configuración de Nginx, no reconstruye la app.

Un detalle importante: ya no existe el npm install manual, porque las dependencias se instalan al construir la imagen. Si se agrega una librería nueva, el --build se encarga.

Pruébalo, para confirmar que el ciclo completo funciona: cambia algo visible en index.html desde tu PC, haz git push, y ejecuta esos tres comandos en el servidor. Debería aparecer en la web.

Comandos que vas a usar

docker compose ps                    # estado de los servicios
docker compose logs app --tail=50    # logs de la app
docker compose logs -f app           # logs en vivo (Ctrl+C para salir)
docker compose restart app           # reiniciar un servicio
docker stats                         # consumo real de memoria y CPU
docker compose down                  # apagar todo (los datos sobreviven)
docker compose up -d                 # levantar todo

Y para entrar a la base de datos:

docker compose exec db psql -U todouser -d todoapp

Una advertencia que hay que recordar

docker compose down -v

Ese -v borra los volúmenes, es decir, la base de datos completa. Nunca se ejecuta en producción. Es el comando más peligroso de Docker y el que más disgustos ha causado.

5. Que todo arranque solo tras un reinicio

El restart: unless-stopped que se puso en cada servicio levanta un contenedor si se cae. Pero si el servidor entero se reinicia (por una actualización del kernel, un corte, o un reinicio manual), esos contenedores solo vuelven si el propio Docker arranca con el sistema.

Es el equivalente al pm2 startup de un servidor manual, donde se configuraba que PM2 se iniciara con el servidor.

Verifícalo:

sudo systemctl is-enabled docker

Si responde enabled, ya está bien: la instalación oficial lo deja configurado. Si responde otra cosa:

sudo systemctl enable docker

Haz la prueba real, porque es la única forma de saberlo con certeza:

sudo reboot

La sesión SSH se cerrará. Espera un minuto, entra a https://tudominio.com y comprueba que la web volvió sola. Luego reconéctate y verifica:

ssh abel@TU_IP
cd ~/app
docker compose ps

Los tres servicios deben estar “running” sin haber hecho nada más.

Ese es el último eslabón: un sistema que se recupera solo de un reinicio, sin intervención manual.