RE:NODE

Deploy a Spring Boot app to production on a Java server

By RE:NODE Team
21 min read


A Spring Boot application deploys as one file: a fat jar that carries your code, its dependencies and an embedded web server. The server needs a Java runtime of the right version, the jar, a port to listen on, and a heap that fits inside the memory limit you pay for. The last of those is where nearly every failed Spring Boot deployment goes wrong, because the JVM is happy to ask for more memory than a small plan has, and a container that is stopped for exceeding its limit looks, from the outside, like a crash with no explanation.

This post covers the whole path for Spring Boot, with notes where Quarkus and Micronaut differ: building the jar with Maven or Gradle, choosing a Java version, binding to the right port, sizing the heap against the plan, keeping configuration out of the jar, connecting a PostgreSQL or MySQL database through HikariCP, exposing a health endpoint, running behind a proxy so client addresses survive, and the errors you will see when something is off.

What the server needs to run a fat jar#

A fat jar (also called an uber jar or executable jar) is a normal jar with the dependency jars nested inside it and a launcher class that knows how to read them. Spring Boot's Maven and Gradle plugins produce it, and java -jar app.jar is the entire run command. There is no application server to install, no WAR to drop into a Tomcat, and no classpath to assemble by hand.

What that means for hosting is short:

  • A JRE, not a JDK. You compile on your own machine or in CI. The server only runs the result, so the runtime is all it needs.
  • The right major version. Spring Boot 3 requires Java 17 or newer. Spring Boot 2.7 and older run on Java 8 to 17 but are out of open-source support. RE:NODE's Java app line runs any jar on Java 17, 21 or 25, so a Boot 3 application fits on all three, and Boot 2.x applications are only reasonable on 17.
  • A single listening port. One embedded server, one port. Actuator can be moved to a second port, but on a plan with one allocation you will keep it on the main one and protect it differently.
  • Memory for more than the heap. The JVM's footprint is the heap plus metaspace, thread stacks, the code cache, direct buffers and garbage collector structures. A heap of 512 MB commonly means a process of 700 to 800 MB. Most of the sizing section below exists to make that sentence concrete.

Pick the Java version by what you compile for, not by what is newest. Compile with --release 17 or the matching Maven and Gradle setting, and a jar built for 17 runs unchanged on 21 and 25. Going the other way fails with UnsupportedClassVersionError, covered in the troubleshooting section. If your dependencies are current, Java 21 is the sensible default: virtual threads are available, the garbage collectors are better tuned for containers, and it has a long support horizon. Java 25 is the newer line and worth testing if you want to be on the latest long-term release.

Building the jar with Maven or Gradle#

Build in CI or on your own machine, run the tests there, and ship the artefact. The build is not something to do on a small production plan, for the same reason Rust builds are not: Maven or Gradle with a warm dependency cache is happy, a cold build on a 1 GB container is slow and can reach the memory limit.

For Maven, the Spring Boot parent or the spring-boot-maven-plugin makes the jar executable:

