The previous lesson left every decision taken and none of them executed. This is the first time CicloUrbana leaves a development machine and answers at a public internet address. And it does so by the shortest route there is: a platform as a service, where somebody else takes care of the operating system, the server, the TLS certificate, restarting the process if it dies and maintaining PostgreSQL, and we are left with the code and a handful of variables.

Heroku is the PaaS that invented much of the vocabulary the whole industry uses today —the Procfile, buildpacks, config vars, git push as a deployment— and that is why it is still the best place to learn the concepts, even though its free tier disappeared in November 2022. Everything we see here transfers almost literally to Railway, Render, Fly.io, Cloud Run or Azure App Service, and section 3 has the equivalence table so you can follow the lesson on whichever platform you prefer.

Contents

  1. What a PaaS is and what it does for you
  2. Heroku's concepts
  3. Heroku today: pricing and equivalent alternatives
  4. Prerequisites
  5. Preparing CicloUrbana: system.properties and Procfile
  6. Creating the application and deploying
  7. The database: Heroku Postgres and DATABASE_URL
  8. Configuration and secrets with config vars
  9. Flyway migrations in the release phase
  10. Scaling, dynos and the daily restart
  11. Logs and the ephemeral filesystem
  12. Custom domain and TLS
  13. Alternative: deploying the container image
  14. Checking health and monitoring
  15. Costs and cleanup
  16. The limits of a PaaS
  17. Common Mistakes and Tips
  18. Exercises

  1. What a PaaS is and what it does for you

A platform as a service is a hosting model in which you hand over code —or an image— and the platform takes care of absolutely everything underneath. In the table of models in 08-01 it was the "very low operational effort" row.

What a PaaS does for you What you give up in control
Operating system and its security updates You do not choose the distribution or the kernel
JRE installation (from a version declaration) Fine JVM tuning limited by the plan's memory
Building the artefact on their servers Little control over the build environment
Starting, supervising and restarting the process There is no systemd and no persistent access to the machine
HTTP load balancer and automatic TLS certificate TLS termination is theirs; you do not choose the cipher configuration
Routing to several instances No fine control of the balancing algorithm
Managed database as an add-on Fewer tunable engine parameters
Log collection and querying Short retention unless you pay for an add-on
Basic metrics and alerts Limited observability compared with your own stack
Rollback to the previous release in one command —

The bargain is explicit: you give up control in exchange for time. For a project like CicloUrbana in its early phase —one developer, a demo for the council, nobody dedicated to infrastructure— it is an excellent bargain. The point at which it stops being one is covered in section 16.

  1. Heroku's concepts

Concept What it is In CicloUrbana
App The unit of deployment: code, configuration, add-ons and domain ciclourbana-ribalta
Dyno The lightweight Linux container where a process runs One web dyno running the JAR
Dyno type Size: Eco, Basic, Standard-1X/2X, Performance-M/L Basic for the demo; Standard-1X with 512 MB for real use
Process type The class of process declared: web, release, worker web (the API) and release (the migrations)
Slug The compressed artefact produced by the build and copied to the dynos The JAR plus the JRE, around 90 MB
Buildpack The script that detects the project type and builds it heroku/java, which detects the pom.xml
Procfile File at the root declaring which command starts each process type web: and release:
Config var Environment variable for the app, managed by the platform SPRING_PROFILES_ACTIVE, JWT_SECRET
Add-on Attachable backing service (factor 4 of 08-01) Heroku Postgres, Papertrail
Release An immutable combination of slug + config vars, numbered (v42) The unit of rollback
Release phase A process run after building and before activating the release Where the Flyway migrations will run
Pipeline Chaining of apps by stage (staging → production) ciclourbana-pre → ciclourbana-ribalta
Review app Ephemeral app created automatically for each pull request Validating a change before merging it

Two concepts deserve a nuance. The first: a release is slug + configuration, so changing a config var creates a new release and restarts the dynos. It is exactly the model from 08-01: there is one artefact, and the release combines artefact and environment.

The second: a dyno is not a named virtual machine, it is an ephemeral container that can be restarted, moved to another machine or duplicated at any moment. Everything we described in 08-01 about disposable, stateless processes applies here literally, and more strictly than on other platforms.

  1. Heroku today: pricing and equivalent alternatives

Important warning: Heroku removed its free tier on 28 November 2022. There are no longer free dynos nor the hobby-dev Postgres plan. To follow this lesson on Heroku you need a card and the spend is real from the first hour: the cheapest plan with a database is around 10-15 dollars a month. There is a Heroku for GitHub Students programme with credit, but it requires an application.

