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
- What a PaaS is and what it does for you
- Heroku's concepts
- Heroku today: pricing and equivalent alternatives
- Prerequisites
- Preparing CicloUrbana:
system.propertiesandProcfile - Creating the application and deploying
- The database: Heroku Postgres and
DATABASE_URL - Configuration and secrets with config vars
- Flyway migrations in the release phase
- Scaling, dynos and the daily restart
- Logs and the ephemeral filesystem
- Custom domain and TLS
- Alternative: deploying the container image
- Checking health and monitoring
- Costs and cleanup
- The limits of a PaaS
- Common Mistakes and Tips
- Exercises
- 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.
- 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.
- 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.
- 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 loginheroku 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.
- Preparing CicloUrbana:
system.properties and Procfile
system.properties and ProcfileThe 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:
Procfile —also at the root, with no extension and with exactly that capital letter— declares the process types:
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.$PORTis 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 errorR10 Boot timeout. This is by far the most common failure of a first deployment.MaxRAMPercentage=75applies here exactly as in the container of 07-04: aBasicdyno has 512 MB and exceeding them producesR14 Memory quota exceedederrors and brutal degradation through swapping.target/ciclourbana.jaris the path inside the slug; it matches thefinalNamein thepom.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.
- 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.gitThe 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.
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 HerokuHow 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.
- The database: Heroku Postgres and
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-ribaltaThe 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/d9fk2l1m3nThat 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-ribaltaIt 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.DatabaseUrlTranslatorDetails 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.
- 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 # removeImportant points:
SPRING_PROFILES_ACTIVE=prodactivates the versionedapplication-prod.yml(without secrets) from 07-02: Swagger closed,INFOlogs,ddl-auto: validate, restricted CORS.openssl rand -base64 48generates the HS256 secret in your terminal and sends it without it ever being written to a file. Never reuse the development secret.- Every
config:setcreates 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/Madridsolves 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:
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.
- 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.jarHow 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.
- 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, soRentalExpirerandOccupancyRecalculatorwould run twice. ShedLock saves exactly this situation: theshedlocktable from migrationV7means only the first dyno to take the lock runs the task and the other skips it. Without ShedLock, scaling toweb=2would 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.
- 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 --tailA 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:
- Logs go to
stdout. It is factor 11 of 08-01 and what Logback already does by default in CicloUrbana. If the application wrote tologs/ciclourbana.log, that file would disappear on every restart and would be different on each dyno. - 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.
- 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.
- 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 statusAt 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:
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.
- 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-ribaltaIt 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.
- 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 applicationMonitoring 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 |
- 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 addonsheroku 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.
- 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:
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.jarRequired 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-ribaltaFix 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:
The fix has two fronts. In the configuration:
spring:
datasource:
hikari:
maximum-pool-size: ${HIKARI_POOL_SIZE:6}
minimum-idle: 2
connection-timeout: 3000With 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:
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
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
