Deploying a Spring Boot Application to Heroku
Spring Boot 13 min read
Three deployment routes, the $PORT binding that decides whether the dyno boots at all, why the database URL Heroku injects is not a JDBC URL, and what the end of the free tier means for a hobby project.
Heroku deploys a Spring Boot application with no Dockerfile and no server configuration: it detects a
Maven project, builds it, and runs whatever the Procfile says. Two details decide whether the first
deploy works, the port binding and the database URL format, and both fail in ways the build log
does not explain.
Written against Spring Boot 3.2 and Java 17.
Costs, before anything else
Heroku has no free tier. Free dynos and free Postgres were withdrawn in November 2022, so every deployment described here bills from the first hour:
| Resource | Tier | Notes |
|---|---|---|
| Dyno | Eco | a pooled monthly allowance shared across the account, sleeps when idle |
| Dyno | Basic | never sleeps, one process |
| Postgres | Essential-0 | row-limited, no free option |
An Eco dyno sleeping after inactivity means a cold start of several seconds on the next request, which for a JVM application is genuinely noticeable. Basic is the first tier that stays warm.
Binding to $PORT
Heroku assigns a port at runtime and passes it in the environment. An application that listens on
8080 is unreachable, and the platform kills it after 60 seconds with R10 Boot timeout — an error
that names the timeout rather than the cause.
server.port=${PORT:8080}
That reads PORT when Heroku sets it and falls back to 8080 locally. The alternative is to put it in
the Procfile:
web: java -Dserver.port=$PORT -jar target/api-0.0.1-SNAPSHOT.jar
Either works; the property is harder to forget.
The Procfile
web: java -Xss512k -XX:MaxRAMPercentage=75 -jar target/api-0.0.1-SNAPSHOT.jar
No extension, at the repository root. web is the process type that receives HTTP traffic — any
other name defines a worker that does not.
Without a Procfile, the Java buildpack guesses a command from the built artifact, which works until
the artifact name changes. Two JVM flags earn their place: MaxRAMPercentage keeps the heap inside
the dyno’s memory limit, and the smaller thread stack matters because an R14 Memory quota exceeded
does not stop the dyno, it just swaps and gets slow.
Pin the Java version explicitly, since the buildpack default moves:
# system.properties
java.runtime.version=17
Route 1: Git push
heroku login
heroku create my-spring-api
git push heroku main
heroku logs --tail
The buildpack detects pom.xml, runs ./mvnw -DskipTests clean install, and starts the Procfile
command. This is the route with the fewest moving parts and the one that fits a CI pipeline worst,
because it needs the deployment to be a git push from a checkout.
Route 2: The deploy plugin, for an existing jar
heroku plugins:install java
./mvnw -DskipTests clean package
heroku deploy:jar target/api-0.0.1-SNAPSHOT.jar --app my-spring-api
This uploads a jar that was built elsewhere, which is what you want from CI: the artifact that was tested is the artifact that ships, rather than a second build on Heroku’s machine from the same source.
Route 3: The Maven plugin
<plugin>
<groupId>com.heroku.sdk</groupId>
<artifactId>heroku-maven-plugin</artifactId>
<version>3.0.7</version>
<configuration>
<appName>my-spring-api</appName>
<includeTarget>false</includeTarget>
<includes>
<include>target/api-0.0.1-SNAPSHOT.jar</include>
</includes>
<jdkVersion>17</jdkVersion>
<processTypes>
<web>java -XX:MaxRAMPercentage=75 -jar target/api-0.0.1-SNAPSHOT.jar</web>
</processTypes>
</configuration>
</plugin>
./mvnw heroku:deploy
Same effect as route 2 with the configuration in the POM instead of a command line.
The database URL is not a JDBC URL
Adding Postgres sets a DATABASE_URL config var:
heroku addons:create heroku-postgresql:essential-0
heroku config:get DATABASE_URL
postgres://user:password@host:5432/dbname
Spring cannot use that. It expects jdbc:postgresql://host:5432/dbname with the credentials as
separate properties, and pointing spring.datasource.url at the raw value produces
Driver claims to not accept jdbcUrl — an error that reads like a missing dependency.
Heroku’s Java buildpack sets JDBC_DATABASE_URL, JDBC_DATABASE_USERNAME and
JDBC_DATABASE_PASSWORD alongside it, in the form Spring wants:
spring.datasource.url=${JDBC_DATABASE_URL:jdbc:postgresql://localhost:5432/local_db}
spring.datasource.username=${JDBC_DATABASE_USERNAME:postgres}
spring.datasource.password=${JDBC_DATABASE_PASSWORD:}
spring.jpa.hibernate.ddl-auto=validate
The fallbacks keep the same file working locally. ddl-auto=validate rather than update, because a
schema change on a running dyno should come from a migration — Flyway runs at startup and is the
usual pairing, covered in the Flyway
walkthrough.
Heroku rotates database credentials without notice, so read them from the environment on every boot rather than copying them into config vars of your own.
Configuration and profiles
heroku config:set SPRING_PROFILES_ACTIVE=prod
heroku config:set JWT_SECRET=...
Config vars arrive as environment variables, and Spring’s relaxed binding maps
SPRING_PROFILES_ACTIVE to spring.profiles.active and APP_JWT_SECRET to app.jwt.secret. That
is the whole mechanism — there is no Heroku-specific configuration API. The same
configuration-properties binding applies unchanged.
Never commit a secret and set it as a config var instead; heroku config prints them, and anyone
with access to the app can read them.
Reading a failed boot
heroku logs --tail --app my-spring-api
heroku ps --app my-spring-api
Three error codes cover most first deployments:
R10 Boot timeout— the process did not bind to$PORTwithin 60 seconds. Either the port binding is wrong, or startup genuinely takes longer than a minute.R14 Memory quota exceeded— the JVM heap plus metaspace exceeds the dyno. SetMaxRAMPercentage; the JVM’s own default assumes it owns the machine.H10 App crashed— the process exited. The real exception is above it in the log, usually a datasource that could not connect.
Migrations and the release phase
Running Flyway at application startup works and has one flaw: with more than one dyno, several instances race to migrate the same database at the same moment. Flyway takes a lock, so the outcome is correct rather than corrupt, but the losing dynos wait — and if a migration is slow they hit the boot timeout and are killed mid-release.
Heroku’s release phase runs a one-off command after the build and before the new dynos start:
release: java -cp target/api-0.0.1-SNAPSHOT.jar org.springframework.boot.loader.launch.JarLauncher --spring.flyway.enabled=true --spring.main.web-application-type=none
web: java -XX:MaxRAMPercentage=75 -jar target/api-0.0.1-SNAPSHOT.jar
A failing release command aborts the deploy and leaves the previous version serving, which is the behaviour you want from a migration that cannot apply.
The filesystem, and what that rules out
Each dyno has its own ephemeral filesystem. It is discarded on every restart, and Heroku restarts dynos at least once a day. Two dynos do not share it, so a file written by one is invisible to the other.
That rules out the obvious things — uploaded files, generated reports, a local cache with any expectation of survival — and one less obvious one: a file-based session store or an embedded database used as anything but a test fixture. Uploads belong in an object store, sessions in Redis or a signed cookie, and logs on stdout, which Heroku collects.
Scaling is a dyno count rather than a machine size:
heroku ps:scale web=2 --app my-spring-api
Two dynos means two JVMs behind Heroku’s router, so anything held in application memory — a scheduled job, an in-process cache, a rate limiter — now exists twice and neither copy knows about the other.
Whether Heroku is the right target
The value is that there is nothing to operate: no base image, no reverse proxy, no TLS certificate. The costs are per-dyno pricing that compares poorly with a small VM, a filesystem that is ephemeral between restarts, and a build that runs on someone else’s machine.
For a Spring Boot API with a database and no unusual requirements it remains one of the shortest paths to a URL. A container on Kubernetes or Elastic Beanstalk trade that simplicity for control.
Frequently asked questions
Does Heroku still have a free tier?
No. Free dynos and free Postgres ended in November 2022. Eco is the cheapest dyno tier and it sleeps when idle; Basic is the cheapest that does not.
Why does my app fail with R10 Boot timeout?
It is not listening on the port Heroku assigned. Set
server.port=${PORT:8080} or pass -Dserver.port=$PORT in the Procfile.
Do I need a Procfile?
Not strictly — the buildpack infers a command — but without one the deployment breaks whenever the artifact name changes, and you cannot pass JVM flags.
Why does the datasource reject DATABASE_URL?
It is a postgres:// URI, not a JDBC URL. Use
JDBC_DATABASE_URL, which the Java buildpack derives in the format Spring expects.
How do I pin the Java version?
A system.properties file at the repository root with
java.runtime.version=17, the buildpack default changes over time.
What causes R14 Memory quota exceeded?
The JVM sized its heap for the whole machine rather than
the dyno. -XX:MaxRAMPercentage=75 keeps it inside the limit. R14 does not crash the app; it makes
it slow.
Can I deploy a jar built by CI instead of pushing source?
Yes — heroku deploy:jar from the
Java CLI plugin, or the heroku-maven-plugin. Both ship the artifact you tested rather than
rebuilding it on Heroku.
Where do I put secrets?
Config vars, set with heroku config:set, read as environment variables.
Spring’s relaxed binding maps them onto properties without extra code.
Is the filesystem persistent?
No. Each dyno gets an ephemeral filesystem that is discarded on restart, and restarts happen at least daily. Anything that must survive belongs in the database or an object store.
How do I see why the app crashed?
heroku logs --tail. The H10 App crashed line is the symptom;
the stack trace above it is the cause, most often a datasource that could not connect.