A Java application deploys as a single runnable jar. The server needs four things from you: a Java version at least as new as the one you compiled for, a jar that carries its own dependencies, code that listens on the port the server was given, and a memory footprint that fits under the plan's limit with room to spare. Everything else - environment variables, a domain, logs, deploys from GitHub - sits on top of those four. Get them right and java -jar app.jar is the whole story. Get one wrong and you see an error that rarely names the real cause.
This post walks the whole path on a hosted Java server: which Java version to pick, how to build a fat jar with Maven or Gradle for Spring Boot and for everything else, how the port reaches your code, how the JVM heap relates to the container limit, how to pass configuration, how to get builds from GitHub onto the server without compiling there, and how to read and keep logs. Where RE:NODE's Java line does something specific, we say so; the rest applies to any host that runs a jar in a container. For the Spring-specific production details - HikariCP, Actuator, proxy headers - there is a companion piece, deploying Spring Boot to production.
How a Java server runs your jar#
A hosted Java server is a container with a JDK inside it and a fixed start command. On RE:NODE's Java line that command is, in full:
java -XX:MaxRAMPercentage=80.0 -Dterminal.jline=false -Dterminal.ansi=true \ -Dserver.port=${SERVER_PORT} -jar {{JARFILE}}Three things follow from it, and they shape every section below.
First, there is no build step. The server runs a jar; it does not run Maven or Gradle. You build on your own machine or in CI, and only the finished artefact goes to the server.
Second, the jar is named by a variable, JARFILE, which defaults to app.jar and is relative to the server's folder. It may include a subfolder, so build/app.jar or release/service.jar are fine, as long as the name ends in .jar. You change it on the Startup tab.
Third, the heap and the port are decided by the command rather than by you. The heap ceiling is 80% of the plan's memory, and the port is passed as the system property server.port. Spring Boot reads that property on its own; other frameworks need one line of code, covered in the port section.
The start command itself is not something the customer edits - only its variables. That is deliberate: it keeps a server's start predictable. It also means the usual tricks of adding -Xmx or -XX:+ExitOnOutOfMemoryError to the command line are not available, which matters for small plans and is dealt with in the memory section.
When a Java server is first created it is not empty: it starts with a small working page on its own address, built as app.jar, so the first start shows something useful instead of a crash. That page is just a jar under the default name. Upload or deploy your own app.jar and it replaces the page; nothing of yours is ever overwritten by it.
The panel decides a server has finished starting when a console line contains one of a few words: Started, Listening, Ready or Server running (in either case). Spring Boot's Started Application in 4.2 seconds matches. A plain jar that prints nothing on start will sit at "Starting" in the panel while working perfectly. Print a line such as Listening on 0.0.0.0:<port> once your server is accepting connections and the status is right.
Choosing Java 17, 21 or 25#
The Java line offers three images: Java 17, Java 21 and Java 25, all long-term-support releases. You pick one in the Docker Image list on the Startup tab, or with the runtime version setting on the GitHub tab if the server deploys from a repository - changing it there rebuilds the server on the new image.
The rule is simple: the runtime must be the same major version as your compile target or newer. A jar compiled for 17 runs on 21 and 25 unchanged. A jar compiled for 21 refuses to start on 17 with an UnsupportedClassVersionError, and the error gives you the class file version rather than the Java version:
| Class file version | Java release | Typical cause |
|---|---|---|
| 61.0 | Java 17 | Built with --release 17 |
| 65.0 | Java 21 | Built on a JDK 21 without a release flag |
| 69.0 | Java 25 | Built on a JDK 25 without a release flag |
Pin the target in the build rather than trusting whatever JDK happens to be on the build machine. In Maven that is <maven.compiler.release>21</maven.compiler.release> in <properties>; in Gradle it is a toolchain:
java { toolchain { languageVersion = JavaLanguageVersion.of(21) }}Which to choose: Spring Boot 3 needs 17 or newer, and so do current Quarkus and Micronaut releases. If your dependencies are current, 21 is the sensible default - virtual threads are stable there, and it has years of support left. Take 25 if you want the newest LTS and your libraries have caught up; run your tests on it first, because agent-based tools and older bytecode libraries are what usually break on a new release. Keep 17 for code you cannot move, such as a Spring Boot 2.7 service, and treat that as a migration you owe yourself.
Building a jar that runs on its own#
"Runnable" means two things: the manifest names a Main-Class, and every dependency is inside the jar or beside it. A jar built by a plain mvn package has neither, and fails with no main manifest attribute, in app.jar or with NoClassDefFoundError on the first library class.
For Spring Boot, the Boot plugin does both. ./mvnw package or ./gradlew bootJar produces an executable fat jar with an embedded web server. Give it a fixed name so the server's JARFILE never needs to change between versions:
<build> <finalName>app</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins></build>That gives target/app.jar. Maven also leaves target/app.jar.original, the thin jar before repackaging; ignore it. In Gradle, bootJar writes to build/libs/, and the jar task writes a second, plain jar with a -plain suffix next to it. Upload the one without the suffix, or set archiveFileName = "app.jar" on the bootJar task.
Other frameworks have their own route to the same result:
- Quarkus builds a "fast jar" by default:
target/quarkus-app/quarkus-run.jarplus alibdirectory beside it. That runs fine if you upload the wholequarkus-appfolder and setJARFILEtoquarkus-app/quarkus-run.jar. If you want one file, setquarkus.package.jar.type=uber-jar(older 3.x releases usedquarkus.package.type) and you get a single*-runner.jar. - Ktor with its Gradle plugin has a
buildFatJartask that writes an-all.jartobuild/libs/. - Micronaut projects generated by Micronaut Launch include the Shadow plugin for Gradle, so
./gradlew shadowJarproduces an-all.jar; on Maven,mvn packageproduces a runnable jar with dependencies. - Plain Java, a JDA Discord bot, a Javalin service: use the Maven Shade plugin or the Gradle Shadow plugin, and set the main class in its configuration.
Check the result before it leaves your machine. java -jar target/app.jar should start without anything else on the classpath, and unzip -p target/app.jar META-INF/MANIFEST.MF shows the Main-Class line. If it starts locally with a bare java -jar on the same major version you picked for the server, it will start there.
The port: SERVER_PORT and server.port#
Your plan comes with an allocation - an address and a port - and the application must listen on that port, on all interfaces. Not 8080, and not localhost. A service on the wrong port starts cleanly, logs that it is ready, and answers nobody.
The port reaches your code twice. It is in the environment as SERVER_PORT, and the start command passes it as the system property server.port. That second form is why Spring Boot needs no configuration at all: Java system properties outrank application.properties in Spring's property order, so whatever server.port you set in the jar is overridden by the real one. That is useful, and occasionally surprising if you expected your own value to win.
Every other framework reads the port its own way:
| Framework | Where the port goes |
|---|---|
| Spring Boot | Nothing to do, server.port is set |
| Quarkus | quarkus.http.port=${SERVER_PORT:8080} |
| Micronaut | micronaut.server.port: ${SERVER_PORT:8080} |
| Ktor | System.getenv("SERVER_PORT") |
| Plain Java | System.getenv("SERVER_PORT") |
Quarkus also needs quarkus.http.host=0.0.0.0 if you have changed it, and a hand-written server must bind every interface rather than the loopback address. The JDK's own HTTP server is enough for a health page or a small API, and shows the whole pattern:
import com.sun.net.httpserver.HttpServer;import java.net.InetSocketAddress;public class Main { public static void main(String[] args) throws Exception { int port = Integer.parseInt(System.getenv().getOrDefault("SERVER_PORT", "8080")); HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0); server.createContext("/", exchange -> { byte[] body = "ok\n".getBytes(); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); exchange.close(); }); server.start(); System.out.println("Listening on 0.0.0.0:" + port); }}A Discord bot built on JDA, or any worker that only makes outbound connections, does not need to listen at all. It still needs a start line the panel recognises, so print Ready once the bot has connected.
For a domain and HTTPS, put the proxy slot in front of the app rather than terminating TLS in the JVM. App plans include a reverse-proxy slot: point an A record at the address shown and the certificate is issued and renewed automatically. The app keeps speaking plain HTTP on its port, and the real client address arrives in X-Forwarded-For. In Spring Boot, server.forward-headers-strategy=framework makes request.getRemoteAddr() and generated URLs honour it. Your domain and its certificate covers the DNS side.
Memory: the heap, the rest of the JVM and the limit#
This is where most Java deployments on small plans go wrong, and where knowing the start command pays off.
The container has a hard memory limit equal to the plan. The JVM's footprint is the heap plus everything that is not the heap: metaspace for loaded classes, the JIT code cache, one stack per thread, direct byte buffers used by Netty and NIO, and the garbage collector's own structures. A Spring Boot web app commonly carries a couple of hundred megabytes of that before it has served a request.
-XX:MaxRAMPercentage=80.0 lets the heap grow to 80% of the container limit. The JVM does not take it all at once - the heap starts smaller and grows when the application allocates faster than the collector reclaims - but under load it is allowed to reach the ceiling, and the non-heap memory has to fit in what is left:
| Plan memory | Heap ceiling | Left for everything else |
|---|---|---|
| 1 GB | about 820 MB | about 205 MB |
| 2 GB | about 1.6 GB | about 410 MB |
| 4 GB | about 3.3 GB | about 820 MB |
| 8 GB | about 6.6 GB | about 1.6 GB |
On 2 GB and above, that is comfortable for a typical service. On 1 GB it is tight for Spring Boot: 205 MB for metaspace, code cache and 200 Tomcat threads leaves little room, and a heap that fills under a burst can push the total over the line. A small Javalin API, a JDA bot or a Quarkus service fits on 1 GB without fuss. A Spring Boot app with JPA is happier on 2 GB.
What happens at the limit matters. On RE:NODE the kernel stops the container and it restarts clean instead of swapping. So there are two different failures, and they need opposite fixes:
- `java.lang.OutOfMemoryError: Java heap space` in the console. The heap ceiling was reached and the JVM said so. The data does not fit, or something leaks. A bigger plan or less held in memory is the answer.
- A restart with no Java error at all. The whole process crossed the container limit and was stopped from outside; the JVM never had a chance to log anything. The heap was not the problem on its own - non-heap memory was.
Because the command line is fixed and variables beginning with JAVA, JDK_ or JVM cannot be set from the panel, you cannot lower the heap with JAVA_TOOL_OPTIONS. What you can control is the non-heap side, which is usually where 1 GB plans fail:
# Fewer request threads: each one has its own stackserver.tomcat.threads.max=50# Create beans when first used instead of all at startspring.main.lazy-initialization=true# Do not keep JPA sessions open through the view layerspring.jpa.open-in-view=falseLazy initialisation lowers startup memory and time at the cost of slower first requests and errors that appear later instead of at boot; test before relying on it. On Java 21 and 25, spring.threads.virtual.enabled=true swaps platform request threads for virtual threads, which cuts stack memory sharply for I/O-bound services. Beyond that, trim dependencies you do not use - every starter adds classes to metaspace. If you have measured and still need a different heap setting, ask us in a ticket rather than fighting the limit; and if the app genuinely needs more memory, the honest answer is the next plan. When to upgrade your plan covers reading the graphs before you do.
Restarts are watched. A crash watcher polls every two minutes; restarts you asked for are not counted, but three unplanned ones in an hour put a warning on the server page and open a ticket, and six suspend the server. A jar that is killed at the limit every few minutes is exactly what that is looking for, so fix sizing before leaving it running. The console graphs memory against the limit; watch it through a day of real traffic. The general background is in Linux swap and the OOM killer.
Environment variables and secrets#
Configuration that changes between environments - database URLs, API keys, a profile name - belongs in the environment, not in the jar. Spring Boot's relaxed binding turns SPRING_DATASOURCE_URL into spring.datasource.url, SPRING_PROFILES_ACTIVE into spring.profiles.active, and so on, so most settings need no code.
On RE:NODE, your own variables are added on the server's Variables tab, under "Your variables": a name and a value, set in the container on the next start, up to 25 per server. Names are uppercase letters, digits and underscores, starting with a letter. Names the panel, the daemon or a language runtime already own are refused, and the list is wider than you might guess: anything starting SERVER_, JAVA, JDK_, JVM, APP_, DB_, DATABASE_, PG, POSTGRES, REDIS or AWS_, plus names such as PATH and CLASSPATH. So DB_PASSWORD will not save, while SPRING_DATASOURCE_PASSWORD or ORDERS_DB_PASSWORD will.
SPRING_PROFILES_ACTIVE=prodSPRING_DATASOURCE_URL=jdbc:postgresql://db-host:port/appdbSPRING_DATASOURCE_USERNAME=appuserSPRING_DATASOURCE_PASSWORD=change-meDISCORD_BOT_TOKEN=change-meThe host and port above are placeholders - use the ones your database server shows. Outside Spring, read variables with System.getenv("NAME") and fail loudly at start if a required one is missing; a clear error in the first second beats a NullPointerException an hour later.
If you prefer a file, Spring Boot also reads an application.properties or application.yml placed next to the jar, or in a config/ folder beside it, and those outrank the copy packed inside the jar. Keep such a file on the server, never in the repository. The broader habits - what counts as a secret, rotation, keeping .env out of Git - are in environment variables and secrets.
App plans include two database slots created in the panel, with a generated host, user and password, which suits a small service. For PostgreSQL, MongoDB or MySQL on their own server, database hosting gives each engine its own plan and credentials.
Deploying from GitHub without building on the server#
Uploading the jar by hand through the file manager or SFTP is fine for a first deploy. For anything you update often, connect GitHub.
The GitHub tab links a repository through a GitHub App with short-lived tokens, so private repositories work. You pick a production branch and a folder for the code, and two switches: pull the latest commit on every start, and deploy on push, which restarts a running server and starts a stopped one. Each deploy is recorded.
The catch is the one from the start command: the server runs a jar, it does not build one. A repository of Java source on the production branch gives the server nothing to run. The clean pattern is to let GitHub Actions build the jar and publish it to a branch that holds nothing but the artefact, and point the server at that branch.
name: Build jaron: push: branches: [main]permissions: contents: writejobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' cache: maven - run: ./mvnw -B -DskipTests package - name: Publish the jar to the deploy branch run: | mkdir out && cp target/app.jar out/app.jar cd out git init -b deploy git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add app.jar git commit -m "Build ${GITHUB_SHA::7}" git push --force \ "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" \ deployRun your tests in a step before the package if you want a failing test to stop the deploy; -DskipTests here is only to keep the example short. The force-push of a fresh one-commit branch is deliberate: a jar committed on top of the last one on every build makes the repository grow by the size of the jar each time, and nobody needs that history - the source history is on main.
On the server, set the production branch to deploy, turn on deploy on push, and leave JARFILE at app.jar. Set the build's Java version and the server's image to the same major version. One caution about the folder setting: whatever is in the code folder is replaced by the repository on every start. If your app writes uploads, an H2 or SQLite file, or logs, have it write outside that folder - or put the code in a subfolder such as release and set JARFILE to release/app.jar, so the server root stays yours.
The GitHub tab also has a pre-flight check that reads the repository before you link it. Its runtime checks are written for Node.js, Python, Go, Bun and Deno rather than Java, but it still reports a committed .env by name without opening it, which is worth having on any repository. More on it in the pre-flight check, and the panel side of deploys is in the deploys guide.
Logs: the console, files and disk#
The console shows the process's standard output, unfiltered and live, with a command line, power buttons and memory and CPU graphs against the limits. For most services that is the log: Spring Boot, Quarkus and Logback all log to the console by default. Spring Boot's coloured output and the console's handling of it are both fine; if colour codes clutter a copied log, spring.output.ansi.enabled=never turns them off.
The console is a live view, not an archive. For anything you need to read after a restart, write to a file as well, and cap it, because the disk limit is real: a server that goes over its disk limit while running is stopped and locked until space is freed. Spring Boot's Logback rotation needs four properties:
logging.file.name=logs/app.loglogging.logback.rollingpolicy.max-file-size=10MBlogging.logback.rollingpolicy.max-history=7logging.logback.rollingpolicy.total-size-cap=200MBtotal-size-cap is the one that protects the disk; without it, max-history keeps seven days however large they are. The relative path is resolved from the server's folder, which is the working directory. Read the files in the file manager, or fetch them over SFTP.
What to log is a separate question from where. Log one line per request at most, log errors with their stack trace once, and do not log request bodies or headers that may carry tokens. Logs worth keeping goes further.
Stopping is part of the same picture. The stop button sends Ctrl+C, which the JVM treats as SIGINT and answers by running shutdown hooks. Spring Boot uses that moment for a graceful shutdown: with server.shutdown=graceful it stops taking new requests and lets in-flight ones finish, up to spring.lifecycle.timeout-per-shutdown-phase (30 seconds by default). That is what makes a deploy-on-push restart polite to the requests in flight. Graceful shutdown and health checks has the detail.
Troubleshooting a Java server that will not start#
The errors people actually see, and what each one means:
- `Error: Unable to access jarfile app.jar`. The file named in
JARFILEis not there. Check the name's case, the folder, and whether a GitHub deploy put the code in a subfolder. - `no main manifest attribute, in app.jar`. The jar is a library jar, not an executable one. Build it with the Boot, Shade or Shadow plugin, or upload the right file - in Gradle, not the
-plainone. - `UnsupportedClassVersionError ... class file version 65.0`. Compiled for a newer Java than the server runs. Change the image on the Startup tab or set the build's release to match.
- `NoClassDefFoundError` or `ClassNotFoundException` for a library class. The dependencies are not in the jar. Same fix as the manifest error.
- Starts, logs that it is ready, but the address does not answer. It is listening on another port or on
localhost. Check the startup log line for the port and compare it with the allocation. - The panel shows "Starting" for ever. The app works but never printed one of the start words. Print
ListeningorReadyonce it is up. - Restarts every few minutes with no error. The process hit the memory limit. See the memory section.
- `APPLICATION FAILED TO START` from Spring Boot. Read the "Description" and "Action" lines under it; they are usually right. A missing datasource URL is the most common, which means a variable did not save or has a typo.
FAQ#
Can the server build my project with Maven or Gradle?
No. The Java server runs a finished jar and has no build step. Build on your machine or in CI - the GitHub Actions workflow above is the usual way - and deploy only the jar.
Can I change the -Xmx or other JVM flags?
Not from the panel. The start command is fixed and sets the heap ceiling to 80% of the plan's memory, and JAVA_TOOL_OPTIONS is one of the reserved variable names. You can reduce non-heap memory from inside the application, and if you have a measured need for different flags, open a ticket.
How much memory does a small Java service need?
A plain Java or Javalin API, a Quarkus service or a JDA bot runs well on 1 GB. A Spring Boot web app with JPA is more comfortable on 2 GB, because the JVM's non-heap memory takes a larger share of a small plan.
Do I need a separate port for Spring Boot Actuator?
Usually not. Keeping Actuator on the main port and exposing only the endpoints you need, such as health, is simpler than managing a second port, and management.endpoints.web.exposure.include=health is the line that does it. Anything more sensitive, such as env or heapdump, should stay unexposed on a public address.
Can I run a WAR file?
Not directly - there is no servlet container installed. Spring Boot can package the same application as an executable jar with an embedded Tomcat, which is the normal route. An old WAR-only application needs repackaging first.
Does the server stay on the Java version I picked?
Yes. The version is the image the server runs, chosen on the Startup tab or the GitHub tab, and it does not change unless you change it.