Since the concepts are universal, here is the translation table:

Platform Model Equivalences Free tier Notes
Railway Container PaaS Service ≈ app · Variables ≈ config vars · Plugin ≈ add-on Limited monthly credit Very close to Heroku; detects the pom.xml
Render PaaS Web Service ≈ app · Environment Group ≈ config vars · render.yaml ≈ Procfile + app.json Yes, with suspension when idle Managed Postgres; the free plan expires
Fly.io Containers at the edge Machine ≈ dyno · fly.toml ≈ Procfile · Secrets ≈ config vars Limited credit Deploys images; multi-region deployment
Google Cloud Run Serverless containers Service ≈ app · Revision ≈ release · Secrets from Secret Manager Generous monthly free quota Scales to zero; mind the JVM's cold start
Azure App Service PaaS App Service ≈ app · App Settings ≈ config vars · Deployment Slot ≈ blue-green Very limited free F1 tier Supports Java 21 JARs directly
Clever Cloud European PaaS Application ≈ app · Environment variables ≈ config vars No EU hosting, relevant for municipal data

The concepts transfer almost unchanged: on all of them you have to declare the Java version, listen on the port the platform indicates through an environment variable, set the secrets as variables, connect a managed database by URL and write the logs to stdout. If you follow the lesson on Railway or Render, change the CLI commands and the rest fits.

  1. Prerequisites

# 1. An account at heroku.com with card verification

# 2. Install the CLI (macOS with Homebrew)
brew tap heroku/brew && brew install heroku

# 2b. Linux
curl https://cli-assets.heroku.com/install.sh | sh

# 3. Check and authenticate (opens the browser)
heroku --version
heroku login

heroku login stores an API token in ~/.netrc. That file is a credential: do not copy it into any repository or image. For automated environments there is heroku authorizations:create, which generates a revocable token with scoped permissions —the correct way to give a pipeline access (08-05)— instead of reusing your personal credentials.

You also need the CicloUrbana repository with mvnw versioned and a clean commit: the deployment is literally a git push.

  1. Preparing CicloUrbana: system.properties and Procfile

The Java buildpack (heroku/java) detects the project by the presence of pom.xml and runs ./mvnw -DskipTests clean install. Tests are skipped deliberately: the platform's build is not the place where the module 6 suite runs, that happens in the pipeline (08-05). But the buildpack needs two things you have to declare.

system.properties —at the root of the repository— pins the Java version. Without it, the buildpack uses a default version that may not be 21 and the application will fail with UnsupportedClassVersionError:

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

Procfile —also at the root, with no extension and with exactly that capital letter— declares the process types:

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

Every part matters:

  • web: is a reserved name: it identifies the process that receives external HTTP traffic. Any other name (worker, release) receives no requests.
  • $PORT is mandatory and non-negotiable. Heroku assigns each dyno an arbitrary port at startup time and publishes it in that variable; the router sends traffic there. An application listening on a fixed 8080 receives nothing and after 60 seconds Heroku kills it with the error R10 Boot timeout. This is by far the most common failure of a first deployment.
  • MaxRAMPercentage=75 applies here exactly as in the container of 07-04: a Basic dyno has 512 MB and exceeding them produces R14 Memory quota exceeded errors and brutal degradation through swapping.
  • target/ciclourbana.jar is the path inside the slug; it matches the finalName in the pom.xml.

As an alternative to -Dserver.port, Spring Boot picks up the SERVER_PORT variable through relaxed binding (02-05), so web: java -jar target/ciclourbana.jar works if you define SERVER_PORT=$PORT. The explicit form in the Procfile is preferable because it keeps the dependency in plain sight.

And one prior check that saves a round trip: application.yml must not pin server.port: 8080 in a way that beats the command line. It does not —the command line has higher precedence (02-04)— but it is worth verifying before blaming the platform.

  1. Creating the application and deploying

# From the root of the repository
heroku create ciclourbana-ribalta
# Creating ⬢ ciclourbana-ribalta... done
# https://ciclourbana-ribalta-1a2b3c4d5e6f.herokuapp.com/
# https://git.heroku.com/ciclourbana-ribalta.git

The command does three things: it reserves the name (globally unique across the whole platform; if it is taken, pick another), it assigns a herokuapp.com domain with TLS already working, and it adds a Git remote called heroku to your local repository. Check it with git remote -v.

git add system.properties Procfile
git commit -m "Prepare Heroku deployment"
git push heroku main