bash
$ ./mvnw -B clean package -DskipTests=false$ ls target/*.jartarget/shop-api-1.4.0.jar  target/shop-api-1.4.0.jar.original

The .original file is the thin jar before repackaging. Deploy the other one. For Gradle:

bash
$ ./gradlew clean bootJar$ ls build/libs/shop-api-1.4.0.jar

bootJar is the task that produces the executable archive; plain jar produces a thin one that will not start. Gradle builds with the org.springframework.boot plugin applied also produce a -plain.jar, which is likewise not what you want.

Three details save a deployment later:

  1. Give the jar a stable name. With Maven set <finalName>app</finalName> inside <build>; with Gradle set bootJar { archiveFileName = 'app.jar' }. A start command that points at app.jar does not need editing on every release.
  2. Build info for the health endpoint. spring-boot-maven-plugin has a build-info goal and the Gradle plugin has springBoot { buildInfo() }. With either, Actuator's /actuator/info can report the version that is actually running, which answers the first question in any incident.
  3. Check the manifest. unzip -p app.jar META-INF/MANIFEST.MF should show a Main-Class of org.springframework.boot.loader... and a Start-Class of your application. If it does not, the repackage step did not run.

Quarkus and Micronaut differ in the artefact. Quarkus's default quarkus-app layout is a directory, not a single file, with quarkus-run.jar and a lib folder next to it. If you want one file, set quarkus.package.jar.type=uber-jar and build with ./mvnw package, and run java -jar target/*-runner.jar. Micronaut's shadowJar Gradle task and its Maven Shade configuration produce a single runnable jar. Everything below about heap, ports and databases applies to both, with different property names.

Uploading and starting it#

The way a jar reaches the server decides how much of the rest you can automate. There are three routes: upload it by hand through the file manager or SFTP; pull a repository from GitHub and build there; or build in CI and upload only the artefact.

On RE:NODE, each app server has the file manager, which unpacks archives in place, and SFTP with credentials per server. Both are fine for a jar. The panel also supports deploys from GitHub through a GitHub App, which works for private repositories, with two switches: pull on every start, and deploy on push, which restarts a running server and starts a stopped one. That is a source-to-server path, so it only helps if the server itself is allowed to build; for a jar built in CI, uploading the artefact is simpler and keeps the build off the production memory budget. If you use the git route, commit the built jar or use a branch that holds only release artefacts, rather than expecting the plan to run Maven.

When a Java server is created, it starts with a small working page on its address until you deploy your own code, and that page never overwrites your files. Once your jar is in place and the start settings point at it, the page is replaced by your application. The Startup tab is where environment variables are set, and the console shows live output, with start, stop, restart and kill buttons.

uploadjava -jarJDBCHTTPSHTTPYour buildmvn packageClientsapp.jarfat jarProxy slotTLS, X-Forwarded-ForJVM-Xmx, MaxRAMPercentageDatabaseHikariCP pool
From a build to a running Spring Boot app

Ports: server.port and SERVER_PORT#

The listening port is the first thing that breaks. Spring Boot defaults to 8080. On a hosting plan you are given an allocated port, and your application has to listen on that one, not on whatever you used locally.

Spring Boot reads the same property from several places, and the order matters. Command-line arguments win over OS environment variables, which win over application.properties inside the jar. The environment variable SERVER_PORT is the relaxed-binding form of server.port, so setting it in the environment needs no code at all:

environment
SERVER_PORT=25565SPRING_PROFILES_ACTIVE=prod

The number above is a placeholder; use the port shown for your server. On panels derived from Pterodactyl the allocation is commonly exposed as SERVER_PORT, so check the Startup tab and the Network tab for what your server actually provides before assuming. If the variable already exists and holds the allocation, you do not set anything: Spring will pick it up as server.port the moment it starts.

If you prefer to keep it in the file, a reference with a default is a good pattern, because it works locally and in production:

application.properties
server.port=${SERVER_PORT:8080}

The address is the other half. Spring's embedded server listens on all interfaces by default, so you do not need server.address. Do not set it to 127.0.0.1 for production - that makes the app reachable only from inside its own container, and the proxy will report a bad gateway. Quarkus binds to localhost by default in some modes and needs quarkus.http.host=0.0.0.0 and quarkus.http.port=${SERVER_PORT:8080}. Micronaut reads micronaut.server.port, and honours the MICRONAUT_SERVER_PORT environment variable.

Sizing the JVM against the plan's memory limit#

This is the section that matters most. A container has a hard memory limit, and the JVM, if you let it, will decide for itself how large to make the heap. Modern JVMs read the container limit through cgroups and default the maximum heap to one quarter of it. That is a safe default, and a wasteful one: on a 2 GB plan it gives you a 512 MB heap and leaves 1.5 GB idle.

What happens at the limit is a platform matter. On RE:NODE, when a process reaches the memory limit, the kernel stops the container and it restarts clean instead of swapping. Upstream Pterodactyl does the opposite and lets the machine swap. Clean stopping is better for a production service, because a swapping JVM produces garbage collection pauses of seconds and then dies slowly. It also means that if you set the heap too large, the symptom is a restart with nothing in the application log: the process was killed from outside, and the JVM never got a chance to write an OutOfMemoryError.

There are two different failures to tell apart:

  • `java.lang.OutOfMemoryError: Java heap space`. The heap is too small for your data. The JVM logs it, and the heap needs to be larger, or the application has a leak.
  • A restart with no Java error. The whole process went over the container limit. The heap is too large for the plan, or non-heap memory has grown. The heap needs to be smaller.

Three ways to set the heap, in order of preference:

bash
# 1. A percentage of the container limit (recommended)$ java -XX:MaxRAMPercentage=65.0 -XX:InitialRAMPercentage=40.0 -jar app.jar# 2. An absolute ceiling$ java -Xms256m -Xmx768m -jar app.jar# 3. Environment only, no command changeJAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=65.0 -XX:+ExitOnOutOfMemoryError"

MaxRAMPercentage scales when you change plan, which is the main reason to prefer it: move from 2 GB to 4 GB and the heap follows, with no edit. -Xmx is exact and is the right choice when you have measured and want a fixed value. When both are present, -Xmx wins. JAVA_TOOL_OPTIONS is read by every JVM that starts, which is useful when you cannot edit the start command; the JVM prints Picked up JAVA_TOOL_OPTIONS: ... at the top of the console so you can confirm it took effect. Note that it also applies to any other Java process that starts in the same environment.

The percentage to choose depends on the plan, because the fixed overhead is a bigger share of a small one:

Plan memoryHeap settingReason
1 GB-Xmx384m to -Xmx512m500 MB or more goes to non-heap
2 GBMaxRAMPercentage=60About 1.2 GB heap, 800 MB for the rest
4 GBMaxRAMPercentage=65Non-heap overhead is a smaller share
6 GB and upMaxRAMPercentage=70Leave room for direct buffers and spikes

These are starting points, not measurements of your app. A service that holds large caches or does a lot of NIO will want more headroom. A tiny API with a modest dependency tree runs in less. Watch the memory graph in the console against the limit for a day of real traffic and adjust.

A few more flags are worth knowing:

FlagEffect
-XX:+ExitOnOutOfMemoryErrorExit on a heap OOM so the platform restarts it clean
-XX:MaxMetaspaceSize=256mCap class metadata, which can leak on redeploys
-Xss512kSmaller thread stacks, if you run hundreds of threads
-XX:+UseSerialGCOn 1 CPU or under 1.5 GB, the lowest overhead collector

ExitOnOutOfMemoryError is the one most people skip and should not. A JVM that has thrown a heap OOM in one thread is often alive but wrecked, serving some requests and failing others. Exiting lets the restart behaviour described above give you a clean process. Be aware that restarts are watched: the crash detection polls every two minutes for uptime that went backwards or a server that went offline, restarts you ask for are not counted, three restarts in an hour raises a warning and an automatic ticket, and six means suspension. A service that restarts in a loop because the heap is too large is exactly what that is looking for, so fix sizing before you leave it running.

The collector matters on small plans. Java picks G1 on machines with two or more CPUs and at least 1792 MB, and falls back to the serial collector otherwise. G1 is a good default at 2 GB and up. Do not tune it unless a graph tells you to. CPU vs RAM for game servers makes the general point about measuring which resource is binding before buying more of the other, and it applies unchanged to a JVM.

CPU is a hard throttle to the share you bought. The JVM sizes its own thread pools from the CPU count it sees, which is derived from the container quota and rounded up. A service on a half-core share can believe it has one, so the compiler threads and GC threads compete with request threads. If startup is slow or latency spikes in bursts, -XX:ActiveProcessorCount=1 pins the JVM's view of the machine and is worth trying on the smallest tiers.

Configuration: application.properties and environment variables#

The jar is the same everywhere. What changes between your laptop and production is configuration, and none of it should live inside the jar. Spring Boot's externalised configuration gives you a clear order, from lowest to highest priority:

  1. application.properties or application.yml packaged inside the jar.
  2. The same files beside the jar, in the working directory or in a config/ folder next to it.
  3. Profile-specific files such as application-prod.properties.
  4. OS environment variables.
  5. Java system properties (-Dkey=value) and command-line arguments (--key=value).

The consequence is useful: ship defaults inside the jar, and override only what differs in production using the environment. The binding rule for environment variables is mechanical - upper case, dots and dashes become underscores:

PropertyEnvironment variable
server.portSERVER_PORT
spring.datasource.urlSPRING_DATASOURCE_URL
spring.datasource.hikari.maximum-pool-sizeSPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE
management.endpoint.health.show-detailsMANAGEMENT_ENDPOINT_HEALTH_SHOWDETAILS

Notice the pool size row: the dash in maximum-pool-size is dropped rather than converted, and the name runs together. Underscores inside the key name are the usual cause of an environment variable that appears to do nothing. When in doubt, hit /actuator/env in a test environment, or set --debug once and read the condition report in the console.

Keep secrets out of the jar and out of the repository. Database passwords, API keys and signing secrets belong in environment variables, which on RE:NODE are set on the Startup tab. Environment variables and secrets covers where each kind belongs and what not to log. A config file that sits beside the jar is the alternative for values you want to edit without a restart cycle in mind; the file manager's editor with syntax highlighting makes that comfortable. Spring does not reload application.properties while running, so a change always needs a restart.

A profile switch ties it together. Keep application.properties for the shared defaults and application-prod.properties for what production changes, and activate it with SPRING_PROFILES_ACTIVE=prod:

application-prod.properties
server.port=${SERVER_PORT:8080}server.forward-headers-strategy=nativeserver.shutdown=gracefulspring.lifecycle.timeout-per-shutdown-phase=20sspring.jpa.open-in-view=falsespring.jpa.hibernate.ddl-auto=validatespring.main.lazy-initialization=falselogging.level.root=INFO

spring.jpa.open-in-view=false stops Spring holding a database connection for the whole of every web request. It is on by default and logs a warning at startup because it is a common source of pool exhaustion. ddl-auto=validate makes the application check the schema rather than change it; production schemas belong to a migration tool such as Flyway or Liquibase, not to Hibernate's guesses.

Connecting a PostgreSQL or MySQL database#

Spring Boot auto-configures a DataSource from three properties and a driver on the classpath. The driver is a dependency: org.postgresql:postgresql for PostgreSQL, com.mysql:mysql-connector-j for MySQL. The URL carries the host, port and database name:

environment
SPRING_DATASOURCE_URL=jdbc:postgresql://db-host:5432/shop?sslmode=preferSPRING_DATASOURCE_USERNAME=shop_appSPRING_DATASOURCE_PASSWORD=change-me

For MySQL the URL starts jdbc:mysql://host:3306/dbname, and the driver class is found automatically from the URL. The host, user and password come from wherever the database lives. Each app server has database slots, created in the panel with a generated host, user and password. For a standalone database there is database hosting with PostgreSQL and MySQL 8.4 LTS, whose credentials are generated per server and reached on the plan's own host and port. Copy the values into the three variables above, not into the jar. The PostgreSQL remote connection guide and the MySQL one go through connection strings in more detail.

HikariCP pool sizing

Spring Boot uses HikariCP by default. It keeps a pool of open connections and hands them to requests. The default maximum-pool-size is 10, and minimum-idle defaults to the same, so a Boot application opens ten connections at start and holds them. That is a lot for a small database.

application-prod.properties
spring.datasource.hikari.maximum-pool-size=6spring.datasource.hikari.minimum-idle=2spring.datasource.hikari.connection-timeout=10000spring.datasource.hikari.idle-timeout=300000spring.datasource.hikari.max-lifetime=1500000spring.datasource.hikari.keepalive-time=120000

The values to understand:

  • `maximum-pool-size` is the hard ceiling on connections from this process. Bigger is not faster. A pool of 6 serving a service on one or two cores handles far more traffic than most people expect, because a query that takes 5 ms lets one connection serve 200 requests a second. If requests queue for connections, look for slow queries before raising the number.
  • `connection-timeout` is how long a request waits for a free connection before failing, in milliseconds. The default of 30 seconds is too long to tolerate in a web request; 10 seconds or less fails visibly and quickly.
  • `max-lifetime` must be shorter than any connection time limit on the database or a network device between you and it. Hikari retires a connection before the server can close it underneath you. The default is 30 minutes, and a value a few minutes under the real limit is correct.
  • `keepalive-time` pings idle connections, which stops stateful firewalls dropping them silently.

Count connections across everything that connects. If you run two instances of the app, or a migration job beside it, the sums add up against the database's own connection limit. Connection pools and limits has that arithmetic, and the MySQL connection limits post covers the server side.

Add Flyway or Liquibase and put the migrations in src/main/resources/db/migration. The app then brings its own schema up to date during start, before it serves traffic. That makes startup slightly longer and deploys repeatable, and it is the main reason to set ddl-auto=validate.

Health checks with Actuator#

Add spring-boot-starter-actuator and expose only what you need. By default only health is exposed over HTTP, which is the correct instinct to keep:

application-prod.properties
management.endpoints.web.exposure.include=health,infomanagement.endpoint.health.probes.enabled=truemanagement.endpoint.health.show-details=nevermanagement.health.db.enabled=true

With probes enabled, Spring exposes /actuator/health/liveness and /actuator/health/readiness in addition to /actuator/health. Liveness answers "is the process in a state where it can continue" and should never depend on the database. Readiness answers "should this instance get traffic", and a database that is down belongs there. The overall /actuator/health aggregates the registered indicators, including the datasource, disk space and anything you add, and returns {"status":"UP"} with an HTTP 200, or 503 when something is down.

Test it from the console or your own machine:

bash
$ curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/actuator/health200

Two cautions. Never expose env, heapdump, beans or mappings on a public address: they leak configuration and, in the case of heapdump, memory contents including secrets. If you need them, put them on a separate management port with management.server.port and do not forward it, or protect them with Spring Security. And keep the health response cheap: an indicator that calls three third-party services turns every monitor ping into load. Graceful shutdown and health checks goes through what a good check does and does not do, and server.shutdown=graceful above is the shutdown half of the same story, letting in-flight requests finish when you press restart.

Behind the proxy: TLS and X-Forwarded-For#

Do not terminate TLS inside the application. App plans include a reverse-proxy slot: point an A record at the address shown, and the certificate is issued and renewed automatically, inside a 21-day window. Your application serves plain HTTP on its allocated port, and the proxy sends the real client address in X-Forwarded-For. What a reverse proxy does explains the full chain.

Spring Boot does not trust those headers unless you tell it to. Without that, request.getRemoteAddr() returns the proxy, every redirect is built with http://, and request.isSecure() is false. The fix is one property:

application-prod.properties
server.forward-headers-strategy=native

The two useful values are native and framework. native lets the embedded server (Tomcat, Jetty or Undertow) process X-Forwarded-* itself, which is the right answer for a plain servlet application. framework uses Spring's own ForwardedHeaderFilter, which is the choice for WebFlux or when you want Spring to do the work. Do not set both. With either in place, getRemoteAddr() yields the client, redirects and Location headers use https, and generated absolute URLs in links and OAuth callbacks are correct.

Tomcat only honours the header from a trusted proxy. The relevant property is server.tomcat.remoteip.internal-proxies, whose default is a regular expression covering private and loopback ranges. If the proxy reaches your container from an address outside that range, the header is ignored and nothing in the log tells you why. Test with a request and print X-Forwarded-For and the resolved remote address from a debug endpoint.

For Quarkus the equivalent is quarkus.http.proxy.proxy-address-forwarding=true, with quarkus.http.proxy.allow-x-forwarded=true on current versions. For Micronaut, resolve the client with HttpClientAddressResolver, which reads X-Forwarded-For for you. WebSockets and server-sent events need the upgrade handled and an idle timeout longer than your heartbeat; Websockets behind a reverse proxy covers it, and it applies to Spring's WebSocket and SseEmitter without change.

Startup time and how to shorten it#

A Spring Boot application with Hibernate, security and a dozen starters commonly takes 8 to 25 seconds to be ready on a fractional core, and longer on the first start after a deploy, since the JVM has to load and verify every class. That is normal, and it matters for two reasons: monitors that check too early report an outage, and restarts are slow.

What actually moves the number:

  • More CPU share during startup. Startup is CPU-bound and mostly single-threaded. A half-core plan takes noticeably longer than a full core, so the cheapest speedup is a larger tier, not a flag.
  • Fewer beans and fewer auto-configurations. Remove starters you do not use. --debug prints the list of auto-configurations that matched, and every one costs time.
  • `-XX:TieredStopAtLevel=1`. Stops the JIT at the quick compiler. Startup is faster, steady-state throughput is lower. Fine for a small internal tool, wrong for a busy API.
  • `-Xshare:auto` with AppCDS. Class data sharing archives make startup faster. Spring Boot 3.3 and newer supports building one from the jar; the Spring Boot documentation has the current steps.
  • Lazy initialisation. spring.main.lazy-initialization=true defers bean creation to first use. Start is faster, but the first request pays for it, and misconfigured beans are found by users instead of at boot. For production, leave it off.
  • Index and migrate before you start. A slow start is often a Flyway migration or a repository scan, not the JVM.

If cold start dominates your problem, Quarkus and Micronaut are built for it: both do their dependency injection analysis at build time and start in a second or two on the JVM. A native image brings it under 100 ms, at the price of long builds that need several gigabytes of memory, so do those in CI.

Troubleshooting#

`UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime`. The jar was compiled for a newer Java than the server runs. Compile with --release 17, or pick Java 21 or 25 for the server.

`no main manifest attribute, in app.jar`. You uploaded a thin jar, usually the .original file from Maven or the -plain.jar from Gradle. Upload the repackaged one.

`Web server failed to start. Port 8080 was already in use`. The app is trying to bind to a port that is not yours, or two copies are running. Set SERVER_PORT to the allocated port and make sure the old process stopped.

The server restarts with no stack trace, over and over. The process exceeded the container memory limit. Lower the heap, for instance MaxRAMPercentage=55, and check the memory graph against the limit. Remember that three restarts in an hour trigger a warning.

`java.lang.OutOfMemoryError: Java heap space`. The heap is too small, or something is accumulating. Raise the heap if the plan has room, or take a heap dump with -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/container/dump.hprof and open it in a profiler. Delete dumps afterwards; they are as large as the heap.

`HikariPool-1 - Connection is not available, request timed out after 30000ms`. The pool is exhausted. Look for slow queries and for open-in-view holding connections, before raising maximum-pool-size.

`Connection refused` or `UnknownHostException` for the database. The host or port in SPRING_DATASOURCE_URL is wrong. Copy them from the database page, and remember that MySQL and PostgreSQL ports differ.

`FATAL: password authentication failed`. The credentials are wrong or have stray whitespace from a paste. Retype the variable.

Redirects go to `http://` and sessions never stick. The forwarded headers are ignored. Set server.forward-headers-strategy=native, and check server.tomcat.remoteip.internal-proxies if it still fails.

A bad gateway from the proxy while the console says the app is up. The app is bound to 127.0.0.1, listening on the wrong port, or still starting. Wait for the Started ... in N seconds line before judging.

Environment variable seems ignored. Check the relaxed binding. SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE does not work; SPRING_DATASOURCE_HIKARI_MAXIMUMPOOLSIZE does.

FAQ#

How much memory does a Spring Boot app need?

A small API with JPA and security typically wants a 256 to 384 MB heap and a 600 to 800 MB container, so the 1 GB tier works for a simple service and 2 GB is comfortable. Bigger caches, large result sets and many threads push it up. Measure with the memory graph rather than guessing.

Should I use -Xmx or MaxRAMPercentage?

Use MaxRAMPercentage when you expect to change plan, since it follows the limit. Use -Xmx when you have a measured number you want to keep fixed. If both are set, -Xmx takes priority.

Can I run Quarkus or Micronaut the same way?

Yes. Both produce a jar that runs with java -jar, the heap flags are identical, and the differences are property names: quarkus.http.port and micronaut.server.port instead of server.port. Their faster startup is an advantage on small plans.

Which Java version should I pick?

The lowest version your dependencies support is the safest to build for, and Java 21 is a good default to run on. Boot 3 needs at least 17. A jar built for 17 runs on 21 and 25, but not the reverse.

Do I need a reverse proxy in front of it?

You need something to terminate TLS and renew certificates, and the proxy slot does that. Set server.forward-headers-strategy so your application sees the real client address and the right scheme.

Why is my app slow for the first minute?

The JVM runs bytecode in an interpreter first and compiles hot paths as it learns them, so the first requests are slower and CPU use is high during warm-up. A small CPU share lengthens it. If readiness checks fail during that period, give them a longer initial delay.