0. Arquitectura y flujo de una petición
https://app.josesaid.lat
app → IP del Droplet
0.0.0.0:80 / 443
127.0.0.1:8090
127.0.0.1:8098
Spring Boot · 8080
Qué pasa en cada salto
app.josesaid.lat. El registro A devuelve la IP pública del Droplet.Host y selecciona el bloque server_name app.josesaid.lat.proxy_pass http://127.0.0.1:8090 y agrega X-Real-IP, X-Forwarded-For, X-Forwarded-Proto y X-Forwarded-Host.app.josesaid.lat y hace ProxyPass a http://127.0.0.1:8098/, conservando el Host original.ui-ms.forward-headers-strategy: framework toma la IP real del cliente y si la conexión original fue HTTP o HTTPS.mod_rewrite, autenticación, cabeceras).
127.0.0.1:8098 sin tocar Docker.
1. Crear la cuenta de DigitalOcean
Los nombres de botones de la consola de DigitalOcean pueden cambiar con el tiempo. Si algo no coincide, busca la función por nombre en el menú.
EMPRESA_JOSESAID para encontrarlo fácil.
2. Crear la llave SSH
Usaremos llaves SSH en lugar de contraseñas para entrar al servidor. Hazlo en tu computadora local.
2.1 Generar el par de llaves
# macOS / Linux (en la terminal) ssh-keygen -t ed25519 -C "josesaid-droplet" -f ~/.ssh/id_ed25519_do # Te pedirá una passphrase. Ponla y guárdala en tu gestor de contraseñas.
2.2 Ver la llave pública
cat ~/.ssh/id_ed25519_do.pub
Copia toda la línea que empieza con ssh-ed25519.
2.3 Registrarla en DigitalOcean
laptop-josesaid.~/.ssh/id_ed25519_do, sin .pub). Solo la llave pública se registra en DigitalOcean.
3. Crear el Droplet
empresa-josesaid-01.3.1 Anotar la IP
Copia la IPv4 pública del Droplet desde la lista de Droplets. La usarás en el DNS y en el acceso SSH. En esta guía la llamaremos IP_DEL_DROPLET.
TU_IP para tu IP pública actual. Consíguela con curl ifconfig.me desde tu computadora.
4. Primer acceso y endurecimiento del servidor
4.1 Entrar como root por primera vez
ssh -i ~/.ssh/id_ed25519_do root@IP_DEL_DROPLET
La primera vez te preguntará si confías en el host. Escribe yes.
4.2 Actualizar el sistema
apt update && apt upgrade -y reboot
Espera un minuto y vuelve a entrar.
4.3 Crear un usuario de despliegue
adduser deploy usermod -aG sudo deploy
4.4 Copiar la llave SSH al nuevo usuario
mkdir -p /home/deploy/.ssh cp /root/.ssh/authorized_keys /home/deploy/.ssh/ chown -R deploy:deploy /home/deploy/.ssh chmod 700 /home/deploy/.ssh chmod 600 /home/deploy/.ssh/authorized_keys
4.5 Probar el acceso con el nuevo usuario
Antes de cerrar la sesión de root, abre otra terminal en tu computadora y prueba:
ssh -i ~/.ssh/id_ed25519_do deploy@IP_DEL_DROPLET sudo whoami # debe responder: root
deploy con llave funcione. Si lo haces antes, puedes quedarte sin acceso al servidor.
4.6 Deshabilitar login de root y contraseñas
Solo ejecútalo cuando el paso 4.5 haya funcionado:
sudo nano /etc/ssh/sshd_config
Asegúrate de que estas líneas tengan estos valores:
PermitRootLogin no PasswordAuthentication no PubkeyAuthentication yes
sudo sshd -t && sudo systemctl restart ssh
A partir de ahora entra solo con ssh -i ~/.ssh/id_ed25519_do deploy@IP_DEL_DROPLET.
4.7 Zona horaria
sudo timedatectl set-timezone America/Mexico_City timedatectl
Los logs y el cron usarán tu hora local. Si prefieres UTC para los logs de producción, omite este paso.
5. Swap y ajustes del sistema
Un Droplet de 2 GB puede quedarse sin memoria durante el build de Maven o al arrancar la JVM. Un archivo de swap evita que el sistema se caiga.
sudo fallocate -l 2G /swapfile sudo chmod 600 /swapfile sudo mkswap /swapfile sudo swapon /swapfile echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab free -h
En free -h debes ver la línea Swap con 2 GB.
5.1 Ajustar la agresividad del swap
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf sudo sysctl -p /etc/sysctl.d/99-swappiness.conf
6. Instalar Docker Engine
Usamos el repositorio oficial de Docker, no el paquete de Ubuntu, que suele estar desactualizado.
6.1 Dependencias y clave del repositorio
sudo apt install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg
6.2 Agregar el repositorio e instalar
echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
6.3 Permitir a tu usuario usar Docker sin sudo
sudo usermod -aG docker deploy
Cierra la sesión SSH y vuelve a entrar para que el grupo tome efecto.
6.4 Verificar
docker --version docker compose version docker run --rm hello-world
Si hello-world imprime un mensaje, Docker funciona.
6.5 Limitar el tamaño de los logs de contenedores
Evita que los logs de Docker llenen el disco.
sudo nano /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
sudo systemctl restart docker
7. Registros DNS
En el proveedor donde administras josesaid.lat, crea estos registros apuntando a IP_DEL_DROPLET:
| Tipo | Nombre (host) | Valor | TTL |
|---|---|---|---|
| A | @ (josesaid.lat) | IP_DEL_DROPLET | 3600 |
| A | www | IP_DEL_DROPLET | 3600 |
| A | app | IP_DEL_DROPLET | 3600 |
Si prefieres administrar el DNS en DigitalOcean, crea el dominio en Networking → Domains y apunta los nameservers del registrador a DigitalOcean. Los registros son los mismos.
7.1 Verificar la propagación
dig +short josesaid.lat dig +short app.josesaid.lat
Ambos deben devolver IP_DEL_DROPLET. Si no, espera y vuelve a intentar.
8. Firewall (UFW)
Ejecuta esto dentro del Droplet, como deploy.
TU_IP) está correcta. Si UFW bloquea tu SSH, pierdes el acceso y tendrás que usar la consola web de DigitalOcean.
sudo apt install -y ufw sudo ufw default deny incoming sudo ufw default allow outgoing # SSH solo desde tu IP sudo ufw allow from TU_IP to any port 22 proto tcp # Web público sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable sudo ufw status verbose
9. Mapa de puertos
| Puerto | Enlace | Servicio | Exposición |
|---|---|---|---|
| 22 | 0.0.0.0 | SSH | Solo tu IP |
| 80 | 0.0.0.0 | Nginx (HTTP) | Público |
| 443 | 0.0.0.0 | Nginx (HTTPS, tras Certbot) | Público |
| 8090 | 127.0.0.1 | Apache | Solo localhost |
| 8098 | 127.0.0.1 | Docker → ui-ms (127.0.0.1:8098:8080) | Solo localhost |
| 8080 (dentro del contenedor) | red interna de Docker | Spring Boot (ui-ms) | No se publica al host |
10. Subir el código y preparar ui-ms
10.1 Clonar el repositorio
Si el repositorio es privado, crea una llave de deploy o un token en GitHub. La forma más simple es una llave SSH de solo lectura:
# En el Droplet, como deploy ssh-keygen -t ed25519 -C "droplet-deploy" -f ~/.ssh/id_ed25519_github cat ~/.ssh/id_ed25519_github.pub
Agrega esa llave pública en GitHub: Repositorio → Settings → Deploy keys → Add deploy key. Deja Allow write access desmarcado.
cat >> ~/.ssh/config <<'EOF' Host github.com IdentityFile ~/.ssh/id_ed25519_github IdentitiesOnly yes EOF chmod 600 ~/.ssh/config git clone git@github.com:TU_USUARIO/EMPRESA_JOSESAID.git ~/EMPRESA_JOSESAID cd ~/EMPRESA_JOSESAID git log --oneline -n 5
10.2 Estructura esperada
~/EMPRESA_JOSESAID/
└── ui-ms/
├── Dockerfile
├── docker-compose.yml
├── .env ← NO va en git; se crea en el servidor
├── pom.xml
└── src/
Dockerfile y el docker-compose.yml deben estar en la misma carpeta que pom.xml.
10.3 Verificar que Spring Boot compila localmente
Antes de dockerizar, verifica en tu computadora que el proyecto compila con Java 21:
cd ui-ms ./mvnw -B clean package -DskipTests ls target/*.jar
11. Dockerfile de ui-ms
Archivo: ui-ms/Dockerfile
Este Dockerfile hace un build multi-etapa. La primera etapa compila con Maven y la segunda solo copia el JAR a una imagen liviana de Java 21.
# ---- Etapa 1: build con Maven ---- FROM maven:3.9-eclipse-temurin-21 AS build WORKDIR /src # Cachear dependencias primero para acelerar builds siguientes COPY pom.xml . RUN mvn -B -q dependency:go-offline COPY src ./src RUN mvn -B -q clean package -DskipTests # ---- Etapa 2: runtime ---- FROM eclipse-temurin:21-jre WORKDIR /app # Usuario sin privilegios RUN groupadd --system app && useradd --system --gid app --uid 10001 app COPY --from=build /src/target/*.jar /app/app.jar USER app EXPOSE 8080 # Límite de memoria de la JVM: 75% del límite del contenedor ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError" ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
exec: sin él, la JVM no recibe las señales de Docker (SIGTERM) y los apagados tardan 10 segundos o fallan.
mvnw o tiene varios módulos Maven, el paso COPY pom.xml cambia. Compártelo para ajustarlo.
12. docker-compose.yml y variables de entorno
12.1 Compose de ui-ms
Archivo: ui-ms/docker-compose.yml
services:
ui-ms:
build:
context: .
dockerfile: Dockerfile
image: empresa/ui-ms:latest
container_name: ui-ms
restart: unless-stopped
env_file:
- .env
environment:
SERVER_PORT: 8080
ports:
- "127.0.0.1:8098:8080" # Solo localhost: Apache es el único que llega aquí
mem_limit: 1g
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
mem_limit: 1g: ajústalo según el plan del Droplet. En un Droplet de 2 GB, 1 GB para ui-ms deja espacio para Nginx, Apache y el sistema. Con MaxRAMPercentage=75, la JVM usará como máximo unos 768 MB de heap.
12.2 Archivo .env con las variables reales
Archivo: ui-ms/.env (en el servidor, nunca en git)
# Crear el archivo y restringir permisos cd ~/EMPRESA_JOSESAID/ui-ms nano .env chmod 600 .env
# ---- Perfil de Spring ---- SPRING_PROFILES_ACTIVE=PERFIL_REAL_DEL_PROYECTO # ---- Base de datos ---- SPRING_DATASOURCE_URL=jdbc:postgresql://HOST:5432/NOMBRE_BD SPRING_DATASOURCE_USERNAME=USUARIO_BD SPRING_DATASOURCE_PASSWORD=PASSWORD_BD # ---- Seguridad JWT (RS256 / JWKS) ---- VARIABLES_JWT_REALES # ---- Eureka / servicios ---- EUREKA_URL_Y_OTROS_SERVICIOS
.env a git ni lo pegues en un chat. Si lo compartiste por error, rota las credenciales.
12.3 Qué pasa con el perfil
El valor de SPRING_PROFILES_ACTIVE debe coincidir con el nombre de un archivo application-PERFIL.yml existente en el proyecto. Si no existe, Spring usará solo application.yml.
13. Spring Boot: cabeceras reenviadas y Actuator
13.1 Leer las cabeceras de los proxies
Archivo: ui-ms/src/main/resources/application.yml (o el perfil de producción)
server:
port: ${SERVER_PORT:8080}
forward-headers-strategy: framework
Sin esta propiedad, request.getRemoteAddr() devuelve 127.0.0.1 y los redirects pueden salir en http:// en lugar de https://.
13.2 Actuator para el healthcheck
Si el proyecto no tiene Actuator, agrega la dependencia en pom.xml:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>
Y exponer solo lo necesario:
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
probes:
enabled: true
env, beans ni heapdump en producción. Pueden filtrar secretos.
13.3 Verificar
curl -s http://127.0.0.1:8098/actuator/health
Debe responder {"status":"UP"}.
14. Apache: proxy hacia Docker
14.1 Instalar y habilitar módulos
sudo apt install -y apache2 sudo a2enmod proxy proxy_http headers sudo a2dismod -f status 2>/dev/null || true
14.2 Cambiar el puerto de escucha
Archivo: /etc/apache2/ports.conf
Listen 127.0.0.1:8090
Así Apache no escucha en el 80 público (que es de Nginx) y solo acepta conexiones locales.
14.3 Desactivar el sitio por defecto
sudo a2dissite 000-default.conf
14.4 Crear el VirtualHost
Archivo: /etc/apache2/sites-available/app.josesaid.lat.conf
<VirtualHost 127.0.0.1:8090>
ServerName app.josesaid.lat
ProxyRequests Off
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8098/ retry=0 timeout=120
ProxyPassReverse / http://127.0.0.1:8098/
ErrorLog ${APACHE_LOG_DIR}/app.josesaid.lat-error.log
CustomLog ${APACHE_LOG_DIR}/app.josesaid.lat-access.log combined
</VirtualHost>
X-Forwarded-*. Las recibe de Nginx y las reenvía a Docker. Apache agrega su propia IP a X-Forwarded-For; por eso Spring debe leer la primera IP de la lista, que es la del cliente original.
14.5 Activar y validar
sudo a2ensite app.josesaid.lat.conf sudo apache2ctl configtest sudo systemctl restart apache2 ss -ltnp | grep 8090
configtest debe responder Syntax OK, y ss debe mostrar 127.0.0.1:8090.
15. Nginx: josesaid.lat y app.josesaid.lat
15.1 Instalar Nginx
sudo apt install -y nginx sudo rm -f /etc/nginx/sites-enabled/default
15.2 Sitio principal: josesaid.lat
Archivo: /etc/nginx/sites-available/josesaid.lat
server {
listen 80;
listen [::]:80;
server_name josesaid.lat www.josesaid.lat;
root /var/www/josesaid.lat/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
15.3 Aplicación: app.josesaid.lat → Apache
Archivo: /etc/nginx/sites-available/app.josesaid.lat
server {
listen 80;
listen [::]:80;
server_name app.josesaid.lat;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8090;
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;
proxy_set_header X-Forwarded-Host $host;
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
}
15.4 Crear directorios y activar
sudo mkdir -p /var/www/josesaid.lat/html /var/www/app.josesaid.lat/html sudo chown -R deploy:deploy /var/www/josesaid.lat /var/www/app.josesaid.lat sudo ln -s /etc/nginx/sites-available/josesaid.lat /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/app.josesaid.lat /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx
nginx -t debe responder syntax is ok y test is successful.
16. Página de bienvenida
Archivo: /var/www/josesaid.lat/html/index.html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>josesaid.lat</title>
</head>
<body>
<h1>Bienvenido a mi página web</h1>
</body>
</html>
17. Pruebas por niveles
Ejecuta las pruebas en orden. Cada nivel depende del anterior.
17.1 Nivel 1: servicios del sistema
systemctl is-active nginx apache2 docker ss -ltnp | grep -E ':(80|8090|8098)\b'
Esperado: active tres veces; 0.0.0.0:80 de Nginx y 127.0.0.1:8090 y 127.0.0.1:8098.
17.2 Nivel 2: cada capa por separado
# Docker → Spring Boot curl -I http://127.0.0.1:8098/ # Apache → Docker curl -I -H "Host: app.josesaid.lat" http://127.0.0.1:8090/ # Nginx → Apache → Docker curl -I -H "Host: app.josesaid.lat" http://127.0.0.1/ # Nginx → página de bienvenida curl -I -H "Host: josesaid.lat" http://127.0.0.1/
17.3 Nivel 3: desde internet
curl -I http://josesaid.lat curl -I http://app.josesaid.lat
17.4 Nivel 4: puertos internos no públicos
Desde tu computadora (no desde el Droplet):
curl -m 5 http://IP_DEL_DROPLET:8090/ # debe fallar curl -m 5 http://IP_DEL_DROPLET:8098/ # debe fallar
17.5 Nivel 5: IP real del cliente
tail -n 3 /var/log/apache2/app.josesaid.lat-access.log docker logs --tail 20 ui-ms
Apache registra 127.0.0.1 (Nginx). Spring Boot debe registrar tu IP pública.
18. HTTPS con Certbot
Ejecuta esto solo cuando la sección 17 pase completa.
sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d josesaid.lat -d www.josesaid.lat -d app.josesaid.lat \ --redirect --agree-tos -m TU_CORREO --no-eff-email sudo certbot renew --dry-run
Certbot agrega listen 443 ssl, la redirección de HTTP a HTTPS y el timer de renovación. Nginx sigue enviando X-Forwarded-Proto $scheme, que ahora será https; Spring Boot lo lee gracias a la sección 13.
curl -I https://josesaid.lat curl -I https://app.josesaid.lat sudo systemctl list-timers | grep certbot
19. Actualizar la aplicación
Procedimiento para publicar una nueva versión de ui-ms:
cd ~/EMPRESA_JOSESAID git pull --ff-only cd ui-ms docker compose build --no-cache ui-ms docker compose up -d ui-ms docker compose ps docker logs --tail 50 ui-ms curl -s http://127.0.0.1:8098/actuator/health
--ff-only evita merges automáticos en el servidor. Si falla, hay cambios locales que revisar antes de desplegar.
19.1 Limpiar imágenes viejas
docker image prune -f
Borra solo las imágenes sin etiqueta que quedan de builds anteriores.
20. Solución de problemas
| Síntoma | Causa probable | Qué revisar |
|---|---|---|
502 Bad Gateway en Nginx | Apache no escucha en 8090 | systemctl status apache2 y ss -ltnp | grep 8090 |
503 o error de conexión desde Apache | ui-ms no responde en 8098 | curl -I http://127.0.0.1:8098/ y docker logs ui-ms |
| El contenedor se reinicia en bucle | Falta una variable del .env o no conecta a la BD | docker logs ui-ms busca Caused by |
OutOfMemoryError o el contenedor se cae | Memoria insuficiente | free -h, docker stats y subir el plan o mem_limit |
| El build de Maven falla en el servidor | Memoria insuficiente durante la compilación | Verifica el swap (sección 5) o compila localmente y sube el JAR |
Redirects a http:// con HTTPS | Spring no lee X-Forwarded-Proto | Confirma forward-headers-strategy: framework |
Spring ve 127.0.0.1 como IP del cliente | Falta forward-headers-strategy | Sección 13 |
| Timeout desde internet | UFW o el Cloud Firewall bloquea 80/443, o el DNS no propagó | ufw status verbose y dig +short app.josesaid.lat |
| No puedes entrar por SSH | UFW bloqueó tu IP o cambió tu IP pública | Consola web de DigitalOcean (Recovery Console) para corregir UFW |
nginx -t falla | Error de sintaxis o archivo duplicado | El mensaje de error y ls -la /etc/nginx/sites-enabled/ |
permission denied al usar Docker | No cerraste sesión tras agregar el grupo | Cierra sesión SSH y vuelve a entrar |
Comandos de diagnóstico
sudo tail -n 50 /var/log/nginx/error.log sudo tail -n 50 /var/log/apache2/app.josesaid.lat-error.log docker logs --tail 100 ui-ms docker stats --no-stream free -h df -h sudo ss -ltnp
21. Rollback
Para volver a una versión anterior de ui-ms:
cd ~/EMPRESA_JOSESAID git log --oneline -n 10 # identifica el commit anterior git checkout COMMIT_ANTERIOR cd ui-ms docker compose build ui-ms docker compose up -d ui-ms
git checkout main y git pull cuando decidas desplegar otra versión. No dejes el servidor en un commit desconocido.
Desactivar la app sin borrar nada
cd ~/EMPRESA_JOSESAID/ui-ms docker compose stop ui-ms sudo a2dissite app.josesaid.lat.conf sudo systemctl restart apache2
/etc/letsencrypt. Solo elimina el bloque listen 443 del archivo de Nginx que quieras revertir.
22. Checklist final
deploy, login de root y contraseñas deshabilitados.deploy en el grupo docker.josesaid.lat, www y app apuntan al Droplet..env creado con chmod 600.docker compose up -d levanta el contenedor; /actuator/health responde UP.forward-headers-strategy: framework configurado.127.0.0.1:8090 y redirige a 127.0.0.1:8098.josesaid.lat sirve la bienvenida; app.josesaid.lat hace proxy_pass a Apache.