Production: Actuator, Packaging & Deployment
Everything so far has run one way: you hit the green arrow in your IDE, the app boots on localhost:8081, and you poke it from a browser. That's the inner loop, and it's great for building. But "it runs on my machine" is not a deployment — it's a demo that happens to be on the right machine.
The mental model for this phase: shipping a Spring Boot app is turning your source into one self-contained file and then putting that file somewhere a server can run it. No app server to install, no WAR to drop into Tomcat, no fragile setup script. You build a single jar that already contains a web server, you feed it production config from the outside (the precedence rules from Phase 4), and you run it with java -jar. Optionally you wrap that jar in a container so the runtime is identical everywhere. Once you see those pieces, deploying stops feeling like a different discipline and starts feeling like the last, easy step.
We'll go in the order you'd actually do it: first make the app observable (can a load balancer tell it's alive?), then package it, then configure it for prod, then containerize it, then talk about where it lands.
Spring Boot Actuator — production endpoints for free
Before you deploy anything, you need to answer a deceptively important question: how does the outside world know your app is healthy? A load balancer or Kubernetes needs to ping something and get a yes/no. You could write that endpoint yourself, but Spring Boot already has it — plus metrics, build info, and more — in a module called Actuator.
📝 Actuator — a Spring Boot starter that adds a set of ready-made HTTP endpoints for monitoring and managing a running app: /actuator/health (is it alive and are its dependencies OK?), /actuator/metrics (memory, request counts, GC, and more), /actuator/info (build/version info you supply). You add one dependency; the endpoints appear.
You add it the same way you add any dependency (recall coordinates from the Java tooling phase — groupId:artifactId:version):
org.springframework.boot
spring-boot-starter-actuator
What just happened: You pulled in the actuator starter. Note there's no <version> — Spring Boot's parent POM manages versions for its own starters, so they stay in lockstep with your Boot version. On the next restart, Actuator auto-configures itself and registers its endpoints. You wrote zero endpoint code.
The one endpoint you'll use constantly is health. Hit it:
What just happened: Actuator reports the app is UP. That tiny response is exactly what a load balancer or container orchestrator polls — if it gets UP (HTTP 200), it keeps sending traffic; if it gets DOWN (HTTP 503) or no answer, it stops routing to this instance and may restart it. Actuator's health check is also smart: it aggregates the health of things your app depends on, so if your database connection is dead, health flips to DOWN automatically. You didn't wire that up — adding the JPA datasource (Phase 5) registered a database health contributor for you.
By default, only health is exposed over HTTP. The rest are switched off until you opt in — and that default is a security feature, not an oversight:
management:
endpoints:
web:
exposure:
include: health,info,metrics
What just happened: You explicitly listed which actuator endpoints are reachable over HTTP — health, info, and metrics. Anything not in the list stays unreachable from the outside.
⚠️ Never expose actuator endpoints carelessly in production. Some of them leak serious internals. /actuator/env dumps your full configuration — including resolved property values that may contain secrets. /actuator/heapdump downloads a snapshot of your app's memory (passwords, tokens, user data, all of it). /actuator/shutdown can stop the app. Whatever you do, do not write include: "*" to expose everything in production. Expose the minimal set you actually need, and ideally put the management endpoints behind authentication or on a separate, internal-only port. Treat the actuator surface as part of your attack surface.
💡 Actuator is your app's first observability — health and a handful of metrics out of the box. Real production systems build on this with proper metrics collection, dashboards, and tracing. Actuator exposes metrics in a format tools like Prometheus scrape directly. When you're ready to go deeper than "is it up?", see /guides/observability-logs-metrics-traces.
Packaging: the fat jar
Now the app knows how to report its health. Time to turn it into something you can copy to a server. In old-school Java you'd build a WAR file and deploy it into a separately-installed application server (Tomcat, JBoss). Spring Boot threw that model out. Your build produces one runnable file with the server inside it.
📝 Fat jar (a.k.a. uber jar) — a single executable .jar that bundles three things: your compiled code, every dependency it needs, and an embedded web server (Tomcat by default). It has no external requirements beyond a Java runtime. That one file is your entire deployable.
You build it with one command. If you used Spring Initializr, your project came with the Maven wrapper (mvnw), so you don't even need Maven installed:
[INFO] --- spring-boot-maven-plugin:3.x.x:repackage ---
[INFO] Replacing main artifact with repackaged archive
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 18.421 s
What just happened: clean wiped the previous build output, then package compiled your code, ran your tests, and assembled the jar. The key line is the spring-boot-maven-plugin:repackage step — that's the plugin (included automatically in an Initializr project) rewriting the plain jar into a fat jar with the server and all dependencies tucked inside. The result lands in target/, named like bookstore-0.0.1-SNAPSHOT.jar.
Now run it the way a server would — no IDE, just Java:
:: Spring Boot :: (v3.x.x)
... : Starting BookstoreApplication using Java 21
... : Tomcat initialized with port 8081 (http)
... : Started BookstoreApplication in 2.3 seconds
What just happened: java -jar launched the fat jar. Notice Tomcat starts up from inside the jar — there's no separate server to install or configure, because it shipped with your code. This is the whole deployment story in one line: build the jar, copy it to a machine that has Java, run java -jar. That's it. (Gradle users get the same artifact from ./gradlew bootJar.)
Prod configuration: don't ship your dev defaults
The jar runs. But right now it's still wearing its development clothes — an in-memory H2 database, chatty DEBUG logging, maybe wide-open CORS. Shipping those to production ranges from embarrassing to dangerous. This is exactly what profiles and externalized config from Phase 4 are for.
The pattern: keep dev-friendly defaults in your base/dev config, put the real production values in application-prod.yml, and feed secrets from the environment. Activate the prod profile when you run:
SPRING_PROFILES_ACTIVE=prod \
What just happened: SPRING_PROFILES_ACTIVE=prod told Boot to layer application-prod.yml over the base config, swapping H2 for the real PostgreSQL and turning logging down to WARN. The datasource URL, username, and password came in as environment variables, which sit above the config file in precedence — so the real database and the real secret never had to be written into a committed file. Same jar you built a minute ago; production behavior, driven entirely by inputs.
⚠️ Audit what leaks from dev to prod before you ship. The usual offenders: the H2 in-memory database (your data vanishes on restart), spring.jpa.hibernate.ddl-auto=create (drops and recreates your schema — catastrophic against a real DB), DEBUG logging (slow and noisy, and it can log sensitive request data), and permissive CORS like allowedOrigins("*"). None of these belong in production. Make application-prod.yml an explicit, conservative override of every one of them rather than trusting that the defaults are safe — they're tuned for your laptop, not the internet.
Docker: make the runtime identical everywhere
java -jar on a server works, but it quietly assumes that server has the right Java version installed and configured. Multiply that across your laptop, a teammate's laptop, CI, staging, and prod, and "works on mine" creeps back in through version drift. A container freezes the entire runtime — the exact JDK, the OS libraries, your jar — into one image that runs identically wherever Docker runs.
A Spring Boot fat jar makes the Dockerfile almost trivial: start from a base image that has Java, copy the jar in, run it.
# Start from a small image that already has a Java runtime
FROM eclipse-temurin:21-jre
# Where the app lives inside the container
WORKDIR /app
# Copy the fat jar built by `./mvnw package` into the image
COPY target/bookstore-0.0.1-SNAPSHOT.jar app.jar
# Document the port the app listens on
EXPOSE 8081
# The command that runs when the container starts
ENTRYPOINT ["java", "-jar", "app.jar"]
What just happened: Each line builds up the image. FROM eclipse-temurin:21-jre picks a base that already contains a Java 21 runtime (a JRE, not the full JDK — smaller, since you only need to run the jar, not compile). COPY drops your fat jar in as app.jar. EXPOSE is documentation (it doesn't actually open the port). ENTRYPOINT is the command Docker runs on startup — the same java -jar you ran by hand, now baked into the image. Because the fat jar already bundles Tomcat and every dependency, the Dockerfile doesn't need to install anything else.
Build the image and run it:
What just happened: docker build turned the Dockerfile into a tagged image, bookstore:1.0. docker run started a container from it: -p 8081:8081 maps the container's port to your host so you can reach it, and -e SPRING_PROFILES_ACTIVE=prod passes the active profile in as an environment variable — the same externalized-config mechanism, now flowing through Docker. That image is now a portable unit: it runs bit-for-bit identically on your machine, CI, and the production cluster, because the Java version and OS came along inside it.
💡 If Docker itself is still fuzzy — images vs containers, layers, why any of this is an improvement — read /guides/docker-without-the-magic. For Spring Boot specifically, the win is that the fat jar and the container are a natural pair: the jar makes the app self-contained, the image makes the runtime self-contained.
Where it actually runs — and why this is easy
You have a jar, or an image. Where does it go? You've got a spectrum, roughly from most-hands-on to least:
- A plain VPS or VM. Copy the jar up, run
java -jar(usually managed bysystemdso it restarts on crash/reboot), and put nginx in front as a reverse proxy to handle TLS and forward traffic to your app onlocalhost:8081. Maximum control, maximum manual work. - A container platform. Push your image to a registry and let something (Kubernetes, ECS, Cloud Run) schedule and run it. This is where your
/actuator/healthendpoint earns its keep: the platform polls it for liveness and readiness probes, and that's what enables zero-downtime rollouts — it starts new instances, waits until their health saysUP, shifts traffic over, then retires the old ones. No health endpoint, no safe rollout. - A PaaS. Platforms like Railway, Render, Fly.io, or Heroku take your repo or image and handle the server, TLS, and scaling for you. Least control, least to manage — often the right call for a side project.
💡 Step back and notice why all of this was short. The embedded server plus the fat jar is the entire reason Spring Boot deploys so much more easily than classic Java. The old model meant installing and tuning an application server, then deploying a WAR into it — a whole separate operational skill. Boot collapsed that: the server lives inside your one runnable artifact, so "deploy" becomes "run a file" (or "run an image"). That single design choice is a big part of why Spring Boot won.
For taking a real project the last mile to a live URL — domain, TLS, picking a host, the unglamorous final 20% — see /guides/ship-your-side-project.
Recap
- Actuator gives you production monitoring endpoints for free — add
spring-boot-starter-actuatorand you get/actuator/health(what load balancers and orchestrators poll),/actuator/metrics, and/actuator/info. - Lock down the actuator surface. Only
healthis exposed by default; opt others in explicitly withmanagement.endpoints.web.exposure.include, and never expose everything —/actuator/envand/actuator/heapdumpcan leak secrets. ./mvnw packagebuilds a fat jar — your code, all dependencies, and an embedded Tomcat in one self-contained file. Run it anywhere withjava -jar app.jar. That file is your deployable.- Configure prod from the outside. Use the
prodprofile plus environment variables for the real database and secrets; never let dev defaults (H2,ddl-auto=create, DEBUG logging, wide-open CORS) reach production. - Docker freezes the runtime. A minimal Dockerfile (JRE base + the jar) produces an image that runs identically everywhere; pass config in with
-e. - Where it runs ranges from a VPS behind nginx, to a container platform (where health checks enable zero-downtime rollouts), to a PaaS. The embedded server + fat jar is why Spring Boot deploys so easily compared to old WAR-on-Tomcat Java.
Quick check
Make sure the production picture is solid before the guide wraps up:
[
{
"q": "Why do load balancers and orchestrators care about /actuator/health?",
"choices": [
"It returns a simple UP/DOWN status they poll to decide whether to route traffic to an instance — and it enables zero-downtime rollouts",
"It speeds up the application by caching responses",
"It is required for the embedded Tomcat server to start at all",
"It encrypts traffic between the app and the load balancer"
],
"answer": 0,
"explain": "Health is the endpoint infrastructure polls. UP (200) means keep sending traffic; DOWN (503) or no answer means stop routing and maybe restart. A container platform uses it for liveness/readiness probes, which is exactly what makes safe, zero-downtime rollouts possible."
},
{
"q": "What makes a Spring Boot fat jar runnable on any machine with just `java -jar app.jar`?",
"choices": [
"It bundles your compiled code, all dependencies, AND an embedded web server inside one self-contained file",
"It compiles your code to native machine code so no JVM is needed",
"It downloads its dependencies from Maven Central at startup",
"It includes a copy of the operating system"
],
"answer": 0,
"explain": "The fat (uber) jar packs your classes, every dependency, and an embedded Tomcat into a single file. There's nothing to install on the target beyond a Java runtime — which is why deployment collapses to copying one file and running it, unlike the old WAR-into-a-server model."
},
{
"q": "Which actuator configuration is dangerous to use in production?",
"choices": [
"exposure.include: \"*\" — exposing every endpoint, including /env and /heapdump which can leak secrets and memory contents",
"exposure.include: health — exposing only the health endpoint",
"Putting management endpoints behind authentication",
"Running management endpoints on a separate internal-only port"
],
"answer": 0,
"explain": "Exposing everything makes endpoints like /actuator/env (resolved config, possibly with secrets), /actuator/heapdump (a full memory snapshot), and /actuator/shutdown reachable. Expose only the minimal set you need, and ideally guard the management surface with auth or a separate port."
}
]
← Phase 9: Security with Spring Security · Guide overview · Phase 11: Where to Go Next →
Check your understanding
1. Why do load balancers and orchestrators care about /actuator/health?
2. What makes a Spring Boot fat jar runnable on any machine with just `java -jar app.jar`?
3. Which actuator configuration is dangerous to use in production?