That git push is the deployment. What happens next, as read in the build log:

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

How to read it, line by line:

Line What it means What to check if it fails
Java app detected It found the pom.xml If it does not appear, the pom.xml is not at the root
Installing JDK 21 It read system.properties If it installs another version, the file is missing or has a typo
Executing Maven The actual build Compilation and dependency errors surface here
Procfile declares types It read the Procfile If it says (none), the file is not at the root or is called procfile
Compressing... 92.4M Slug size The hard limit is 500 MB; if it gets close, review what is being packaged
Launching... v3 Release number It is the identifier for rolling back

Mind the branch. Only what you push to the heroku remote gets deployed. If you work on develop, git push heroku develop:main is how you deploy that branch onto Heroku's main one.

It still does not work: the database is missing.

  1. The database: Heroku Postgres and 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

The add-on creates a managed PostgreSQL instance and automatically defines a config var called DATABASE_URL. Here comes the detail that breaks the first deployment of every Spring Boot application on Heroku:

postgres://user123:[email protected]:5432/d9fk2l1m3n

That format is not a valid JDBC URL. It follows the twelve-factor convention —a single value with scheme, credentials and destination— but the PostgreSQL driver expects jdbc:postgresql://host:port/database with the username and password separately. If the application tries to use DATABASE_URL as it stands, startup fails with Driver claims to not accept jdbcUrl.

There are two correct ways to solve it.

Option A (recommended): define the three explicit variables. You read the values from the add-on and translate them into the standard Spring properties:

# See the current value
heroku config:get DATABASE_URL --app ciclourbana-ribalta

# Translate into Spring's properties
heroku config:set \
  SPRING_DATASOURCE_URL="jdbc:postgresql://ec2-10-20-30-40.compute-1.amazonaws.example:5432/d9fk2l1m3n?sslmode=require" \
  SPRING_DATASOURCE_USERNAME="user123" \
  SPRING_DATASOURCE_PASSWORD="secretPassword" \
  --app ciclourbana-ribalta

It is explicit, easy to debug and adds no code. Its drawback is real: Heroku rotates the database credentials during maintenance and upgrades, and when it does it changes DATABASE_URL but not your copied variables. You have to keep an eye on the maintenance notices.

Option B: transform DATABASE_URL at startup. An EnvironmentPostProcessor converts the value before the DataSource is created:

package com.ciclourbana.common.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;

/**
 * Translates Heroku's DATABASE_URL (postgres://user:password@host:port/database)
 * into the standard Spring properties. It runs very early during startup,
 * before the DataSource is built.
 */
public class DatabaseUrlTranslator implements EnvironmentPostProcessor {

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication app) {
        String value = environment.getProperty("DATABASE_URL");
        if (value == null || value.startsWith("jdbc:")) {
            return;                                  // not on Heroku, or already translated
        }
        URI uri = URI.create(value);
        String[] credentials = uri.getUserInfo().split(":", 2);

        Map<String, Object> properties = new HashMap<>();
        properties.put("spring.datasource.url",
                "jdbc:postgresql://%s:%d%s?sslmode=require"
                        .formatted(uri.getHost(), uri.getPort(), uri.getPath()));
        properties.put("spring.datasource.username", credentials[0]);
        properties.put("spring.datasource.password", credentials[1]);

        environment.getPropertySources()
                   .addFirst(new MapPropertySource("heroku-datasource", properties));
    }
}

And it is registered in src/main/resources/META-INF/spring.factories:

org.springframework.boot.env.EnvironmentPostProcessor=\
com.ciclourbana.common.config.DatabaseUrlTranslator

Details that explain the code: we use an EnvironmentPostProcessor and not a @Bean because it has to run before the autoconfiguration builds the DataSource (02-06); addFirst gives these properties the highest precedence; the early return makes the class harmless outside Heroku, so the same artefact serves everywhere —the principle from 08-01—; and sslmode=require is mandatory, because Heroku Postgres only accepts encrypted connections.

Backups. The add-on takes automatic backups, and they can also be managed by hand:

heroku pg:backups:schedule DATABASE_URL --at "03:00 Europe/Madrid" --app ciclourbana-ribalta
heroku pg:backups:capture --app ciclourbana-ribalta      # one-off backup
heroku pg:backups                                        # list
heroku pg:backups:download b012                          # download a dump
heroku pg:backups:restore b012 DATABASE_URL              # restore (DESTRUCTIVE)

Remember the principle from 08-01: a backup that has never been restored is not a backup. Try pg:backups:restore on another app, not on the production one.

