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

  1. Qué es un PaaS y qué te da hecho
  2. Los conceptos de Heroku
  3. Heroku hoy: precios y alternativas equivalentes
  4. Requisitos previos
  5. Preparar CicloUrbana: system.properties y Procfile
  6. Crear la aplicación y desplegar
  7. La base de datos: Heroku Postgres y DATABASE_URL
  8. Configuración y secretos con config vars
  9. Migraciones de Flyway en la release phase
  10. Escalado, dynos y el reinicio diario
  11. Logs y sistema de ficheros efímero
  12. Dominio propio y TLS
  13. Alternativa: desplegar la imagen de contenedor
  14. Comprobar la salud y monitorizar
  15. Costes y limpieza
  16. Los límites de un PaaS
  17. Errores Comunes y Consejos
  18. Ejercicios

  1. 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.

  1. 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.

  1. 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.

  1. 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 login

heroku 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.

  1. Preparar CicloUrbana: system.properties y Procfile

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

java.runtime.version=21
maven.version=3.9.9

Procfile —también en la raíz, sin extensión y con esa mayúscula exacta— declara los tipos de proceso:

web: java -Dserver.port=$PORT -XX:MaxRAMPercentage=75 -jar target/ciclourbana.jar

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.
  • $PORT es 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 error R10 Boot timeout. Este es, con diferencia, el fallo más común del primer despliegue.
  • MaxRAMPercentage=75 aplica aquí lo mismo que en el contenedor de 07-04: un dyno Basic tiene 512 MB y superarlos produce errores R14 Memory quota exceeded y una degradación brutal por swap.
  • target/ciclourbana.jar es la ruta dentro del slug; coincide con el finalName del pom.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.

  1. 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.git

El 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 main

Ese 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 Heroku

Có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.

  1. La base de datos: Heroku Postgres y DATABASE_URL

heroku 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-ribalta

El 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/d9fk2l1m3n

Ese 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-ribalta

Es 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.TraductorDatabaseUrl

Detalles 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.

  1. 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        # eliminar

Puntos importantes:

  • SPRING_PROFILES_ACTIVE=prod activa el application-prod.yml versionado (sin secretos) de 07-02: Swagger cerrado, logs en INFO, ddl-auto: validate, CORS restringido.
  • openssl rand -base64 48 genera 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:set crea una publicación nueva y reinicia los dynos. Agrupa los cambios en un solo comando para no provocar varios reinicios seguidos.
  • TZ=Europe/Madrid resuelve 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:

heroku config:set JWT_SECRETO="$(openssl rand -base64 48)" --app ciclourbana-ribalta

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.

  1. 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.jar

Có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.

  1. 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 el CaducadorAlquileres y el RecalculadorOcupacion se ejecutarían por duplicado. ShedLock salva exactamente esta situación: la tabla shedlock de la migración V7 hace que solo el primer dyno que tome el bloqueo ejecute la tarea y el otro la salte. Sin ShedLock, escalar a web=2 significarí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.

  1. 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 --tail

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

  1. 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 en logs/ciclourbana.log, ese fichero desaparecería en cada reinicio y sería distinto en cada dyno.
  2. 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.
  3. 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.

  1. 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 estado

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

server:
  forward-headers-strategy: framework

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.

  1. 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-ribalta

Requiere 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.

  1. 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 aplicacion

Complementos 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

  1. 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 addons

heroku 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.

  1. 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:

java.runtime.version=21
maven.version=3.9.9

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.jar

Config 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-ribalta

Correcció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:

2 dynos × 10 + release (1..2) + admin (1..2) = 22..24 > 20

Corrección en dos frentes. En la configuración:

spring:
  datasource:
    hikari:
      maximum-pool-size: ${HIKARI_POOL_SIZE:6}
      minimum-idle: 2
      connection-timeout: 3000

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

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 25s

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

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados