La lección anterior dejó todas las decisiones tomadas y ninguna ejecutada. Esta es la primera vez que CicloUrbana sale de una máquina de desarrollo y responde en una dirección pública de Internet. Y lo hace por el camino más corto que existe: una plataforma como servicio, donde alguien se ocupa del sistema operativo, del servidor, del certificado TLS, de reiniciar el proceso si muere y de mantener PostgreSQL, y a nosotros nos queda el código y unas cuantas variables.
Heroku es el PaaS que inventó buena parte del vocabulario que usa hoy toda la industria —el Procfile, los buildpacks, las config vars, el git push como despliegue— y por eso sigue siendo el mejor sitio para aprender los conceptos, aunque su nivel gratuito desapareció en noviembre de 2022. Todo lo que veremos aquí se traslada casi literalmente a Railway, Render, Fly.io, Cloud Run o Azure App Service, y en el apartado 3 hay la tabla de equivalencias para que puedas seguir la lección en la plataforma que prefieras.
Contenido
- Qué es un PaaS y qué te da hecho
- Los conceptos de Heroku
- Heroku hoy: precios y alternativas equivalentes
- Requisitos previos
- Preparar CicloUrbana:
system.propertiesyProcfile - Crear la aplicación y desplegar
- La base de datos: Heroku Postgres y
DATABASE_URL - Configuración y secretos con config vars
- Migraciones de Flyway en la release phase
- Escalado, dynos y el reinicio diario
- Logs y sistema de ficheros efímero
- Dominio propio y TLS
- Alternativa: desplegar la imagen de contenedor
- Comprobar la salud y monitorizar
- Costes y limpieza
- Los límites de un PaaS
- Errores Comunes y Consejos
- Ejercicios
- Qué es un PaaS y qué te da hecho
Una plataforma como servicio es un modelo de alojamiento en el que entregas código —o una imagen— y la plataforma se ocupa de absolutamente todo lo que hay por debajo. En la tabla de modelos de 08-01 era la fila de «esfuerzo operativo muy bajo».
| Lo que te da hecho un PaaS | Lo que pierdes en control |
|---|---|
| Sistema operativo y sus actualizaciones de seguridad | No eliges la distribución ni el kernel |
| Instalación del JRE (a partir de una declaración de versión) | Ajustes finos de la JVM limitados por la memoria del plan |
| Construcción del artefacto en sus servidores | Poco control sobre el entorno de construcción |
| Arranque, supervisión y reinicio del proceso | No hay systemd ni acceso persistente a la máquina |
| Balanceador HTTP y certificado TLS automático | La terminación TLS es suya; no eliges la configuración de cifrados |
| Enrutado a varias instancias | Sin control fino del algoritmo de balanceo |
| Base de datos gestionada como add-on | Menos parámetros del motor ajustables |
| Recogida y consulta de logs | Retención corta salvo con add-on de pago |
| Métricas básicas y alertas | Observabilidad limitada frente a un stack propio |
| Reversión a la publicación anterior en un comando | — |
El trato es explícito: cedes control a cambio de tiempo. Para un proyecto como CicloUrbana en su fase inicial —un desarrollador, una demostración al ayuntamiento, cero personas dedicadas a infraestructura— es un trato excelente. El punto donde deja de serlo se trata en el apartado 16.
- Los conceptos de Heroku
| Concepto | Qué es | En CicloUrbana |
|---|---|---|
| App | La unidad de despliegue: código, configuración, add-ons y dominio | ciclourbana-ribalta |
| Dyno | El contenedor Linux ligero donde corre un proceso | Un dyno web ejecutando el JAR |
| Tipo de dyno | Tamaño: Eco, Basic, Standard-1X/2X, Performance-M/L |
Basic para la demostración; Standard-1X con 512 MB para uso real |
| Tipo de proceso | La clase de proceso declarada: web, release, worker |
web (la API) y release (las migraciones) |
| Slug | El artefacto comprimido que resulta de la construcción y se copia a los dynos | El JAR más el JRE, unos 90 MB |
| Buildpack | El script que detecta el tipo de proyecto y lo construye | heroku/java, que detecta el pom.xml |
Procfile |
Fichero en la raíz que declara qué comando arranca cada tipo de proceso | web: y release: |
| Config var | Variable de entorno de la app, gestionada por la plataforma | SPRING_PROFILES_ACTIVE, JWT_SECRETO |
| Add-on | Servicio de respaldo conectable (factor 4 de 08-01) | Heroku Postgres, Papertrail |
| Release | Una combinación inmutable de slug + config vars, numerada (v42) |
La unidad de reversión |
| Release phase | Un proceso que se ejecuta después de construir y antes de activar la publicación | Donde correrán las migraciones de Flyway |
| Pipeline | Encadenamiento de apps por etapa (staging → production) |
ciclourbana-pre → ciclourbana-ribalta |
| Review app | App efímera creada automáticamente por cada pull request | Validar un cambio antes de fusionarlo |
Dos conceptos merecen un matiz. El primero: una release es slug + configuración, así que cambiar una config var crea una publicación nueva y reinicia los dynos. Es exactamente el modelo de 08-01: el artefacto es uno, la publicación combina artefacto y entorno.
El segundo: el dyno no es una máquina virtual con nombre, es un contenedor efímero que puede reiniciarse, moverse de máquina o duplicarse en cualquier momento. Todo lo que describimos en 08-01 sobre procesos desechables y sin estado se aplica aquí de forma literal, y con más rigor que en otras plataformas.
- Heroku hoy: precios y alternativas equivalentes
Aviso importante: Heroku eliminó su nivel gratuito el 28 de noviembre de 2022. Ya no existen los dynos gratuitos ni el plan hobby-dev de Postgres. Para seguir esta lección en Heroku hace falta una tarjeta y el gasto es real desde la primera hora: el plan más barato con base de datos ronda los 10-15 dólares al mes. Existe el programa Heroku for GitHub Students con crédito, pero requiere solicitud.
Como los conceptos son universales, esta es la tabla de traducción:
| Plataforma | Modelo | Equivalencias | Nivel gratuito | Notas |
|---|---|---|---|---|
| Railway | PaaS de contenedores | Servicio ≈ app · Variables ≈ config vars · Plugin ≈ add-on | Crédito mensual limitado | Muy cercano a Heroku; detecta el pom.xml |
| Render | PaaS | Web Service ≈ app · Environment Group ≈ config vars · render.yaml ≈ Procfile + app.json |
Sí, con suspensión por inactividad | Postgres gestionado; el plan gratuito caduca |
| Fly.io | Contenedores en el borde | Machine ≈ dyno · fly.toml ≈ Procfile · Secrets ≈ config vars |
Crédito limitado | Despliega imágenes; despliegue en varias regiones |
| Google Cloud Run | Contenedores sin servidor | Service ≈ app · Revision ≈ release · Secrets desde Secret Manager | Cuota gratuita mensual generosa | Escala a cero; cuidado con el arranque en frío de la JVM |
| Azure App Service | PaaS | App Service ≈ app · App Settings ≈ config vars · Deployment Slot ≈ blue-green | Nivel F1 gratuito muy limitado | Soporta JAR de Java 21 directamente |
| Clever Cloud | PaaS europeo | Application ≈ app · Environment variables ≈ config vars | No | Alojamiento en la UE, relevante para datos municipales |
Los conceptos se trasladan casi tal cual: en todas ellas hay que declarar la versión de Java, escuchar en el puerto que indique la plataforma por variable de entorno, poner los secretos como variables, conectar una base de datos gestionada por URL y escribir los logs a stdout. Si sigues la lección en Railway o Render, cambia los comandos de la CLI y el resto encaja.
- Requisitos previos
# 1. Cuenta en heroku.com con verificación de tarjeta
# 2. Instalar la CLI (macOS con Homebrew)
brew tap heroku/brew && brew install heroku
# 2 bis. Linux
curl https://cli-assets.heroku.com/install.sh | sh
# 3. Comprobar y autenticarse (abre el navegador)
heroku --version
heroku loginheroku login guarda un testigo de API en ~/.netrc. Ese fichero es una credencial: no lo copies a ningún repositorio ni a una imagen. Para entornos automatizados existe heroku authorizations:create, que genera un testigo revocable con permisos acotados —la forma correcta de darle acceso a una canalización (08-05)— en lugar de reutilizar tus credenciales personales.
También necesitas el repositorio de CicloUrbana con el mvnw versionado y commit limpio: el despliegue es literalmente un git push.
- Preparar CicloUrbana:
system.properties y Procfile
system.properties y ProcfileEl buildpack de Java (heroku/java) detecta el proyecto por la presencia de pom.xml y ejecuta ./mvnw -DskipTests clean install. Se saltan las pruebas de forma deliberada: la construcción de la plataforma no es el sitio donde se ejecuta la suite del módulo 6, eso ocurre en la canalización (08-05). Pero el buildpack necesita dos cosas que hay que declarar.
system.properties —en la raíz del repositorio— fija la versión de Java. Sin él, el buildpack usa una versión por defecto que puede no ser 21 y la aplicación fallará con UnsupportedClassVersionError:
Procfile —también en la raíz, sin extensión y con esa mayúscula exacta— declara los tipos de proceso:
Cada parte importa:
web:es un nombre reservado: identifica al proceso que recibe tráfico HTTP externo. Cualquier otro nombre (worker,release) no recibe peticiones.$PORTes obligatorio y no negociable. Heroku asigna a cada dyno un puerto arbitrario en tiempo de arranque y lo publica en esa variable; el enrutador envía el tráfico ahí. Una aplicación que escuche en el 8080 fijo no recibe nada y a los 60 segundos Heroku la mata con el errorR10 Boot timeout. Este es, con diferencia, el fallo más común del primer despliegue.MaxRAMPercentage=75aplica aquí lo mismo que en el contenedor de 07-04: un dynoBasictiene 512 MB y superarlos produce erroresR14 Memory quota exceededy una degradación brutal por swap.target/ciclourbana.jares la ruta dentro del slug; coincide con elfinalNamedelpom.xml.
Como alternativa a -Dserver.port, Spring Boot toma la variable SERVER_PORT por relaxed binding (02-05), así que web: java -jar target/ciclourbana.jar funciona si se define SERVER_PORT=$PORT. La forma explícita del Procfile es preferible porque deja la dependencia a la vista.
Y una comprobación previa que ahorra un viaje: el application.yml no debe fijar server.port: 8080 de forma que gane a la línea de comandos. No lo hace —la línea de comandos tiene mayor precedencia (02-04)— pero conviene verificarlo antes de culpar a la plataforma.
- Crear la aplicación y desplegar
# Desde la raíz del repositorio
heroku create ciclourbana-ribalta
# Creating ⬢ ciclourbana-ribalta... done
# https://ciclourbana-ribalta-1a2b3c4d5e6f.herokuapp.com/
# https://git.heroku.com/ciclourbana-ribalta.gitEl comando hace tres cosas: reserva el nombre (globalmente único en toda la plataforma; si está cogido, elige otro), asigna un dominio herokuapp.com con TLS ya funcionando, y añade un remoto de Git llamado heroku a tu repositorio local. Compruébalo con git remote -v.
git add system.properties Procfile
git commit -m "Preparar despliegue en Heroku"
git push heroku mainEse git push es el despliegue. Lo que ocurre a continuación, leído en el log de construcción:
remote: -----> Building on the Heroku-24 stack
remote: -----> Determining which buildpack to use for this app
remote: -----> Java app detected
remote: -----> Installing JDK 21... done
remote: -----> Executing Maven
remote: $ ./mvnw -DskipTests clean install
remote: [INFO] BUILD SUCCESS
remote: -----> Discovering process types
remote: Procfile declares types -> release, web
remote: -----> Compressing... done, 92.4M
remote: -----> Launching... done, v3
remote: https://ciclourbana-ribalta-1a2b3c4d5e6f.herokuapp.com/ deployed to HerokuCómo leerlo, línea a línea:
| Línea | Qué significa | Qué revisar si falla |
|---|---|---|
Java app detected |
Encontró el pom.xml |
Si no aparece, el pom.xml no está en la raíz |
Installing JDK 21 |
Leyó system.properties |
Si instala otra versión, el fichero falta o tiene una errata |
Executing Maven |
Construcción real | Aquí salen los errores de compilación y de dependencias |
Procfile declares types |
Leyó el Procfile |
Si dice (none), el fichero no está en la raíz o se llama procfile |
Compressing... 92.4M |
Tamaño del slug | El límite duro es 500 MB; si se acerca, revisa qué se está empaquetando |
Launching... v3 |
Número de publicación | Es el identificador para revertir |
Cuidado con la rama. Solo se despliega lo que se empuja al remoto heroku. Si trabajas en desarrollo, git push heroku desarrollo:main es la forma de desplegar esa rama sobre la principal de Heroku.
Todavía no funciona: falta la base de datos.
- La base de datos: Heroku Postgres y
DATABASE_URL
DATABASE_URLheroku addons:create heroku-postgresql:essential-0 --app ciclourbana-ribalta
# Creating heroku-postgresql:essential-0 on ⬢ ciclourbana-ribalta... ~$5/month
# Database has been created and is available
heroku pg:info --app ciclourbana-ribaltaEl add-on crea una instancia gestionada de PostgreSQL y define automáticamente una config var llamada DATABASE_URL. Aquí llega el detalle que rompe el primer despliegue de toda aplicación Spring Boot en Heroku:
postgres://usuario123:[email protected]:5432/d9fk2l1m3nEse formato no es una URL JDBC válida. Sigue la convención de los doce factores —un único valor con esquema, credenciales y destino— pero el driver de PostgreSQL espera jdbc:postgresql://host:puerto/base con usuario y contraseña por separado. Si la aplicación intenta usar DATABASE_URL tal cual, el arranque falla con Driver claims to not accept jdbcUrl.
Hay dos formas correctas de resolverlo.
Opción A (recomendada): definir las tres variables explícitas. Se leen los valores del add-on y se traducen a las propiedades estándar de Spring:
# Ver el valor actual
heroku config:get DATABASE_URL --app ciclourbana-ribalta
# Traducir a las propiedades de Spring
heroku config:set \
SPRING_DATASOURCE_URL="jdbc:postgresql://ec2-10-20-30-40.compute-1.amazonaws.example:5432/d9fk2l1m3n?sslmode=require" \
SPRING_DATASOURCE_USERNAME="usuario123" \
SPRING_DATASOURCE_PASSWORD="contrasenaSecreta" \
--app ciclourbana-ribaltaEs explícito, se depura fácilmente y no añade código. Su inconveniente es real: Heroku rota las credenciales de la base de datos en mantenimientos y actualizaciones, y cuando lo hace cambia DATABASE_URL pero no tus variables copiadas. Hay que estar atento a los avisos de mantenimiento.
Opción B: transformar DATABASE_URL al arrancar. Un EnvironmentPostProcessor convierte el valor antes de que se cree el DataSource:
package com.ciclourbana.comun.config;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
/**
* Traduce la DATABASE_URL de Heroku (postgres://usuario:clave@host:puerto/base)
* a las propiedades estandar de Spring. Se ejecuta muy pronto en el arranque,
* antes de que se construya el DataSource.
*/
public class TraductorDatabaseUrl implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment entorno, SpringApplication app) {
String valor = entorno.getProperty("DATABASE_URL");
if (valor == null || valor.startsWith("jdbc:")) {
return; // no estamos en Heroku, o ya viene traducida
}
URI uri = URI.create(valor);
String[] credenciales = uri.getUserInfo().split(":", 2);
Map<String, Object> propiedades = new HashMap<>();
propiedades.put("spring.datasource.url",
"jdbc:postgresql://%s:%d%s?sslmode=require"
.formatted(uri.getHost(), uri.getPort(), uri.getPath()));
propiedades.put("spring.datasource.username", credenciales[0]);
propiedades.put("spring.datasource.password", credenciales[1]);
entorno.getPropertySources()
.addFirst(new MapPropertySource("heroku-datasource", propiedades));
}
}Y se registra en src/main/resources/META-INF/spring.factories:
org.springframework.boot.env.EnvironmentPostProcessor=\
com.ciclourbana.comun.config.TraductorDatabaseUrlDetalles que explican el código: se usa EnvironmentPostProcessor y no un @Bean porque tiene que ejecutarse antes de que la autoconfiguración construya el DataSource (02-06); addFirst da a estas propiedades la máxima precedencia; el return temprano hace que la clase sea inocua fuera de Heroku, de modo que el mismo artefacto sirve para todas partes —principio de 08-01—; y sslmode=require es obligatorio, porque Heroku Postgres solo acepta conexiones cifradas.
Copias de seguridad. El add-on hace backups automáticos, y además se pueden gestionar a mano:
heroku pg:backups:schedule DATABASE_URL --at "03:00 Europe/Madrid" --app ciclourbana-ribalta
heroku pg:backups:capture --app ciclourbana-ribalta # copia puntual
heroku pg:backups # listar
heroku pg:backups:download b012 # descargar un volcado
heroku pg:backups:restore b012 DATABASE_URL # restaurar (DESTRUCTIVO)Recuerda el principio de 08-01: una copia que nunca se ha restaurado no es una copia. Prueba pg:backups:restore en otra app, no en la de producción.
| Plan | Coste aprox. | Conexiones | Almacenamiento | Uso |
|---|---|---|---|---|
essential-0 |
5 $/mes | 20 | 1 GB | Práctica y demostración |
essential-2 |
20 $/mes | 40 | 32 GB | Producción pequeña |
standard-0 |
50 $/mes | 120 | 64 GB | Producción con réplica y PITR |
Fíjate en la columna de conexiones y recuerda el cálculo de 08-01: con essential-0 y 20 conexiones, dos dynos con el pool por defecto de 10 agotan el límite sin dejar margen para migraciones ni administración. Si vas a escalar a dos dynos, baja maximum-pool-size a 5.
- Configuración y secretos con config vars
Aquí es donde los perfiles de 07-02 encajan con la plataforma:
heroku config:set \
SPRING_PROFILES_ACTIVE=prod \
JWT_SECRETO="$(openssl rand -base64 48)" \
TZ=Europe/Madrid \
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError" \
--app ciclourbana-ribalta
heroku config --app ciclourbana-ribalta # listar
heroku config:unset VARIABLE_OBSOLETA # eliminarPuntos importantes:
SPRING_PROFILES_ACTIVE=prodactiva elapplication-prod.ymlversionado (sin secretos) de 07-02: Swagger cerrado, logs enINFO,ddl-auto: validate, CORS restringido.openssl rand -base64 48genera el secreto HS256 en tu terminal y lo envía sin que quede escrito en ningún fichero. Nunca reutilices el secreto de desarrollo.- Cada
config:setcrea una publicación nueva y reinicia los dynos. Agrupa los cambios en un solo comando para no provocar varios reinicios seguidos. TZ=Europe/Madridresuelve lo del apartado 7.5 de 08-01: los dynos corren en UTC.
Advertencia de seguridad. Las config vars están cifradas en reposo, pero son visibles para cualquier colaborador de la app y aparecen en heroku config. Aplica mínimo privilegio en el equipo, y rota el secreto JWT si alguien deja el proyecto o si sospechas exposición:
Consecuencia práctica de rotar: todos los testigos emitidos con la clave anterior dejan de validar, así que los ciudadanos tendrán que autenticarse de nuevo. Es una molestia aceptable y la razón por la que conviene que el JWT tenga vida corta y haya un refresh token.
- Migraciones de Flyway en la release phase
CicloUrbana tiene siete migraciones (V1…V7) y ddl-auto: validate (04-08). Hay dos formas de aplicarlas, y es el mismo dilema del apartado 7.2 de 08-01.
Al arrancar es lo que ya hace la aplicación: spring.flyway.enabled: true y Flyway migra en el arranque del contexto. Funciona con un dyno. Con varios, todos intentan migrar a la vez y Flyway serializa con un bloqueo: correcto, pero con dos efectos molestos —los dynos que esperan consumen su ventana de arranque de 60 segundos, y si la migración falla todos los dynos entran en bucle de reinicio, dejando la app caída aunque la versión anterior funcionase.
En la release phase es la forma segura. Se declara un tipo de proceso release en el Procfile:
release: java -Dspring.flyway.enabled=true -Dspring.main.web-application-type=none -jar target/ciclourbana.jar
web: java -Dserver.port=$PORT -Dspring.flyway.enabled=false -jar target/ciclourbana.jarCómo funciona: tras construir el slug y antes de conmutar el tráfico a la nueva publicación, Heroku ejecuta el proceso release en un dyno de un solo uso, con las config vars de la app. web-application-type=none hace que la aplicación arranque sin Tomcat: se levanta el contexto, Flyway migra y el proceso termina. Si sale con código distinto de cero, la publicación se cancela y los dynos siguen ejecutando la versión anterior.
| Al arrancar | Release phase | |
|---|---|---|
| Con varios dynos | Todos esperan el bloqueo | Ya está migrado cuando arrancan |
| Si la migración falla | Todos los dynos caen en bucle | La publicación se aborta, la versión previa sigue viva |
| Visibilidad del resultado | Mezclada en el log del dyno | Un paso propio, con su propio código de salida |
| Tiempo de arranque | Migración + contexto dentro de los 60 s | Solo el contexto |
| Migración larga | Puede agotar el arranque | Tiene su propio tiempo |
La release phase es claramente preferible, y es el equivalente exacto de la tarea puntual de ECS (08-03) y del Job de Kubernetes (08-04): el factor 12 de los doce factores, procesos de administración ejecutados como procesos efímeros del mismo código.
Detalle práctico: al desactivar Flyway en el proceso web, ddl-auto: validate sigue actuando de red de seguridad —si por lo que sea el esquema no corresponde, la aplicación no arranca en vez de fallar consulta a consulta.
- Escalado, dynos y el reinicio diario
heroku ps --app ciclourbana-ribalta # estado actual
heroku ps:scale web=2 --app ciclourbana-ribalta
heroku ps:type web=standard-1x --app ciclourbana-ribalta
heroku ps:restart --app ciclourbana-ribalta| Tipo de dyno | RAM | Coste aprox./mes | Se duerme | Uso |
|---|---|---|---|---|
Eco |
512 MB | 5 $ (paquete de horas) | Sí, a los 30 min | Prácticas |
Basic |
512 MB | 7 $ | No | Demostraciones |
Standard-1X |
512 MB | 25 $ | No | Producción pequeña, métricas incluidas |
Standard-2X |
1 GB | 50 $ | No | Cuando 512 MB aprietan |
Performance-M |
2,5 GB | 250 $ | No | Carga alta |
512 MB son ajustados para Spring Boot. CicloUrbana con Hibernate, Spring Security y el pool arranca en torno a 250-350 MB de heap más metaespacio. Con MaxRAMPercentage=75 cabe, pero sin margen para un pico. Si aparecen errores R14 en el log, la respuesta correcta es Standard-2X, no bajar el porcentaje hasta lo absurdo.
El reinicio diario (dyno cycling). Heroku reinicia todos los dynos al menos una vez cada 24 horas, además de cuando cambia una config var, cuando se despliega o cuando la máquina anfitriona lo necesita. No es un fallo: es la plataforma imponiendo el factor 9, desechabilidad. Consecuencias para lo que ya tenemos construido:
- Nada en memoria sobrevive. Ya lo cumplimos: sin sesión gracias al JWT (05-04).
- Nada en disco sobrevive (apartado 11).
- Las tareas programadas de 07-03 se ven afectadas. Una tarea con
@Scheduled(fixedDelay = ...)reinicia su contador en cada reinicio del dyno; y con dos dynos, ambos tienen el planificador activo, así que elCaducadorAlquileresy elRecalculadorOcupacionse ejecutarían por duplicado. ShedLock salva exactamente esta situación: la tablashedlockde la migraciónV7hace que solo el primer dyno que tome el bloqueo ejecute la tarea y el otro la salte. Sin ShedLock, escalar aweb=2significaría duplicar informes y recalcular la ocupación dos veces por minuto.
Un matiz sobre las tareas con cron: si un reinicio coincide justo con la hora programada, esa ejecución puede perderse. Para tareas críticas conviene un planificador externo —Heroku Scheduler es un add-on— que invoque un endpoint o lance un proceso puntual, en lugar de depender de que el dyno esté vivo en ese segundo exacto.
- Logs y sistema de ficheros efímero
heroku logs --tail --app ciclourbana-ribalta
heroku logs --num 500 --source app --app ciclourbana-ribalta
heroku logs --dyno web.1 --tailEl sistema de ficheros de un dyno es efímero: cada dyno tiene su propia copia del slug con una capa escribible que se destruye en cada reinicio, y dos dynos no comparten nada. De ahí se siguen tres reglas:
- Los logs van a
stdout. Es el factor 11 de 08-01 y lo que Logback ya hace por defecto en CicloUrbana. Si la aplicación escribiera enlogs/ciclourbana.log, ese fichero desaparecería en cada reinicio y sería distinto en cada dyno. - Los ficheros subidos por usuarios no pueden guardarse en el dyno. Si mañana CicloUrbana admite fotos de incidencias, van a un almacenamiento de objetos (S3 o equivalente), no al disco.
- La retención de Heroku es de 1.500 líneas o una semana, lo que llegue antes. Para producción real hace falta un add-on de agregación (Papertrail, Logtail) o reenvío a un sistema propio — el tema completo de 09-05.
Un detalle de formato: los logs de Heroku están multiplexados entre dynos y procesos, y una traza de excepción de Java aparece como decenas de líneas independientes. Es un buen argumento para adoptar logs en JSON (09-05) y para que el FiltroTraza con MDC de 03-06 esté ahí: con el identificador de traza en cada línea, reconstruir una petición entre miles de líneas mezcladas deja de ser un ejercicio de paciencia.
- Dominio propio y TLS
El dominio herokuapp.com funciona con HTTPS desde el primer momento. Para el dominio del ayuntamiento:
heroku domains:add ciclourbana.ribalta.example --app ciclourbana-ribalta
# Configure your app's DNS provider to point to the DNS Target:
# ciclourbana.ribalta.example -> tranquil-otter-9x8y7z6w.herokudns.example
heroku certs:auto:enable --app ciclourbana-ribalta
heroku certs:auto --app ciclourbana-ribalta # ver el estadoEn el proveedor DNS se crea un CNAME hacia el destino indicado. Nunca un registro A: la dirección IP de Heroku cambia. Con el CNAME propagado, Automated Certificate Management solicita y renueva el certificado por Let's Encrypt automáticamente. Requiere un plan de pago (Basic o superior).
Dos ajustes en la aplicación cierran el círculo con 08-01 y 05-05:
El enrutador de Heroku termina el TLS y habla HTTP con el dyno, añadiendo X-Forwarded-Proto: https. Sin esa propiedad, las cabeceras Location de los 201 Created y las URLs de springdoc saldrían con http:// y un host interno. Y el CORS de 05-05 debe permitir el origen definitivo https://ciclourbana.ribalta.example, no el dominio de pruebas.
- Alternativa: desplegar la imagen de contenedor
En 07-04 construimos una imagen cuidada: multietapa, por capas, con usuario no root y JAVA_TOOL_OPTIONS. Heroku puede desplegar esa imagen en lugar de construir con el buildpack:
heroku stack:set container --app ciclourbana-ribalta
heroku container:login
heroku container:push web --app ciclourbana-ribalta
heroku container:release web --app ciclourbana-ribaltaRequiere un heroku.yml en la raíz:
build:
docker:
web: Dockerfile
release: Dockerfile
release:
command:
- java -Dspring.flyway.enabled=true -Dspring.main.web-application-type=none -jar /app/ciclourbana.jar
run:
web: java -Dserver.port=$PORT -jar /app/ciclourbana.jar| Buildpack (JAR) | Contenedor (imagen) | |
|---|---|---|
| Qué controlas | Poco: versión de Java y poco más | Todo: base, paquetes, usuario, zona horaria |
| Reproducibilidad | Construye Heroku, entorno opaco | La imagen es idéntica en todas partes |
Paridad con pre/prod |
Depende de la plataforma | Total: la misma imagen que ECS o Kubernetes |
| Esfuerzo | Cero configuración | Mantener el Dockerfile |
| Encaje con 08-03 y 08-04 | Ninguno | Directo |
Si el plan a medio plazo es salir a ECS o Kubernetes, desplegar la imagen desde el principio hace que el paso siguiente no sea un salto: cambia la plataforma, no el artefacto. Ojo con $PORT también aquí: el ENTRYPOINT de la imagen de 07-04 escucha en 8080 fijo, así que hay que respetar el run: web: del heroku.yml o parametrizar el puerto.
- Comprobar la salud y monitorizar
curl -s https://ciclourbana.ribalta.example/actuator/health | jq
# {"status":"UP"}
curl -s https://ciclourbana.ribalta.example/actuator/health/readiness
curl -s https://ciclourbana.ribalta.example/actuator/info | jq '.build'
# { "version": "2.4.0", "time": "2026-09-01T09:14:22Z" }Ese /actuator/info con build-info (07-01) responde a la pregunta de 08-01: qué versión está corriendo realmente en Ribalta.
Un detalle de configuración específico de Heroku: el puerto de gestión separado (8081) de 07-01 no funciona aquí, porque un dyno solo puede exponer un puerto, el de $PORT. En Heroku hay que dejar Actuator en el puerto principal bajo la cadena de seguridad que ya escribimos, con /actuator/health e /actuator/info públicos y el resto exigiendo ADMIN:
# application-heroku.yml (o dentro del perfil prod, condicionado)
management:
server:
port: ${MANAGEMENT_PORT:} # vacio = mismo puerto que la aplicacionComplementos de monitorización: los dynos Standard incluyen métricas (memoria, tiempo de respuesta, throughput) en el panel; existen add-ons de APM; y heroku ps más el log muestran los códigos de error de la plataforma, que conviene conocer:
| Código | Significado | Causa habitual |
|---|---|---|
| R10 | Boot timeout: no escuchó en $PORT en 60 s |
El Procfile no pasa $PORT, o el arranque tarda demasiado |
| R14 | Cuota de memoria superada | Heap mal dimensionado para el tipo de dyno |
| H12 | Request timeout a los 30 s | Consulta lenta o llamada externa sin timeout (07-06) |
| H10 | App crashed | Excepción en el arranque; mira el log completo |
- Costes y limpieza
Advertencia de coste. Todo lo de esta lección se factura por hora prorrateada desde el momento en que existe, con tráfico o sin él.
| Recurso | Plan mínimo | Coste aprox./mes |
|---|---|---|
Dyno Basic |
1 dyno | 7 $ |
Dyno Standard-1X |
1 dyno | 25 $ |
| Heroku Postgres | essential-0 |
5 $ |
| Certificado automático | Incluido con plan de pago | 0 $ |
| Add-on de logs | Plan básico | 0-7 $ |
| Total mínimo realista | ~12-32 $/mes |
Limpieza obligatoria al terminar la práctica. Destruir la app elimina también sus add-ons y sus copias de seguridad:
# 1. (Opcional) Descargar una copia antes de destruir nada
heroku pg:backups:capture --app ciclourbana-ribalta
heroku pg:backups:download --app ciclourbana-ribalta
# 2. Ver lo que se va a facturar
heroku addons --app ciclourbana-ribalta
heroku ps --app ciclourbana-ribalta
# 3. Destruir (pide confirmación escribiendo el nombre)
heroku apps:destroy --app ciclourbana-ribalta --confirm ciclourbana-ribalta
# 4. Verificar que no queda nada
heroku apps
heroku addonsheroku apps:destroy es irreversible y se lleva la base de datos y sus copias. Si el dominio propio estaba apuntando, borra también el CNAME en el proveedor DNS para no dejar un registro colgando hacia un destino inexistente.
- Los límites de un PaaS
Un PaaS es la elección correcta hasta que deja de serlo. Las señales:
| Límite | Manifestación | Cuándo aparece |
|---|---|---|
| Coste por unidad de capacidad | Cuatro Standard-2X cuestan más que la infraestructura equivalente |
Al escalar de verdad |
| Sin red privada propia | No puedes aislar la base de datos en subredes tuyas ni conectar con sistemas internos del ayuntamiento | Requisitos de red o cumplimiento |
| Control limitado de la JVM y del sistema | No hay ajustes finos del anfitrión, ni versiones concretas de bibliotecas del sistema | Problemas de rendimiento específicos |
| Reinicio diario forzado | Incompatible con procesos largos que no toleren interrupciones | Trabajos por lotes pesados |
| Cierre de conexiones a los 30 s (H12) | Peticiones largas o SSE necesitan otro diseño | Descargas grandes, streaming |
| Dependencia del proveedor | Add-ons, Procfile, release phase y CLI son suyos |
Al querer migrar |
| Región y cumplimiento | Datos municipales que deben residir en la UE | Requisito legal desde el día uno |
El último punto es especialmente relevante para CicloUrbana: los datos de los ciudadanos de Ribalta están sujetos al RGPD y el ayuntamiento puede exigir alojamiento en la Unión Europea. Heroku tiene región europea, pero es una decisión que hay que tomar al crear la app (heroku create --region eu), no después.
Cuando alguna de esas señales aparece, el destino natural es un modelo de contenedores gestionados con red propia: exactamente lo que hace la siguiente lección.
Errores Comunes y Consejos
Escuchar en un puerto fijo. Sin $PORT, el dyno no recibe tráfico y muere con R10 a los 60 segundos. Es el error número uno.
procfile en minúscula o dentro de una subcarpeta. Heroku no lo encuentra, el log dice Procfile declares types -> (none) y la app no arranca. Debe llamarse Procfile, en la raíz, sin extensión.
Usar DATABASE_URL como si fuera JDBC. El fallo es Driver claims to not accept jdbcUrl. Traduce con variables explícitas o con el EnvironmentPostProcessor, y no olvides sslmode=require.
Olvidar SPRING_PROFILES_ACTIVE=prod. La app arranca con la configuración de desarrollo en Internet: Swagger abierto, logs DEBUG y quizá H2. Es el escenario exacto del ejercicio 3 de 07-02.
Escalar a dos dynos sin revisar el pool. Con essential-0 (20 conexiones) y maximum-pool-size: 10, dos dynos agotan el límite y el tercer proceso —incluida la release phase— falla con too many clients.
Escalar a dos dynos sin ShedLock. Los informes salen duplicados y la ocupación se recalcula dos veces. La migración V7 de 07-03 ya está: solo hay que verificar que las tareas llevan @SchedulerLock.
Escribir ficheros en el dyno. Desaparecen en el próximo reinicio y no se comparten entre dynos.
Consejo: usa un pipeline con pre y prod. Dos apps encadenadas y heroku pipelines:promote mueven el slug ya construido de una a otra, sin reconstruir. Es el principio de 08-01 implementado por la plataforma.
Consejo: heroku releases y heroku rollback son tu plan de reversión. heroku releases lista las publicaciones numeradas y heroku rollback v41 vuelve a la anterior en segundos. Con la salvedad conocida: la base de datos no vuelve, así que el esquema debe seguir siendo compatible hacia atrás.
Consejo: heroku run bash para inspeccionar. Abre un dyno puntual con el slug y las config vars. Perfecto para depurar y también un recordatorio de por qué los secretos son secretos: quien puede ejecutar eso, los ve todos.
Ejercicios
Ejercicio 1
Prepara el repositorio de CicloUrbana para Heroku sin desplegar nada todavía: escribe system.properties y un Procfile con los procesos web y release, decide qué config vars hacen falta y en qué orden se ejecuta todo desde git push hasta que la app atiende su primera petición. Explica qué pasaría si faltase cada uno de los dos ficheros.
Ejercicio 2
Se despliega CicloUrbana y el log muestra, en este orden: Java app detected, BUILD SUCCESS, Launching... v3, y a continuación un bucle de at=error code=H10 desc="App crashed" con la excepción java.lang.IllegalStateException: Cannot load driver class: org.postgresql.Driver ... jdbcUrl is required with driverClassName. La app tiene el add-on heroku-postgresql:essential-0 creado y heroku config muestra DATABASE_URL. Diagnostica, corrige de las dos formas posibles y explica cuál elegirías para un proyecto que planea migrar a AWS en seis meses.
Ejercicio 3
CicloUrbana lleva un mes en Heroku con web=1, dyno Basic y essential-0. El ayuntamiento pide alta disponibilidad, así que se escala a web=2. En 48 horas aparecen tres problemas: (a) el informe nocturno de ocupación llega dos veces por correo, (b) el log muestra FATAL: sorry, too many clients already de forma intermitente y (c) durante los despliegues algunos ciudadanos reciben 503. Explica la causa de cada uno y propón la corrección completa, indicando qué cambia en la aplicación, qué en la configuración y qué en el plan de la plataforma.
Soluciones
Solución 1
system.properties en la raíz:
Procfile en la raíz:
release: java -Dspring.flyway.enabled=true -Dspring.main.web-application-type=none -jar target/ciclourbana.jar
web: java -Dserver.port=$PORT -XX:MaxRAMPercentage=75 -jar target/ciclourbana.jarConfig vars necesarias:
| Variable | Valor | Por qué |
|---|---|---|
SPRING_PROFILES_ACTIVE |
prod |
Activa application-prod.yml (07-02) |
SPRING_DATASOURCE_URL |
jdbc:postgresql://...?sslmode=require |
Traducción de DATABASE_URL |
SPRING_DATASOURCE_USERNAME / _PASSWORD |
del add-on | Idem |
JWT_SECRETO |
openssl rand -base64 48 |
Firma HS256 (05-04); nunca el de desarrollo |
TZ |
Europe/Madrid |
Los dynos corren en UTC (07-03) |
JAVA_TOOL_OPTIONS |
-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError |
Evitar R14 |
Secuencia completa: git push heroku main → el buildpack detecta el pom.xml → instala el JDK 21 leído de system.properties → ejecuta ./mvnw -DskipTests clean install → comprime el slug → lee el Procfile y descubre los tipos release y web → ejecuta el proceso release en un dyno de un solo uso, que levanta el contexto sin Tomcat, aplica V1…V7 y termina con código 0 → si el código es 0, activa la publicación v3 → arranca el dyno web, que escucha en $PORT, valida el esquema con ddl-auto: validate y queda listo → el enrutador empieza a enviarle tráfico.
Si falta system.properties: el buildpack instala su JDK por defecto. Si es anterior a 21, la construcción falla al compilar código de Java 21, o —peor— construye y el arranque muere con UnsupportedClassVersionError.
Si falta el Procfile: el buildpack de Java intenta adivinar el comando de arranque a partir del JAR. Puede llegar a funcionar, pero sin $PORT, así que el dyno no escucha donde debe y muere con R10 Boot timeout. Y no habría release phase, con lo que las migraciones tendrían que aplicarse al arrancar.
Solución 2
Diagnóstico. La construcción fue bien y la app arrancó, así que el problema es de configuración en tiempo de ejecución. El mensaje jdbcUrl is required viene de HikariCP: no encontró spring.datasource.url. El add-on definió DATABASE_URL, pero Spring Boot no la reconoce como propiedad suya —espera SPRING_DATASOURCE_URL— y aunque la leyera, postgres://usuario:clave@host:5432/base no es una URL JDBC: le falta el prefijo jdbc: y lleva credenciales embebidas que el driver no acepta ahí.
Corrección A — variables explícitas:
heroku config:get DATABASE_URL --app ciclourbana-ribalta
# postgres://u9k2:[email protected]:5432/d3n1
heroku config:set \
SPRING_DATASOURCE_URL="jdbc:postgresql://ec2-10-20-30-40.compute-1.amazonaws.example:5432/d3n1?sslmode=require" \
SPRING_DATASOURCE_USERNAME="u9k2" \
SPRING_DATASOURCE_PASSWORD="pw7x" \
--app ciclourbana-ribaltaCorrección B — EnvironmentPostProcessor: la clase TraductorDatabaseUrl del apartado 7, registrada en META-INF/spring.factories, que traduce en el arranque y no hace nada fuera de Heroku.
Cuál elegir con AWS a seis meses vista: la A. Razonamiento: la opción B introduce código específico de Heroku dentro del artefacto, justo lo que 08-01 pide evitar. Es una pieza que habría que mantener, probar y, previsiblemente, borrar en la migración. La opción A resuelve el problema fuera del binario, con las tres propiedades estándar de Spring, que son exactamente las mismas que se usarán con RDS: migrar consistirá en cambiar sus valores. El inconveniente conocido —Heroku rota las credenciales— se mitiga documentándolo en el runbook y suscribiéndose a los avisos de mantenimiento del add-on.
Si el horizonte fuese quedarse años en Heroku con rotaciones frecuentes, la B sería defendible; incluso entonces, aislada en un paquete de integración y activada por una condición explícita.
Solución 3
(a) El informe llega dos veces. Ambos dynos tienen @EnableScheduling activo y cada uno ejecuta su propia copia de la tarea. Es el problema de 07-03 con varias instancias, ahora en producción. La causa concreta es que la tarea del informe no lleva @SchedulerLock, o que falta la configuración de ShedLock. Corrección en la aplicación:
@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid")
@SchedulerLock(name = "informeOcupacionDiario",
lockAtMostFor = "PT30M", lockAtLeastFor = "PT5M")
public void generarInformeDiario() { ... }lockAtMostFor libera el bloqueo si el dyno muere a mitad; lockAtLeastFor evita que un segundo dyno lo ejecute si el primero terminó en milisegundos por reloj desajustado. La tabla shedlock ya existe desde la migración V7, así que no hace falta nada nuevo en la base de datos. Verificación: el log del dyno que no ejecuta debe mostrar la traza de ShedLock indicando que no obtuvo el bloqueo.
(b) too many clients already. El plan essential-0 permite 20 conexiones y maximum-pool-size es 10: dos dynos consumen las 20 exactas, sin dejar ninguna para la release phase, para heroku pg:psql ni para la monitorización del add-on. Por eso es intermitente: falla justo cuando algo más pide conexión. Es el cálculo de 08-01:
Corrección en dos frentes. En la configuración:
spring:
datasource:
hikari:
maximum-pool-size: ${HIKARI_POOL_SIZE:6}
minimum-idle: 2
connection-timeout: 3000Con 2 × 6 = 12 quedan 8 conexiones de margen. Y en el plan: subir a essential-2 (40 conexiones) si el rendimiento con 6 no basta. Recuerda el principio de 08-01: un pool grande no es más rápido; con 6 conexiones por dyno y consultas indexadas, CicloUrbana atiende holgadamente el tráfico de una ciudad pequeña.
(c) 503 durante los despliegues. Al desplegar, Heroku envía SIGTERM a los dynos antiguos y arranca los nuevos, pero el enrutador puede seguir enviando peticiones al dyno que se está apagando durante una ventana breve. Si el proceso deja de aceptar conexiones de golpe, esas peticiones fallan. Correcciones:
El apagado ordenado (01-05) hace que las peticiones en curso terminen. Heroku concede 30 segundos entre SIGTERM y SIGKILL, así que 25 s deja margen. Complementos: escalar a web=3 para que durante el relevo siempre haya capacidad sobrante, y evitar reinicios innecesarios agrupando los config:set en un único comando en lugar de encadenar varios, cada uno con su publicación y su reinicio.
Resumen del plan. En la aplicación: @SchedulerLock en las tareas y shutdown: graceful con su tiempo. En la configuración: maximum-pool-size bajado a 6 y connection-timeout explícito. En el plan de la plataforma: essential-2 si el pool reducido aprieta, y valorar web=3. Y una conclusión de fondo: los tres síntomas aparecieron el día que hubo más de una instancia, que es el momento en que los factores 6, 8 y 9 dejan de ser teoría.
Conclusión
CicloUrbana ya está en Internet. Cualquiera con la dirección puede consultar las estaciones de Ribalta, autenticarse y alquilar una bicicleta, sobre HTTPS, con una base de datos gestionada y copias de seguridad automáticas, y todo ello se ha conseguido sin administrar un solo servidor. Sabes qué te da hecho un PaaS y qué control cedes a cambio, y manejas su vocabulario completo —app, dyno y sus tipos, slug, buildpack, Procfile, config var, release, release phase, pipeline y review app—, con la advertencia clara de que Heroku ya no tiene nivel gratuito y la tabla de alternativas donde esos mismos conceptos aparecen con otro nombre.
Has preparado el proyecto con system.properties y un Procfile que respeta la regla que más despliegues rompe —escuchar en $PORT—, has leído el log de construcción línea a línea sabiendo qué revisar en cada una, y has resuelto el problema clásico de la DATABASE_URL de Heroku, que sigue los doce factores pero no es una URL JDBC válida, con las dos soluciones y el criterio para elegir entre ellas. Los perfiles de 07-02 encajaron con las config vars, el secreto del JWT se generó fuera del repositorio y sabes por qué y cómo se rota. Las migraciones de Flyway pasaron a la release phase, que aborta la publicación si fallan en lugar de dejar todos los dynos en bucle: el factor 12 en su forma más concreta.
También conoces lo que la plataforma impone: dynos con memoria ajustada donde MaxRAMPercentage deja de ser un detalle, un reinicio diario que convierte la desechabilidad en un hecho y que obliga a que ShedLock esté bien puesto antes de escalar a dos dynos, un sistema de ficheros efímero que confirma por qué los logs van a stdout, y el cálculo del pool de HikariCP frente al límite de conexiones del plan. Has añadido el dominio del ayuntamiento con certificado automático y forward-headers-strategy para que las URLs salgan bien tras el proxy, has comprobado con /actuator/health y /actuator/info qué versión corre realmente, y —muy importante— has destruido la app al terminar la práctica, porque todo esto se factura por hora.
Y sabes dónde están los límites: coste por unidad de capacidad al escalar, ausencia de red privada propia, control reducido del entorno, el corte a los 30 segundos, la dependencia del proveedor y la cuestión de la región para datos municipales sujetos al RGPD. Cuando esas señales aparecen, el siguiente paso es una infraestructura real que siga siendo gestionada. La lección siguiente, Desplegando en AWS, la construye: una imagen publicada en ECR, PostgreSQL en RDS dentro de subredes privadas donde Internet no llega, secretos en Secrets Manager, un servicio de ECS Fargate con su definición de tarea, y un balanceador con certificado de ACM que comprueba la salud contra /actuator/health/readiness. Con la misma advertencia de siempre, y aquí más en serio: RDS y el balanceador se facturan por hora aunque nadie use la aplicación.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