Plan Approx. cost Connections Storage Use
essential-0 $5/month 20 1 GB Practice and demos
essential-2 $20/month 40 32 GB Small production
standard-0 $50/month 120 64 GB Production with a replica and PITR

Look at the connections column and remember the calculation from 08-01: with essential-0 and 20 connections, two dynos with the default pool of 10 exhaust the limit leaving no headroom for migrations or administration. If you are going to scale to two dynos, lower maximum-pool-size to 5.

  1. Configuration and secrets with config vars

This is where the profiles from 07-02 fit together with the platform:

heroku config:set \
  SPRING_PROFILES_ACTIVE=prod \
  JWT_SECRET="$(openssl rand -base64 48)" \
  TZ=Europe/Madrid \
  JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError" \
  --app ciclourbana-ribalta

heroku config --app ciclourbana-ribalta      # list
heroku config:unset OBSOLETE_VARIABLE        # remove

Important points:

  • SPRING_PROFILES_ACTIVE=prod activates the versioned application-prod.yml (without secrets) from 07-02: Swagger closed, INFO logs, ddl-auto: validate, restricted CORS.
  • openssl rand -base64 48 generates the HS256 secret in your terminal and sends it without it ever being written to a file. Never reuse the development secret.
  • Every config:set creates a new release and restarts the dynos. Group the changes into a single command so as not to cause several restarts in a row.
  • TZ=Europe/Madrid solves the issue from section 7.5 of 08-01: dynos run in UTC.

Security warning. Config vars are encrypted at rest, but they are visible to any collaborator on the app and they appear in heroku config. Apply least privilege within the team, and rotate the JWT secret if somebody leaves the project or if you suspect exposure:

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

The practical consequence of rotating: every token issued with the previous key stops validating, so citizens will have to authenticate again. It is an acceptable nuisance and the reason why the JWT should be short-lived and there should be a refresh token.

  1. Flyway migrations in the release phase

CicloUrbana has seven migrations (V1…V7) and ddl-auto: validate (04-08). There are two ways to apply them, and it is the same dilemma as in section 7.2 of 08-01.

At startup is what the application already does: spring.flyway.enabled: true and Flyway migrates as the context starts. It works with one dyno. With several, they all try to migrate at once and Flyway serialises with a lock: correct, but with two annoying effects —the dynos that wait consume their 60-second startup window, and if the migration fails all the dynos enter a restart loop, leaving the app down even though the previous version worked.

In the release phase is the safe way. You declare a release process type in the 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

How it works: after building the slug and before switching traffic to the new release, Heroku runs the release process on a one-off dyno, with the app's config vars. web-application-type=none makes the application start without Tomcat: the context comes up, Flyway migrates and the process finishes. If it exits with a non-zero code, the release is cancelled and the dynos carry on running the previous version.

At startup Release phase
With several dynos They all wait for the lock It is already migrated when they start
If the migration fails All the dynos fall into a loop The release is aborted, the previous version stays alive
Visibility of the result Mixed into the dyno's log A step of its own, with its own exit code
Startup time Migration + context within the 60 s Only the context
Long migration May exhaust the startup window Has its own time

The release phase is clearly preferable, and it is the exact equivalent of the ECS one-off task (08-03) and the Kubernetes Job (08-04): factor 12 of the twelve factors, admin processes run as ephemeral processes of the same code.

A practical detail: by disabling Flyway in the web process, ddl-auto: validate still acts as a safety net —if for whatever reason the schema does not match, the application does not start instead of failing query by query.

  1. Scaling, dynos and the daily restart

heroku ps --app ciclourbana-ribalta          # current status
heroku ps:scale web=2 --app ciclourbana-ribalta
heroku ps:type web=standard-1x --app ciclourbana-ribalta
heroku ps:restart --app ciclourbana-ribalta
Dyno type RAM Approx. cost/month Sleeps Use
Eco 512 MB $5 (bundle of hours) Yes, after 30 min Practice
Basic 512 MB $7 No Demos
Standard-1X 512 MB $25 No Small production, metrics included
Standard-2X 1 GB $50 No When 512 MB gets tight
Performance-M 2.5 GB $250 No High load

512 MB is tight for Spring Boot. CicloUrbana with Hibernate, Spring Security and the pool starts at around 250-350 MB of heap plus metaspace. With MaxRAMPercentage=75 it fits, but with no headroom for a spike. If R14 errors appear in the log, the correct answer is Standard-2X, not lowering the percentage to something absurd.

