To deploy Java apps to production you need four things: a runnable artifact, a runtime, a place to run it, and a way to see whether it is healthy. This guide builds each one in order with real commands. The example shows how to deploy Spring Boot apps, because that is the most common shape, but the steps work for Quarkus, Micronaut, Javalin or any framework that produces a JAR with a main class. If you have not picked a platform yet, skim the Java hosting comparison first. The commands below use Fly.io.
Figures checked in November 2026.
Step 1: Choose a JDK version
Pick an LTS release. Java 25 is the newest one, and Java 21 is still fully supported by every major vendor. Eclipse Temurin plans builds for Java 25 until at least September 2031 and for Java 21 until at least December 2029. Spring Boot 4 needs Java 17 or later, so both choices work.
Use the same major version everywhere: in your build file, on your machine, and in the Docker image. A mismatch shows up as an UnsupportedClassVersionError at startup, which is easy to fix but annoying to hit at 2 a.m. The Java versions and support dates page lists end dates for Oracle, Temurin, Corretto, Azul, Microsoft and Red Hat builds, and the OpenJDK vs Oracle JDK guide explains which builds are free to use in production.
Step 2: Build a fat JAR
A fat JAR holds your code, all dependencies and an embedded server. Spring Boot's plugins build it for you.
With Maven
./mvnw clean package
ls target/*.jar
With Gradle
./gradlew bootJar
ls build/libs/*.jar
Use the wrapper scripts (mvnw, gradlew) rather than a globally installed tool. They pin the build tool version in the repository, so CI and the Docker build use exactly what you tested with.
Step 3: Run it locally the production way
Before any container, run the JAR the way the server will:
java -jar target/myapp-0.0.1-SNAPSHOT.jar
Then open http://localhost:8080. If the app reads secrets or database URLs, pass them as environment variables now, not through a local properties file. Spring Boot maps environment variables to properties, so SPRING_DATASOURCE_URL sets spring.datasource.url. Getting this right on your laptop saves a confusing first deploy, whether you deploy Java web apps to Fly.io, Render or your own VM.
Step 4: Write a Dockerfile with Eclipse Temurin
The official eclipse-temurin images on Docker Hub use tags in the form <version>-<jdk|jre>-<os>. Current examples are 25-jdk-noble and 25-jre-noble (Ubuntu 24.04), 25-jre-alpine and 21-jre-noble. The two-stage file below builds with the JDK and ships with the JRE:
# build stage
FROM eclipse-temurin:25-jdk-noble AS build
WORKDIR /src
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -B dependency:go-offline
COPY src/ src/
RUN ./mvnw -B -DskipTests package
# runtime stage
FROM eclipse-temurin:25-jre-noble
WORKDIR /app
RUN useradd --system --no-create-home appuser
COPY --from=build /src/target/*.jar app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]
Copying pom.xml before the source lets Docker cache the dependency download, so a code change does not fetch the whole internet again. The app runs as a non-root user. Build and test the image locally:
docker build -t myapp .
docker run --rm -p 8080:8080 -m 512m myapp
The -m 512m flag matters. It gives the container the same memory limit it will have in production, so a memory problem shows up on your machine first.
Step 5: Size the JVM for a small instance
This is the step most guides skip, and it decides whether a Java app survives on an entry plan. On Linux the JVM detects container limits by default (UseContainerSupport is on), and it sizes the maximum heap from the container memory. Oracle's java documentation states the default for -XX:MaxRAMPercentage is 25 percent. On a 512 MB container that means a heap of about 128 MB, while the rest of the memory sits unused.
Setting -XX:MaxRAMPercentage=75, which is also the example value in Oracle's own documentation, raises the heap to roughly 384 MB on the same machine. The remaining quarter is not wasted. Metaspace, thread stacks, the JIT code cache and direct buffers live outside the heap, and a container that hands everything to the heap gets killed by the kernel without a Java error message. On 256 MB instances, start lower, around 60 to 65 percent, and test with real traffic.
You can also set the flag without rebuilding the image through the standard JAVA_TOOL_OPTIONS environment variable, which the JVM reads at startup. That is handy when you want to tune memory per environment.
Step 6: Add a health check
Add Spring Boot Actuator to the project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
It exposes /actuator/health, which returns {"status":"UP"} when the app and its checked dependencies (a database, for example) are reachable. Point the platform's health check at that path. A plain / check only proves the web server answers, not that the app can do anything useful.
Step 7: Deploy to Fly.io
Fly.io runs Docker images on small virtual machines billed per second. It has no free tier for new organizations, only a trial with $1 of credit for seven days. A shared-cpu-1x machine with 512 MB costs $3.69 per month running all the time, and 1 GB costs $6.70. That makes it one of the cheaper ways to deploy Spring Boot apps that have to stay awake.
Create the app
Install flyctl, sign in, and run this from the project folder:
fly launch --no-deploy
It detects the Dockerfile and writes a fly.toml file. Edit it so the port, memory and health check match the app:
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "stop"
min_machines_running = 0
[[http_service.checks]]
grace_period = "30s"
interval = "30s"
method = "GET"
timeout = "5s"
path = "/actuator/health"
[[vm]]
memory = "512mb"
The grace_period gives the JVM time to boot before the first check. Fly's documented example uses 10 seconds, which is too short for many Spring Boot apps on a shared CPU. With auto_stop_machines = "stop" and min_machines_running = 0, Fly stops the machine when there is no traffic and starts it on the next request, so you pay less but the first visitor waits for a JVM start. Set min_machines_running = 1 if that delay is a problem.
Ship it
fly deploy
Fly builds the image, starts a machine, waits for the health check and switches traffic. Every later change ships the same way, which is the main reason to deploy Java apps from a Dockerfile rather than by hand. Your app is live at https://<app-name>.fly.dev.
Step 8: Read the logs
fly logs
Everything the app writes to stdout and stderr appears here, together with platform events such as health check failures and machine restarts. Keep Spring Boot's default console logging. Writing log files inside the container is pointless, because the file system is replaced on every deploy. If you see the machine restart without a Java exception, look at memory first and revisit step 5.
Step 9: Add a custom domain
fly certs add example.com
fly certs show example.com
The first command registers the hostname and prints the A and AAAA records to create at your DNS provider. Fly also shows them in the dashboard under Certificates. The second command reports progress, and the certificate is ready once its status changes to Ready. HTTPS is handled by Fly, so the app keeps listening on plain HTTP port 8080.
Other hosts for Java app hosting
The same Docker image runs almost anywhere, so you can deploy Java app images built here on most container platforms without changes. Render runs Docker images and has a free web service with 512 MB that spins down after 15 minutes without traffic and needs about a minute to wake up. Heroku skips Docker and builds Java from source with a native buildpack whose default is OpenJDK 25. Its Eco dyno costs $5 per month and sleeps after 30 minutes of inactivity, and Basic costs $7 per month and stays on. Clever Cloud runs JAR and WAR files natively and bills in euros, from about EUR 4.56 per month for 256 MiB.
For Java app hosting on a zero budget, the free Java hosting guide explains which free tiers allow always-on apps and which do not. To deploy Java web apps that ship as a WAR into an existing Tomcat instead of a fat JAR, read servlets and JSP to Jakarta EE first, because the Tomcat version decides which servlet namespace your code must use.
Sources
- hub.docker.com/_/eclipse-temurin
- github.com/docker-library/docs/blob/master/eclipse-temurin/README.md
- docs.oracle.com/en/java/javase/25/docs/specs/man/java.html
- docs.fly.io/reference/configuration
- fly.io/docs/apps/custom-domain
- fly.io/docs/about/pricing
- github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide
- docs.spring.io/spring-boot/how-to/webserver.html
- adoptium.net/support
- render.com/docs/free
- heroku.com/pricing
- devcenter.heroku.com/articles/java-support
- clever.cloud/pricing