The best Java and Spring Boot hosting platforms in 2026
Most hosting guides are written for runtimes that start instantly, use whatever memory they happen to need, and die between requests without complaint. The JVM is none of those things, and every Java deployment problem I have watched people fight traces back to a platform whose defaults were designed for something else.
The JVM wants three things. It wants a real chunk of memory that it can claim early and hold. It wants a few seconds of runway before it is judged on latency, because the code you wrote is interpreted before it is compiled. And it wants to stay alive, because everything it learns about your workload — inlining decisions, branch profiles, the shape of your heap — is thrown away when the process exits. A platform that gives you all three makes Java boring. A platform that gives you two makes Java expensive in ways that are hard to attribute.
This is a buyer's guide for putting a Spring Boot, Quarkus, Micronaut, or plain-servlet service into production in 2026. I build PandaStack, which appears near the bottom with its actual trade-offs rather than a pitch.
The first question is memory, and it is not close
A Spring Boot service with a moderate dependency set — Spring Web, Spring Data JPA, Hibernate, a connection pool, a metrics exporter — is not a small process. Class metadata, the JIT's code cache, thread stacks, direct byte buffers used by the HTTP layer, and the heap itself all cost real bytes, and only one of those is the heap you configured. This is the origin of the single most common Java hosting bug: you set the heap to the size of the instance, and the process gets killed by the kernel because the heap was never the whole picture.
Modern JVMs are container-aware and will read the cgroup memory limit rather than the host's physical RAM, which is a genuine improvement over the JDK 8 era. But container-aware defaults are conservative — the JVM leaves headroom for itself and then some — so on a small instance you can end up with a heap far smaller than you assumed, followed by a garbage collector that spends its life running and an application that looks CPU-bound when it is actually memory-starved.
# Two flags do most of the work. Set the heap as a PERCENTAGE of the
# container limit, not an absolute number, so the same image behaves
# sensibly on a 1 GiB dev instance and a 4 GiB production one.
FROM eclipse-temurin:21-jre AS runtime
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
# MaxRAMPercentage=70 leaves ~30% for metaspace, code cache, thread
# stacks and direct buffers. If you set -Xmx to the container limit
# instead, the kernel kills you and the JVM never sees an OOMError.
ENV JAVA_OPTS="-XX:MaxRAMPercentage=70 -XX:+UseSerialGC"
# UseSerialGC is deliberate for SMALL instances: G1's own bookkeeping and
# background threads are not free, and below ~2 vCPU / 2 GiB the serial
# collector often wins on both latency and footprint. Above that, delete
# the flag and let the JVM's ergonomics pick G1.
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]Startup time, and why it is a hosting decision
A Spring Boot application on a stock JVM takes seconds to become ready: classpath scanning, bean graph construction, entity metamodel building, connection pool warm-up. Then it takes longer still to become fast, because the hot paths start out interpreted and get compiled as the JIT observes them. Neither number is a defect. Both are facts that a platform either accommodates or punishes.
Platforms punish it in two specific ways. The first is an aggressive health-check deadline: if the platform expects a port to answer within a handful of seconds and then declares the deploy failed, your only options are to raise the timeout, add a startup probe with a longer grace period, or cut startup work. The second is scale-to-zero implemented as cold container starts on the request path — where the first user after an idle period waits out the whole JVM boot, plus the platform's own machine-start latency, and gets a timeout instead of a page.
There are three real mitigations and they are worth knowing before you shop. Class Data Sharing (`-XX:+AutoCreateSharedArchive`) memory-maps a pre-parsed class archive and reliably removes a meaningful slice of startup. Spring's AOT processing does the bean-graph work at build time. And GraalVM native-image compiles ahead of time to a binary that starts in milliseconds — at the cost of a build that is slow and memory-hungry enough to be its own hosting requirement, plus reflection configuration for anything dynamic.
The build: layered jars matter more than the base image
Maven and Gradle both resolve a dependency tree into a local cache and then produce an artifact. On an ephemeral builder that cache starts empty every time, which is why the first thing to ask a platform is whether the dependency cache survives between deploys. The naive Dockerfile — copy the whole project, run `mvn package` — invalidates on every source change and re-downloads the world.
Spring Boot's answer is layered jars, which split the fat jar into dependencies, snapshot dependencies, loader, and your own classes, so that the layer holding a hundred megabytes of dependencies is cached independently of the layer holding your controllers. It is a build-tool feature rather than a platform feature, and it works everywhere — which makes it the highest-leverage thing you can do without changing hosts.
# syntax=docker/dockerfile:1.7
FROM maven:3-eclipse-temurin-21 AS builder
WORKDIR /app
# Resolve dependencies against the POM ALONE first. This layer survives
# every commit that doesn't touch pom.xml.
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -q dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn -B -q package -DskipTests
# Explode the fat jar into Spring Boot's layers so the huge dependency
# layer is cached separately from your own classes.
RUN java -Djarmode=layertools -jar target/*.jar extract --destination /app/layers
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/layers/dependencies/ ./
COPY --from=builder /app/layers/spring-boot-loader/ ./
COPY --from=builder /app/layers/snapshot-dependencies/ ./
COPY --from=builder /app/layers/application/ ./
ENV JAVA_OPTS="-XX:MaxRAMPercentage=70"
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]Why functions are usually the wrong shape for Java
Java on a function platform is not impossible — SnapStart-style snapshot restore and native-image both make it viable — but the structural mismatch runs deeper than cold starts. A function environment is frozen between invocations. That breaks `@Scheduled` methods, any `ExecutorService` doing background work, in-memory caches with time-based eviction, and anything else that assumes the clock keeps running between requests.
Connection pooling is the sharper edge. HikariCP exists to amortise connection setup across a long-lived process. In a function, every warm environment holds its own pool, so a traffic burst becomes a connection burst against your database, and the fix is an external pooler rather than a configuration change. If your Java service holds state, does periodic work, or talks to Postgres under load, pick a long-running process and stop reading function benchmarks.
The platforms, and what each is actually good at
- Fly.io — Bring your own image, pick a machine with real memory, run a long-lived JVM, and stop machines when idle. Best for teams who want VM-level control and are comfortable close to the infrastructure.
- Railway — Detects Maven and Gradle without a Dockerfile and gets you from git to a running Spring Boot app quickly, with Postgres one click away and per-branch environments. Verify builder resources before committing a large multi-module build.
- Render — Build command plus start command, or your own Dockerfile, with first-class background workers and cron alongside the web service. The most boring path for one Spring Boot API and a managed Postgres, which is a compliment.
- Heroku — The original Java buildpack, still one of the smoothest git-push experiences, and a large ecosystem of add-ons. Worth evaluating on economics rather than capability.
- Google Cloud Run — Container-based with request-oriented scaling and a build service where you can choose a machine with real memory, which makes it one of the better homes for a native-image build. Check the CPU-outside-requests setting: without it, background work between requests does not get CPU.
- AWS App Runner and Elastic Beanstalk — The managed paths inside an existing AWS footprint. Beanstalk is the older, more configurable one; App Runner is the simpler container-shaped one. Both are reasonable when the rest of your world is already AWS.
- Azure Container Apps — Containers with scale-to-zero and KEDA-driven scaling, and the natural choice if your organisation is Azure-first.
- Northflank — Configurable build resources as a first-class pipeline feature, plus services, jobs, and managed databases. The direct answer to a build that keeps getting killed.
- Koyeb — Buildpack or Dockerfile, global placement, scale-to-zero as an ordinary setting. Suits a small Quarkus or Micronaut service better than a heavyweight Spring Boot monolith.
- VPS plus systemd — Build the jar in CI, copy it over, run it under a systemd unit with a memory limit and a restart policy. Best economics on the list if you are willing to own patching and TLS.
- PandaStack — Git-driven with no Dockerfile: mise reads `.tool-versions` for the JDK, and you give it explicit build and start commands because Java is not one of the auto-detected frameworks. Each app is a Firecracker microVM with its own kernel and 4 GiB of RAM, so a Maven build and a JVM heap both fit; snapshot-restore makes scale-to-zero fast enough to use on staging. Best for per-app isolation and environment-per-branch that costs nothing while idle. Not the right host for a GraalVM native-image build — that wants more build memory than an app VM has.
Pick by situation
- One Spring Boot API and a Postgres database, and you want to stop thinking about it → Render, or Railway if the first hour matters more than the fifth month.
- A Spring Boot monolith that needs several gigabytes of heap → Fly.io or Northflank, where instance size is an explicit choice rather than a tier side effect.
- You are committed to GraalVM native-image → Cloud Run or Northflank for the configurable build machine, or build the image in your own CI and deploy the artifact anywhere.
- Quarkus or Micronaut, small footprint, spiky traffic → Koyeb or Cloud Run. These frameworks were designed for exactly this shape and it would be a waste not to use it.
- Staging and per-branch environments that should cost nothing overnight → PandaStack, mine, or Koyeb. Both make idle cheap; the difference is isolation model.
- The service holds thousands of WebSockets or runs `@Scheduled` work → anything with a genuinely long-running process. Not functions, and check the platform proxy's connection-duration limit before committing.
- Your service compiles or runs code it did not write — a CI service, a plugin host, a coding agent → a microVM boundary rather than a container one. Maven plugins and Gradle build scripts execute arbitrary code at build time.
The short version
Ask about memory first, because that is where Java hosting actually goes wrong. Then set `MaxRAMPercentage` instead of `-Xmx`, use layered jars so a one-line change does not re-download your dependency tree, and give the JVM a startup grace period longer than the platform's default. Those three changes fix more Java deployments than switching hosts does.
After that, the decision is mostly about process lifetime. If your service does work between requests — and most Spring Boot services do — pick a platform that keeps a process alive and treat scale-to-zero as a staging feature rather than a production one. Get memory and startup right and Fly, Railway, Render, Heroku, Cloud Run, App Runner, Container Apps, Northflank, Koyeb, a VPS, and PandaStack will all run your jar perfectly well.
Frequently asked questions
Why does my Spring Boot app get killed with exit code 137?
Exit 137 is SIGKILL from the kernel's OOM killer, not a JVM error. It means the process's total memory footprint exceeded the container or cgroup limit. The usual cause is setting -Xmx to the full instance size: the heap is only part of what a JVM uses, and metaspace, the JIT code cache, thread stacks, and direct byte buffers used by the HTTP and database layers all live outside it. Replace -Xmx with -XX:MaxRAMPercentage=70 so the heap scales with the container limit and leaves headroom for everything else. If you were already using a percentage, lower it and check for large direct-buffer use — Netty-based stacks and some drivers allocate off-heap. A genuine heap exhaustion looks different: you get an OutOfMemoryError with a stack trace, and the process usually logs before it dies.
Do I need a Dockerfile to deploy a Spring Boot app?
No. Several platforms detect a Maven or Gradle project, run the build, and start the resulting jar from build and start commands you configure, with no image to maintain. What you give up is control over the build stages — which matters for Java, because Spring Boot's layered-jar mode is the difference between caching a hundred megabytes of dependencies and rebuilding them on every commit. A middle path that works well: skip the Dockerfile, but enable layered jars in your build plugin and make sure the platform persists the Maven or Gradle cache between deploys. Time two consecutive deploys with no source change; if the second is not dramatically faster, the cache is not real regardless of what the docs say.
Is GraalVM native-image worth it for hosting?
It is worth it when startup latency or memory footprint is your actual constraint, and not otherwise. A native binary starts in milliseconds and idles on a fraction of the JVM's memory, which turns Java into a reasonable fit for scale-to-zero and function platforms it is normally bad at. The costs land in three places. The build is slow and needs several gigabytes of memory for points-to analysis, so your builder becomes a shopping requirement. Anything reflective — some Hibernate mappings, some JSON binders, some Spring features — needs explicit configuration or a build-time hint. And peak throughput on long-running workloads is often lower than a warmed-up JIT, because there is no profile-guided recompilation at runtime. Use it for short-lived or latency-sensitive services; keep the JVM for a monolith that runs for weeks.
Can Java run on serverless functions?
Technically yes, structurally usually no. Snapshot-restore features and native-image both make Java cold starts acceptable, so the latency argument is weaker than it used to be. The problem is that function environments freeze between invocations, and a typical Java service assumes the clock keeps running: @Scheduled methods stop firing, ExecutorService background work stalls mid-task, time-based cache eviction misbehaves, and each warm environment holds its own HikariCP pool so a traffic burst becomes a connection burst against your database. Functions are a good fit for genuinely request-scoped Java — queue consumers, event handlers, webhooks. For anything that does work between requests, use a long-running process.
How much memory does a Spring Boot service actually need?
Measure it rather than guessing, because the honest answer ranges from a couple of hundred megabytes for a trimmed Quarkus service to several gigabytes for a JPA-heavy monolith. The right method is to run the app under realistic load and watch resident set size, not heap: enable -XX:NativeMemoryTracking=summary and read the breakdown, which will show you how much is heap versus metaspace, code cache, thread stacks, and direct buffers. Then size the instance so the heap you need is roughly 70% of it. One practical shortcut for small instances: below about 2 vCPU and 2 GiB, -XX:+UseSerialGC often beats G1 on both latency and footprint, because G1's background threads and bookkeeping are not free at that scale.
Keep reading
- Sandboxing an untrusted Maven build — Maven plugins execute arbitrary code at build time
- The best Rust hosting platforms in 2026
- Fixing an out-of-memory build
- PandaStack Apps — git-driven deploys onto a microVM per app
- Moving from serverless functions to a long-running server
49ms p50 cold start. Fork, snapshot, and scale to zero.