The daily restart (dyno cycling). Heroku restarts every dyno at least once every 24 hours, on top of when a config var changes, when you deploy or when the host machine needs it. It is not a fault: it is the platform imposing factor 9, disposability. Consequences for what we have already built:

  • Nothing in memory survives. We already satisfy this: no session, thanks to the JWT (05-04).
  • Nothing on disk survives (section 11).
  • The scheduled tasks from 07-03 are affected. A task with @Scheduled(fixedDelay = ...) resets its counter on every dyno restart; and with two dynos, both have the scheduler active, so RentalExpirer and OccupancyRecalculator would run twice. ShedLock saves exactly this situation: the shedlock table from migration V7 means only the first dyno to take the lock runs the task and the other skips it. Without ShedLock, scaling to web=2 would mean duplicate reports and recalculating occupancy twice a minute.

A nuance about cron tasks: if a restart happens to coincide with the scheduled time, that execution may be lost. For critical tasks it is worth using an external scheduler —Heroku Scheduler is an add-on— that calls an endpoint or launches a one-off process, instead of depending on the dyno being alive at that exact second.

  1. Logs and the ephemeral filesystem

heroku logs --tail --app ciclourbana-ribalta
heroku logs --num 500 --source app --app ciclourbana-ribalta
heroku logs --dyno web.1 --tail

A dyno's filesystem is ephemeral: each dyno has its own copy of the slug with a writable layer that is destroyed on every restart, and two dynos share nothing. Three rules follow from that:

  1. Logs go to stdout. It is factor 11 of 08-01 and what Logback already does by default in CicloUrbana. If the application wrote to logs/ciclourbana.log, that file would disappear on every restart and would be different on each dyno.
  2. Files uploaded by users cannot be stored on the dyno. If tomorrow CicloUrbana accepts incident photos, they go into object storage (S3 or equivalent), not onto disk.
  3. Heroku's retention is 1,500 lines or one week, whichever comes first. For real production you need an aggregation add-on (Papertrail, Logtail) or forwarding to your own system — the full subject of 09-05.

A formatting detail: Heroku's logs are multiplexed across dynos and processes, and a Java exception stack trace appears as dozens of independent lines. It is a good argument for adopting JSON logs (09-05) and for having the TraceFilter with MDC from 03-06 in place: with the trace identifier on every line, reconstructing one request out of thousands of mixed lines stops being an exercise in patience.

  1. Custom domain and TLS

The herokuapp.com domain works with HTTPS from the very first moment. For the council's domain:

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      # check the status

At the DNS provider you create a CNAME pointing to the indicated target. Never an A record: Heroku's IP address changes. With the CNAME propagated, Automated Certificate Management requests and renews the certificate through Let's Encrypt automatically. It requires a paid plan (Basic or above).

Two settings in the application close the circle with 08-01 and 05-05:

server:
  forward-headers-strategy: framework

Heroku's router terminates TLS and speaks HTTP to the dyno, adding X-Forwarded-Proto: https. Without that property, the Location headers of 201 Created responses and the springdoc URLs would come out with http:// and an internal host. And the CORS configuration from 05-05 must allow the final origin https://ciclourbana.ribalta.example, not the test domain.

  1. Alternative: deploying the container image

In 07-04 we built a carefully made image: multi-stage, layered, with a non-root user and JAVA_TOOL_OPTIONS. Heroku can deploy that image instead of building with the 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

It requires a heroku.yml at the root:

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) Container (image)
What you control Little: the Java version and not much else Everything: base, packages, user, time zone
Reproducibility Heroku builds it, opaque environment The image is identical everywhere
Parity with pre/prod Depends on the platform Total: the same image as ECS or Kubernetes
Effort Zero configuration Maintaining the Dockerfile
Fit with 08-03 and 08-04 None Direct

If the medium-term plan is to move out to ECS or Kubernetes, deploying the image from the start means the next step is not a leap: the platform changes, the artefact does not. Watch out for $PORT here too: the ENTRYPOINT of the 07-04 image listens on a fixed 8080, so you have to respect the run: web: entry of heroku.yml or parameterise the port.

  1. Checking health and monitoring

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" }

That /actuator/info with build-info (07-01) answers the question from 08-01: which version is actually running in Ribalta.

A configuration detail specific to Heroku: the separate management port (8081) from 07-01 does not work here, because a dyno can only expose one port, the one in $PORT. On Heroku you have to leave Actuator on the main port behind the security chain we already wrote, with /actuator/health and /actuator/info public and the rest requiring ADMIN:

