Quarkus and Micronaut both run on a plain Java server as a normal jar, and on a small plan they are the easier frameworks to fit: they do their dependency injection work at build time, so they start in a second or two and carry a smaller heap than a comparable Spring Boot service. The three things that catch people are the artefact layout (Quarkus does not produce one file by default), the memory limit (the JVM will happily ask for more than you pay for), and the health endpoint paths, which are not the ones Spring uses. This post covers all three, plus the question everyone asks first: should you build a native executable or just run on the JVM?
It is deliberately the companion to deploying a Spring Boot app to production, which covers the general path for a fat jar - Java versions, externalised configuration, HikariCP, proxy headers. Here we stay with what differs for Quarkus and Micronaut, and repeat only what you need to follow along.
What differs from a Spring Boot fat jar#
A Spring Boot build gives you one executable file. Quarkus and Micronaut give you the same thing in different shapes, and the shape decides what you upload and what the start command is.
| Framework | Default build output | Run with |
|---|---|---|
| Quarkus | target/quarkus-app/ directory | java -jar target/quarkus-app/quarkus-run.jar |
| Quarkus (uber-jar) | target/*-runner.jar | java -jar target/*-runner.jar |
| Micronaut (Gradle) | build/libs/*-all.jar | java -jar build/libs/app-0.1-all.jar |
| Micronaut (Maven) | target/*.jar | java -jar target/app-0.1.jar |
The Micronaut names depend on your project name and version, and on the plugins you applied, so treat the file names above as examples and look at the directory after a build. For Micronaut with Gradle, the -all.jar comes from the Shadow plugin and the shadowJar task; with Maven, the generated project uses the Shade or Micronaut Maven plugin to produce the runnable archive.
The common ground is this: both frameworks listen on a port you choose, read configuration from files and environment variables, and need a Java runtime of at least the version they were compiled for. RE:NODE's Java app line runs any jar on Java 17, 21 or 25, which covers Quarkus, Micronaut, Spring Boot and Ktor alike. Current Quarkus 3 releases and Micronaut 4 releases require Java 17 or newer; check the version of your own framework in its release notes before choosing, and compile with a --release flag matching the oldest Java you intend to run on. A jar built for 17 runs on 21 and 25, never the other way round.
Quarkus packaging: fast-jar, uber-jar and the rest#
The Quarkus default is called fast-jar. It is not one file. The build writes a directory:
target/quarkus-app/ quarkus-run.jar lib/ boot/ main/ app/ quarkus/quarkus-run.jar is a tiny launcher. The dependencies sit unpacked-as-jars in lib/, your own classes in app/, and generated build-time data in quarkus/. The layout exists for one reason: start-up is faster when the runtime does not have to open and scan one enormous archive. It also makes deploys cheaper, because most of the directory does not change between releases - only app/ and quarkus/ do, so an incremental upload (or a layered container image) moves a few hundred kilobytes rather than fifty megabytes.
The consequence for hosting is simple but easy to get wrong: you must upload the whole `quarkus-app` directory, not just `quarkus-run.jar`. The launcher alone fails with a class-loading error because lib/ is not beside it. Archive the directory in CI, upload the archive, and unpack it in place. The panel's file manager unpacks archives where they sit, which is exactly what this needs.
$ ./mvnw -B clean package$ tar -czf quarkus-app.tar.gz -C target quarkus-app$ ls -lh quarkus-app.tar.gzThe packaging type is controlled by one property, set in application.properties or on the command line with -D:
quarkus.package.jar.type | Result |
|---|---|
fast-jar | The default directory layout above |
uber-jar | One runnable *-runner.jar with everything inside |
legacy-jar | The older single jar plus lib/, kept for compatibility |
mutable-jar | A fast-jar that can be re-augmented at start, for dev tooling |
On older Quarkus releases (before 3.9) the property was quarkus.package.type, and the values were the same words. If the build ignores your setting, you are reading documentation for the wrong major version.
quarkus.package.jar.type=uber-jarWhen is an uber-jar the right call? When your deployment path really wants one file - dragging a single jar into a file manager, or a start command you do not want to touch. The price is slower start and a larger single archive that changes on every release. If you can handle a tarball, keep fast-jar. If you cannot, the uber-jar is fine and many small services run that way without trouble.
Micronaut has no equivalent choice to make. You get a runnable jar from the build, and the -all.jar suffix (Gradle) tells you which file to upload. Its start-up speed comes from ahead-of-time processing in the annotation processor rather than from the archive layout.
Native image or JVM: which one to run#
Both frameworks can compile your application to a native executable with GraalVM. The numbers that make people want it are real: start-up in tens of milliseconds and a much smaller resident memory footprint. The costs are real as well, and for a small hosted service they usually outweigh the gain.
| JVM mode | Native executable | |
|---|---|---|
| Start-up | About a second or two | Tens of milliseconds |
| Memory at idle | Heap plus JVM overhead | Noticeably lower |
| Peak throughput | Higher once warmed up | Often lower, no JIT |
| Build time | Seconds to a minute | Minutes |
| Build memory | Modest | Several gigabytes |
| Reflection, proxies | Just work | Must be known at build time |
| Debugging, profiling | Full JVM tooling | Limited |
| Portability | Any machine with Java | One OS and CPU architecture |
The commands, for reference:
# Quarkus: build a native executable (needs GraalVM or Mandrel installed)$ ./mvnw package -Dnative# Quarkus: build it inside a container, so you need no local GraalVM$ ./mvnw package -Dnative -Dquarkus.native.container-build=true# Micronaut, Gradle$ ./gradlew nativeCompile# Micronaut, Maven$ ./mvnw package -Dpackaging=native-imageQuarkus writes a *-runner binary into target/. It only runs on the operating system and CPU architecture it was built for, which is why the container build option exists: it gives you a Linux binary even if you develop on something else.
The honest recommendation for a hosted app server is JVM mode. Three reasons. First, the Java line on RE:NODE is built around running a jar on Java 17, 21 or 25; a native executable is a different kind of file and the Java line is not documented as running one, so confirm with support before you plan around it. Second, a native build wants several gigabytes of memory for minutes, which does not belong on the production plan you are trying to keep small - it belongs in CI. Third, JVM mode with sensible heap flags is already small enough: a Quarkus or Micronaut service with a modest dependency tree fits in a 1 GB plan, which is the cheapest tier. Native is worth the trouble when you scale to zero and start often (functions, jobs), when you run very many tiny instances, or when memory is the binding constraint on a larger deployment. For one service on one plan, it is a cost without a payoff.
If you do go native, remember that reflection, resource loading and dynamic proxies must be declared at build time. Libraries that rely on them need configuration or an extension. Both frameworks ship extensions for the common ones, and a missing registration shows up at runtime as a class-not-found or a missing resource that worked perfectly in JVM mode - so run your integration tests against the native build, not just the JVM one. Quarkus has @QuarkusIntegrationTest for exactly this.
Ports and binding#
The listening port is the first thing that breaks in any framework, and both of these default to 8080. Your server has an allocated port, and your application must listen on it, on all interfaces. Environment variables and secrets covers where to set them; the keys are below.
quarkus.http.host=0.0.0.0quarkus.http.port=${SERVER_PORT:8080}micronaut: server: port: ${SERVER_PORT:8080}${SERVER_PORT:8080} means "use the environment variable if it is set, otherwise 8080", which works on your laptop and in production without edits. Check the Startup and Network tabs for the variable your server actually provides before relying on the name SERVER_PORT; a Pterodactyl-derived panel commonly exposes the allocation under that name, but verify it for your server.
Both frameworks also map environment variables to configuration keys with no code at all: upper case the key and turn dots and dashes into underscores.
| Key | Environment variable |
|---|---|
quarkus.http.port | QUARKUS_HTTP_PORT |
quarkus.datasource.jdbc.url | QUARKUS_DATASOURCE_JDBC_URL |
micronaut.server.port | MICRONAUT_SERVER_PORT |
datasources.default.url | DATASOURCES_DEFAULT_URL |
Quarkus's default bind address depends on the mode: it is the loopback address in dev mode and all interfaces in a normal production run. Setting quarkus.http.host=0.0.0.0 explicitly costs nothing and removes the doubt. A service bound to 127.0.0.1 inside its container is reachable by nobody, and the proxy reports a bad gateway while the console insists the app is running.
Profiles work the same way in both. In Quarkus, a %prod. prefix scopes a key to the production profile, which is what java -jar runs unless told otherwise; %dev. and %test. are the others. In Micronaut, environments are named with MICRONAUT_ENVIRONMENTS=prod, which loads application-prod.yml on top of application.yml.
quarkus.http.port=8080%prod.quarkus.http.port=${SERVER_PORT:8080}%prod.quarkus.log.level=INFO%dev.quarkus.log.level=DEBUGHeap flags for a small memory limit#
This is the part that matters most. A container has a hard memory limit, and the JVM decides for itself how big a heap to ask for. The detailed treatment is in the Spring Boot post; the working version is here.
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. That is the better behaviour for a service, but it changes how you read a failure. There are two quite different ones:
- `java.lang.OutOfMemoryError: Java heap space` in the console. The JVM noticed it ran out of heap and said so. The heap is too small for the data, or something leaks.
- A restart with no Java error at all. The whole process went over the container limit and was stopped from outside. The heap was too large for the plan, or memory outside the heap grew.
The second is the common one, because the JVM's footprint is more than its heap. It also holds metaspace for class metadata, thread stacks, the JIT code cache, direct buffers and garbage collector structures. Quarkus adds Netty and Vert.x, which allocate direct (off-heap) buffers for network I/O; that is part of why a Quarkus service's resident size is bigger than its -Xmx. Micronaut on Netty does the same.
Set the heap as a share of the container limit, so it follows you when you change plan:
$ java -XX:MaxRAMPercentage=60.0 -XX:InitialRAMPercentage=25.0 \ -XX:+ExitOnOutOfMemoryError \ -jar target/quarkus-app/quarkus-run.jarIf you cannot edit the start command, put the flags in the environment instead; every JVM that starts reads JAVA_TOOL_OPTIONS, and prints Picked up JAVA_TOOL_OPTIONS: ... at the top of the console so you can see it took effect:
JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=60.0 -XX:+ExitOnOutOfMemoryErrorDo not confuse that with JAVA_OPTS and JAVA_OPTS_APPEND. Those are read by the launch scripts inside the Red Hat and Quarkus container images, not by the java binary itself, so on a plain java -jar start they do nothing.
Starting points by plan size, which are guesses to be replaced with a graph:
| Plan memory | Heap setting | Why |
|---|---|---|
| 1 GB | -Xmx256m to -Xmx384m | Roughly half goes to non-heap and the OS |
| 2 GB | MaxRAMPercentage=60 | About 1.2 GB heap, the rest for overhead |
| 4 GB | MaxRAMPercentage=65 | Overhead is a smaller share |
| 6 GB and up | MaxRAMPercentage=70 | Keep room for buffers and spikes |
The sensible way to find your own number: set a deliberately generous heap on a plan with spare memory, run real traffic for a day, and watch the memory graph in the console against the limit. A small REST service on either framework commonly needs a heap of 128 to 256 MB; a service that caches or handles large payloads needs more. Set the heap to what you observed plus headroom, not to a round percentage of the plan.
A few flags earn their place on small plans:
| Flag | Effect |
|---|---|
-XX:+ExitOnOutOfMemoryError | Exit on heap OOM so the platform restarts a clean process |
-XX:MaxMetaspaceSize=128m | Cap class metadata growth |
-XX:MaxDirectMemorySize=128m | Cap the direct buffers Netty uses |
-XX:+UseSerialGC | Lowest overhead collector for 1 CPU or a small heap |
-Xss512k | Smaller thread stacks if you run many threads |
-XX:+ExitOnOutOfMemoryError is the one most people skip. A JVM that has thrown a heap OOM in one thread is often alive but wrecked: it serves some requests and fails others. Exiting hands you a clean process. Mind that restarts are watched. The crash watcher polls every two minutes for uptime that went backwards or a server that went offline; restarts you asked for are not counted, three restarts in an hour put a warning on the server page and open an automatic ticket, and six mean suspension. A service stuck in an out-of-memory loop is precisely what that looks for, so fix the sizing before leaving it unattended.
CPU matters as well. It is a hard throttle to the share you bought, and 100% is slow, never grounds for suspension. The JVM sizes its thread pools from the CPU count it sees, which can round a half-core share up to one. If start-up is slow or latency comes in bursts on the smallest tier, -XX:ActiveProcessorCount=1 is worth trying. The general lesson of CPU vs RAM for game servers applies unchanged: find out which resource is binding before buying more of the other.
Threads, not just heap
Both frameworks run on Netty, and both can run blocking code on a worker pool. Quarkus sets that pool's size by default from the CPU count it detects, and the key to cap it is quarkus.thread-pool.max-threads. Each thread costs a stack and some native memory, so a worker pool of 200 on a service that never has more than 10 requests in flight just inflates the footprint. On Micronaut, the equivalent lives under micronaut.executors, for example micronaut.executors.io.type: fixed with micronaut.executors.io.nThreads: 16. Set it to what the service really uses.
Database connections#
The connection details come from whichever database you attach. App servers have database slots created in the panel, with a generated host, user and password; a standalone database hosting plan gives PostgreSQL or MySQL 8.4 LTS with credentials generated per server. Keep the values in environment variables, never inside the jar.
QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://db-host:5432/shopQUARKUS_DATASOURCE_USERNAME=shop_appQUARKUS_DATASOURCE_PASSWORD=change-mequarkus.datasource.db-kind=postgresqlquarkus.datasource.jdbc.max-size=8quarkus.datasource.jdbc.min-size=2Quarkus uses Agroal as its pool, and quarkus.datasource.jdbc.max-size is the ceiling. For Micronaut the datasource is configured under datasources.default, with HikariCP as the default pool:
datasources: default: url: ${DATASOURCES_DEFAULT_URL} username: ${DATASOURCES_DEFAULT_USERNAME} password: ${DATASOURCES_DEFAULT_PASSWORD} maximum-pool-size: 8Whatever the pool, the arithmetic is the same: every connection is held by the database, so two instances with a pool of eight each, plus a migration job, add up against the database's own limit. Connection pools and limits goes through it. A pool of six to eight is plenty for a service on one or two cores.
Health endpoints in Quarkus and Micronaut#
Neither framework uses Spring's /actuator/health. Both give you a health endpoint only after you add a dependency, and the paths differ.
Quarkus: SmallRye Health
Add the extension:
$ ./mvnw quarkus:add-extension -Dextensions="smallrye-health"That gives you four endpoints under the /q prefix:
| Path | Question it answers |
|---|---|
/q/health | Everything combined: live, ready and started |
/q/health/live | Is the process in a state where it can continue? |
/q/health/ready | Should this instance receive traffic? |
/q/health/started | Has start-up finished? |
A response is {"status":"UP","checks":[...]} with HTTP 200, or DOWN with 503. Extensions contribute checks on their own - the datasource extension adds a database readiness check, for instance, so a missing database turns /q/health/ready red without you writing anything. Your own checks are CDI beans:
@ApplicationScoped@Readinesspublic class CacheWarmCheck implements HealthCheck { @Override public HealthCheckResponse call() { return HealthCheckResponse.up("cache-warm"); }}The /q prefix is the Quarkus non-application root and is configurable with quarkus.http.non-application-root-path. If you move it, update whatever monitors the service.
Micronaut: the management module
Add micronaut-management to the build. The endpoints are enabled by configuration:
endpoints: health: enabled: true details-visible: NEVERThe base path is /health, with /health/liveness and /health/readiness beneath it. details-visible takes NEVER, AUTHENTICATED or ANONYMOUS and decides who sees the per-indicator breakdown. Leave it at NEVER on a public address: details can reveal hostnames and versions. To keep management endpoints off the public port entirely, endpoints.all.port moves them to a separate one.
What a good check does
Liveness should never depend on the database or any other service. If it does, a database outage makes the platform restart a perfectly healthy process, which makes things worse. Readiness is where dependencies belong. Keep every check cheap: an indicator that calls three third-party APIs turns each monitor ping into load. Graceful shutdown and health checks covers this in more depth, along with the shutdown half of the story. In Quarkus, quarkus.shutdown.timeout sets how long it waits for in-flight requests to finish when you press Restart.
Test from the console or your own machine:
$ curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/q/health/ready200And do not expose more than health on a public address. Metrics, the Quarkus dev UI and the Micronaut /env and /beans endpoints reveal configuration. The Quarkus dev UI is only active in dev mode, but double check that nothing you added to application.properties enables management paths in production.
Behind the proxy: TLS and real client addresses#
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 real client address arrives in X-Forwarded-For.
Neither framework trusts forwarded headers by default, for the sound reason that any client could send them. Without the setting you see the proxy's address as the client, and redirects built from the request come out as http://.
quarkus.http.proxy.proxy-address-forwarding=truequarkus.http.proxy.allow-x-forwarded=truequarkus.http.proxy.enable-forwarded-host=trueFor Micronaut, the key that names the header carrying the client address is micronaut.server.client-address-header:
micronaut: server: client-address-header: X-Forwarded-ForOnly enable these when you are genuinely behind a proxy, since anyone who can reach the application port directly could then spoof their address. After setting them, send a request and print the resolved client address from a debug endpoint rather than assuming. What a reverse proxy does explains the whole chain.
Deploying and starting it on RE:NODE#
The deploy path is the same as for any jar. Build in CI or locally and run your tests there. A Quarkus or Micronaut build is lighter than a native one but still wants more memory than a small production plan should spend on it, and building where you test keeps the artefact reproducible.
- Build the artefact:
./mvnw packagefor Quarkus (then archivetarget/quarkus-app), or./gradlew shadowJarfor Micronaut. - Upload it through the file manager or SFTP. Both are on every app server, with SFTP credentials per server. The file manager unpacks archives in place, which suits the Quarkus directory.
- Set environment variables on the Startup tab: the port, the datasource values,
JAVA_TOOL_OPTIONS, and the profile (QUARKUS_PROFILE=prodorMICRONAUT_ENVIRONMENTS=prod). - Point the start settings at the right file, then start from the console and watch for the framework's started line.
The alternative is deploys from GitHub. The panel does this through a GitHub App with short-lived tokens, so private repositories work, with two switches: pull on every start, and deploy on push, which restarts a running server and starts a stopped one. It is GitHub only. That is a source-to-server route, so it suits you only if the server will hold the build output; the practical pattern is a branch that contains just the release artefact. Before linking, the pre-flight check reads the repository and lists what will break, worst first, such as binding the wrong port or host.
A new Java server starts with a small working page on its address until you deploy code, and that page never overwrites your files.
Troubleshooting#
`Error: Unable to access jarfile quarkus-run.jar` or a missing `lib` error. You uploaded the launcher without the rest of the quarkus-app directory. Upload and unpack the whole directory, or switch to uber-jar.
`no main manifest attribute, in app.jar`. You uploaded a thin jar. For Micronaut that is the plain jar rather than the -all.jar; for Quarkus it is the wrong file in target/. Look for the runnable one.
`UnsupportedClassVersionError`. The jar was compiled for a newer Java than the server runs. Compile with --release 17, or choose Java 21 or 25 for the server.
The server restarts with no stack trace. The process went over the container limit. Lower MaxRAMPercentage, cap direct memory with -XX:MaxDirectMemorySize, and compare the memory graph to 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 accumulates. Raise it if the plan has room, or capture a dump with -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/container/dump.hprof and delete the file afterwards, as it is as large as the heap.
`java.lang.OutOfMemoryError: Direct buffer memory`. Netty ran out of off-heap space, usually under a flood of large requests or responses. Raise -XX:MaxDirectMemorySize if there is room on the plan, and look for code that holds buffers.
Port already in use, or bad gateway while the app is up. The app listens on 8080 rather than the allocated port, or on loopback. Set the port from the environment and quarkus.http.host=0.0.0.0.
`/q/health` returns 404. The smallrye-health extension is not in the build, or quarkus.http.non-application-root-path moved it.
`/health` returns 404 on Micronaut. micronaut-management is not on the classpath, or endpoints.health.enabled is false.
Native image works locally but fails on the server. It was built for a different operating system or CPU architecture. Build it in CI in a Linux container, or go back to JVM mode.
FAQ#
Is fast-jar better than an uber-jar?
For start-up and incremental deploys, yes. For a deploy path that needs a single file, no. Fast-jar is the default for good reason, but an uber-jar is not wrong on a small service: set quarkus.package.jar.type=uber-jar and upload one file.
Should I use a native image on a small plan?
Usually not. The build needs several gigabytes of memory and minutes of CPU, the Java line is documented for running jars, and JVM mode with a trimmed heap already fits the cheapest tier. Native pays off for scale-to-zero and for very many small instances, not for one service.
How much memory does a Quarkus or Micronaut service need?
A small REST service with a database commonly runs in a heap of 128 to 256 MB and a process well under 1 GB, so the 1 GB tier is workable. Measure with the console's memory graph over a day of real traffic rather than trusting any rule of thumb, including this one.
Do I need -Xmx or MaxRAMPercentage?
Use MaxRAMPercentage if you may change plan, because it follows the limit. Use -Xmx for a fixed, measured value. If both are set, -Xmx wins.
Why does my health endpoint answer on one port and not another?
The management interface may be on its own port. In Micronaut endpoints.all.port does that, and Quarkus can serve management on a separate interface when configured. A plan has the ports it lists, so check the Network tab before moving anything.
Can I run Ktor or Spring Boot the same way?
Yes. Any jar runs on the Java line, and the heap flags are identical. The Spring Boot specifics are in the Spring Boot deployment guide.


