Skip to content
CalliCoder

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:

ResourceTierNotes
DynoEcoa pooled monthly allowance shared across the account, sleeps when idle
DynoBasicnever sleeps, one process
PostgresEssential-0row-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 $PORT within 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. Set MaxRAMPercentage; 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.