# application-heroku.yml (or inside the prod profile, conditionally)
management:
  server:
    port: ${MANAGEMENT_PORT:}   # empty = same port as the application

Monitoring extras: Standard dynos include metrics (memory, response time, throughput) in the dashboard; there are APM add-ons; and heroku ps plus the log show the platform's error codes, which are worth knowing:

Code Meaning Usual cause
R10 Boot timeout: it did not listen on $PORT within 60 s The Procfile does not pass $PORT, or startup takes too long
R14 Memory quota exceeded Heap badly sized for the dyno type
H12 Request timeout after 30 s Slow query or an external call with no timeout (07-06)
H10 App crashed Exception during startup; look at the full log

  1. Costs and cleanup

Cost warning. Everything in this lesson is billed by prorated hour from the moment it exists, traffic or no traffic.

Resource Minimum plan Approx. cost/month
Basic dyno 1 dyno $7
Standard-1X dyno 1 dyno $25
Heroku Postgres essential-0 $5
Automatic certificate Included with a paid plan $0
Log add-on Basic plan $0-7
Realistic minimum total ~$12-32/month

Mandatory cleanup when you finish the exercise. Destroying the app also removes its add-ons and its backups:

# 1. (Optional) Download a backup before destroying anything
heroku pg:backups:capture --app ciclourbana-ribalta
heroku pg:backups:download --app ciclourbana-ribalta

# 2. See what is going to be billed
heroku addons --app ciclourbana-ribalta
heroku ps --app ciclourbana-ribalta

# 3. Destroy (asks for confirmation by typing the name)
heroku apps:destroy --app ciclourbana-ribalta --confirm ciclourbana-ribalta

# 4. Verify nothing is left
heroku apps
heroku addons

heroku apps:destroy is irreversible and takes the database and its backups with it. If the custom domain was pointing at it, delete the CNAME at the DNS provider too, so as not to leave a record dangling towards a nonexistent target.

  1. The limits of a PaaS

A PaaS is the right choice until it stops being one. The signals:

Limit How it shows up When it appears
Cost per unit of capacity Four Standard-2X dynos cost more than the equivalent infrastructure When you really scale
No private network of your own You cannot isolate the database in your own subnets or connect to the council's internal systems Network or compliance requirements
Limited control of the JVM and the system There is no fine tuning of the host, nor specific versions of system libraries Specific performance problems
Forced daily restart Incompatible with long processes that do not tolerate interruption Heavy batch jobs
Connections cut off after 30 s (H12) Long requests or SSE need a different design Large downloads, streaming
Vendor lock-in Add-ons, Procfile, release phase and CLI are theirs When you want to migrate
Region and compliance Municipal data that must reside in the EU A legal requirement from day one

The last point is especially relevant for CicloUrbana: the data of Ribalta's citizens is subject to the GDPR and the council may require hosting in the European Union. Heroku has a European region, but it is a decision you have to take when creating the app (heroku create --region eu), not afterwards.

When any of those signals appears, the natural destination is a managed container model with your own network: exactly what the next lesson does.

Common Mistakes and Tips

Listening on a fixed port. Without $PORT, the dyno receives no traffic and dies with R10 after 60 seconds. It is mistake number one.

A lowercase procfile or one inside a subfolder. Heroku does not find it, the log says Procfile declares types -> (none) and the app does not start. It must be called Procfile, at the root, with no extension.

Using DATABASE_URL as if it were JDBC. The failure is Driver claims to not accept jdbcUrl. Translate it with explicit variables or with the EnvironmentPostProcessor, and do not forget sslmode=require.

Forgetting SPRING_PROFILES_ACTIVE=prod. The app starts with the development configuration exposed to the internet: Swagger open, DEBUG logs and possibly H2. It is exactly the scenario of exercise 3 in 07-02.

Scaling to two dynos without reviewing the pool. With essential-0 (20 connections) and maximum-pool-size: 10, two dynos exhaust the limit and the third process —including the release phase— fails with too many clients.

Scaling to two dynos without ShedLock. Reports come out duplicated and occupancy is recalculated twice. Migration V7 from 07-03 is already there: you only have to verify that the tasks carry @SchedulerLock.

Writing files on the dyno. They disappear on the next restart and are not shared between dynos.

Tip: use a pipeline with pre and prod. Two chained apps and heroku pipelines:promote move the already built slug from one to the other, without rebuilding. It is the principle from 08-01 implemented by the platform.

Tip: heroku releases and heroku rollback are your rollback plan. heroku releases lists the numbered releases and heroku rollback v41 goes back to the previous one in seconds. With the known caveat: the database does not come back, so the schema must remain backwards compatible.

Tip: heroku run bash to inspect. It opens a one-off dyno with the slug and the config vars. Perfect for debugging and also a reminder of why secrets are secrets: whoever can run that sees all of them.

Exercises

Exercise 1

Prepare the CicloUrbana repository for Heroku without deploying anything yet: write system.properties and a Procfile with the web and release processes, decide which config vars are needed and in what order everything runs from git push until the app serves its first request. Explain what would happen if each of the two files were missing.

Exercise 2

CicloUrbana is deployed and the log shows, in this order: Java app detected, BUILD SUCCESS, Launching... v3, and then a loop of at=error code=H10 desc="App crashed" with the exception java.lang.IllegalStateException: Cannot load driver class: org.postgresql.Driver ... jdbcUrl is required with driverClassName. The app has the heroku-postgresql:essential-0 add-on created and heroku config shows DATABASE_URL. Diagnose it, fix it in both possible ways and explain which one you would choose for a project planning to migrate to AWS in six months.

Exercise 3

CicloUrbana has been on Heroku for a month with web=1, a Basic dyno and essential-0. The council asks for high availability, so it is scaled to web=2. Within 48 hours three problems appear: (a) the nightly occupancy report arrives twice by email, (b) the log shows FATAL: sorry, too many clients already intermittently and (c) during deployments some citizens receive 503. Explain the cause of each one and propose the complete correction, indicating what changes in the application, what in the configuration and what in the platform plan.

Solutions

Solution 1

system.properties at the root:

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

Procfile at the root:

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

Required config vars:

Variable Value Why
SPRING_PROFILES_ACTIVE prod Activates application-prod.yml (07-02)
SPRING_DATASOURCE_URL jdbc:postgresql://...?sslmode=require Translation of DATABASE_URL
SPRING_DATASOURCE_USERNAME / _PASSWORD from the add-on Likewise
JWT_SECRET openssl rand -base64 48 HS256 signing (05-04); never the development one
TZ Europe/Madrid Dynos run in UTC (07-03)
JAVA_TOOL_OPTIONS -XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError Avoid R14

Full sequence: git push heroku main → the buildpack detects the pom.xml → installs JDK 21 as read from system.properties → runs ./mvnw -DskipTests clean install → compresses the slug → reads the Procfile and discovers the release and web types → runs the release process on a one-off dyno, which brings up the context without Tomcat, applies V1…V7 and finishes with code 0 → if the code is 0, it activates release v3 → the web dyno starts, listens on $PORT, validates the schema with ddl-auto: validate and is ready → the router starts sending it traffic.

If system.properties is missing: the buildpack installs its default JDK. If it is older than 21, the build fails compiling Java 21 code, or —worse— it builds and startup dies with UnsupportedClassVersionError.

If the Procfile is missing: the Java buildpack tries to guess the start command from the JAR. It may even work, but without $PORT, so the dyno does not listen where it should and dies with R10 Boot timeout. And there would be no release phase, so the migrations would have to be applied at startup.

Solution 2

Diagnosis. The build succeeded and the app started, so the problem is runtime configuration. The jdbcUrl is required message comes from HikariCP: it did not find spring.datasource.url. The add-on defined DATABASE_URL, but Spring Boot does not recognise it as one of its properties —it expects SPRING_DATASOURCE_URL— and even if it read it, postgres://user:password@host:5432/database is not a JDBC URL: it lacks the jdbc: prefix and carries embedded credentials that the driver does not accept there.

Fix A — explicit variables:

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

Fix B — EnvironmentPostProcessor: the DatabaseUrlTranslator class from section 7, registered in META-INF/spring.factories, which translates during startup and does nothing outside Heroku.

Which to choose with AWS six months away: A. The reasoning: option B introduces Heroku-specific code inside the artefact, precisely what 08-01 asks you to avoid. It is a piece you would have to maintain, test and, predictably, delete during the migration. Option A solves the problem outside the binary, with the three standard Spring properties, which are exactly the ones that will be used with RDS: migrating will consist of changing their values. The known drawback —Heroku rotates the credentials— is mitigated by documenting it in the runbook and subscribing to the add-on's maintenance notices.

If the horizon were staying on Heroku for years with frequent rotations, B would be defensible; even then, isolated in an integration package and activated by an explicit condition.

Solution 3

(a) The report arrives twice. Both dynos have @EnableScheduling active and each runs its own copy of the task. It is the problem from 07-03 with several instances, now in production. The concrete cause is that the report task does not carry @SchedulerLock, or that the ShedLock configuration is missing. Fix in the application:

@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid")
@SchedulerLock(name = "dailyOccupancyReport",
               lockAtMostFor = "PT30M", lockAtLeastFor = "PT5M")
public void generateDailyReport() { ... }

lockAtMostFor releases the lock if the dyno dies halfway; lockAtLeastFor prevents a second dyno from running it if the first finished in milliseconds because of a skewed clock. The shedlock table has existed since migration V7, so nothing new is needed in the database. Verification: the log of the dyno that does not run it should show the ShedLock trace stating that it did not acquire the lock.

(b) too many clients already. The essential-0 plan allows 20 connections and maximum-pool-size is 10: two dynos consume exactly those 20, leaving none for the release phase, for heroku pg:psql or for the add-on's monitoring. That is why it is intermittent: it fails precisely when something else asks for a connection. It is the calculation from 08-01:

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

The fix has two fronts. In the configuration:

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

With 2 × 6 = 12 there are 8 connections of headroom. And in the plan: move up to essential-2 (40 connections) if performance with 6 is not enough. Remember the principle from 08-01: a large pool is not faster; with 6 connections per dyno and indexed queries, CicloUrbana comfortably serves the traffic of a small city.

(c) 503 during deployments. When deploying, Heroku sends SIGTERM to the old dynos and starts the new ones, but the router may keep sending requests to the dyno that is shutting down for a brief window. If the process stops accepting connections all at once, those requests fail. Fixes:

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

Graceful shutdown (01-05) lets in-flight requests finish. Heroku allows 30 seconds between SIGTERM and SIGKILL, so 25 s leaves headroom. Complements: scaling to web=3 so that there is always spare capacity during the changeover, and avoiding unnecessary restarts by grouping the config:set calls into a single command instead of chaining several, each with its own release and its own restart.

Summary of the plan. In the application: @SchedulerLock on the tasks and shutdown: graceful with its timeout. In the configuration: maximum-pool-size lowered to 6 and an explicit connection-timeout. In the platform plan: essential-2 if the reduced pool gets tight, and consider web=3. And an underlying conclusion: the three symptoms appeared the day there was more than one instance, which is the moment factors 6, 8 and 9 stop being theory.

Conclusion

CicloUrbana is now on the internet. Anyone with the address can look up Ribalta's stations, authenticate and rent a bike, over HTTPS, with a managed database and automatic backups, and all of it has been achieved without administering a single server. You know what a PaaS does for you and what control you give up in exchange, and you handle its full vocabulary —app, dyno and its types, slug, buildpack, Procfile, config var, release, release phase, pipeline and review app—, with the clear warning that Heroku no longer has a free tier and the table of alternatives where those same concepts appear under other names.

You have prepared the project with system.properties and a Procfile that respects the rule that breaks the most deployments —listening on $PORT—, you have read the build log line by line knowing what to check in each one, and you have solved the classic problem of Heroku's DATABASE_URL, which follows the twelve factors but is not a valid JDBC URL, with both solutions and the criterion for choosing between them. The profiles from 07-02 fitted together with the config vars, the JWT secret was generated outside the repository and you know why and how it is rotated. The Flyway migrations moved to the release phase, which aborts the release if they fail instead of leaving every dyno in a loop: factor 12 in its most concrete form.

You also know what the platform imposes: dynos with tight memory where MaxRAMPercentage stops being a detail, a daily restart that turns disposability into a fact and that requires ShedLock to be properly in place before scaling to two dynos, an ephemeral filesystem that confirms why logs go to stdout, and the calculation of the HikariCP pool against the plan's connection limit. You have added the council's domain with an automatic certificate and forward-headers-strategy so that the URLs come out right behind the proxy, you have checked with /actuator/health and /actuator/info which version is really running, and —very importantly— you have destroyed the app after finishing the exercise, because all of this is billed by the hour.

And you know where the limits are: cost per unit of capacity when scaling, the absence of a private network of your own, reduced control of the environment, the 30-second cut-off, vendor lock-in and the question of region for municipal data subject to the GDPR. When those signals appear, the next step is real infrastructure that is still managed. The next lesson, Deploying to AWS, builds it: an image published in ECR, PostgreSQL in RDS inside private subnets the internet cannot reach, secrets in Secrets Manager, an ECS Fargate service with its task definition, and a load balancer with an ACM certificate that health-checks against /actuator/health/readiness. With the usual warning, and here more seriously: RDS and the load balancer are billed by the hour even if nobody uses the application.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved