Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 38 · Secure, Tested, Production-Ready
Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
Prepare the Job Application Tracker to run outside your laptop: configuration and secrets from the environment, useful logging, Actuator health checks verified on the running application, a multi-stage Dockerfile and a Compose file, and a production checklist. The Docker files are written but were not run in this environment.
For
Learners with a working Spring Boot API who need to understand how it is configured, observed and packaged to run on a server or in a container.
Level
Before you start
Spring Boot configuration (article 32), security and secrets (article 36) and logging basics (article 22). The Actuator behaviour was verified on JDK 21 with Spring Boot 4.1.1. Docker and Docker Compose were not available in the environment where this course was verified, so the Dockerfile and Compose file were NOT RUN.
On this page
Key takeaways
- The same build artifact must run in every environment. Differences, including every secret, come from the environment (configuration outside the code), and a missing required value must stop the application at start-up.
- Log to standard output, at INFO by default, with enough context to diagnose a problem, and never log secrets, tokens or personal data.
- Actuator exposes health endpoints for monitoring and for container platforms. Expose only what you need, never show component details publicly, and keep everything else behind authentication.
- A container image should be built in stages, run as a non-root user and contain no secrets. A Compose file wires the application to a database and passes configuration through environment variables.
- Before going live, check the list: HTTPS, secrets, database migrations, backups, health checks, limits, logging, dependency updates and a rollback plan.
An application that works on your machine is not yet an application that runs in production. Someone has to give it its configuration and secrets, decide where its logs go, ask whether it is healthy, and package it so that it starts the same way everywhere. This article does that for the Job Application Tracker.
What you will learn
#- How to configure one build for many environments, and handle secrets.
- What and how to log in production.
- How to add health checks with Actuator.
- How to package the application with Docker and Compose.
- A checklist for going live.
Honesty about verification: the Actuator endpoints below were run on a real application. The Dockerfile and Compose file are standard patterns but were not built or run, because Docker was not installed where this course was verified. Treat them as a starting point to check on your own machine.
Configuration: one artifact, many environments
#The principle from the twelve-factor app method: build once, configure from the environment. The jar you test is the jar you deploy. Everything that differs between development, testing and production is supplied from outside:
Kind of setting | Where it lives | Example |
|---|---|---|
Non-secret defaults |
| page sizes, token lifetime |
Environment-specific but not secret | Environment variable or profile file | the database URL, allowed origins |
Secrets | Environment variable or a secret manager, never the repository or the image |
|
Spring Boot makes this easy, as article 32 showed: an environment variable such as DB_URL or SECURITY_ALLOWEDORIGINS overrides the file, and the project's application.properties reads secrets with ${JWT_SECRET} and has no defaults for them. If a required value is missing, the application refuses to start, with a message that does not reveal the value (article 36). Failing at start-up is much better than failing on the first request.
Rules for secrets in production:
- Never commit them, and never bake them into a Docker image: anyone who can pull the image can read them.
- Inject them at run time, from your platform's secret store, or from environment variables set by the orchestrator.
- Use a different secret in every environment, and rotate them if they may have leaked.
- Do not print them: not in logs, not in error messages, not in
/actuator/env.
Logging in production
#Logging is how you learn what happened when you were not watching (article 22 taught the basics). In a deployed service:
- Write to standard output, not to files. The platform (Docker, Kubernetes, a log service) collects it. Spring Boot does this by default.
- Use INFO as the default level, and raise a package to DEBUG temporarily for diagnosis:
logging.level.com.example.jobtracker=debug, without redeploying code. - Log events, with context: the user id, the resource id, the outcome. Log an error once, with its stack trace, where it is handled. The catch-all handler in the API does exactly that and returns a generic message to the client.
- Never log secrets, tokens, passwords or personal data. A debug log of a request body can contain all of them.
- Correlate requests. A request id carried through the logs lets you follow one request across components. Structured (JSON) logging makes logs searchable. Spring Boot supports structured logging formats through configuration.
- Watch for the noisy ones:
show-sqlis for development only, and DEBUG logging in production is slow and may expose data.
Health checks with Actuator
#A load balancer or container platform needs to ask the application: are you alive, and can you take traffic? Spring Boot Actuator provides that. Add the starter (spring-boot-starter-actuator) and expose only what you need:
management.endpoints.web.exposure.include=healthmanagement.endpoint.health.probes.enabled=truemanagement.endpoint.health.show-details=neverThe first line exposes only the health endpoint over HTTP. The second turns on liveness and readiness probes, the two questions orchestrators ask: liveness ("is the process healthy, or should it be restarted?") and readiness ("is it ready to receive traffic?"). The third makes sure the public response says only UP or DOWN and never lists components such as the database. The security configuration permits GET /actuator/health without a token (a one-line change in SecurityConfig) and keeps everything else denied. This is the real output from the running application, with PostgreSQL connected:
$ curl -i http://localhost:18085/actuator/healthHTTP/1.1 200 Content-Type: application/vnd.spring-boot.actuator.v3+json{"groups":["liveness","readiness"],"status":"UP"}$ curl -i http://localhost:18085/actuator/health/readinessHTTP/1.1 200 {"status":"UP"}$ curl -i http://localhost:18085/actuator/envHTTP/1.1 401 /actuator/healthreturnedUPand listed the two groups,livenessandreadiness./actuator/health/readinessreturnedUP. Readiness depends on the application context being ready; it is what a platform checks before sending traffic./actuator/env, which would list the environment (including secrets), is not exposed and is behind authentication anyway: it returned401.
Actuator can also expose metrics, thread dumps and more. Expose the minimum, protect it, and never leave management endpoints open to the internet. The same build still passes all 125 tests (the run for this article used JDK 21).
Database migrations at deploy time
#Flyway runs at start-up and applies pending migrations (article 34). That is convenient, and it has consequences in production:
- The application's database user needs the permission to change the schema. Many teams run migrations as a separate step with a more privileged user, and run the application with a restricted one.
- Migrations must be backwards compatible with the previous version of the application, so a rolling deployment (old and new versions running together) works. Add a column in one release, use it in the next, drop the old one in a third.
- Back up before migrating, and test restoring a backup. A backup you have never restored is a hope.
Docker
#A container image packages the application with its runtime, so it runs the same way everywhere. A Dockerfile describes how to build it. This one uses two stages: the first compiles with Maven and the second contains only a Java runtime and the jar:
# Build stage: compile and package with MavenFROM maven:3.9-eclipse-temurin-21 AS buildWORKDIR /buildCOPY pom.xml .COPY src ./srcRUN mvn -B -ntp -q package -DskipTests# Run stage: only a Java runtime and the jar, running as an unprivileged userFROM eclipse-temurin:21-jreRUN useradd --system --uid 10001 appuserWORKDIR /appCOPY --from=build /build/target/jobtracker-*.jar app.jarUSER appuserEXPOSE 8080ENTRYPOINT ["java", "-jar", "app.jar"]Why it is written this way:
- Multi-stage build: the final image has no Maven, no source code and no build caches, so it is smaller and exposes less.
- A non-root user: if an attacker takes over the process, they do not have administrator rights inside the container.
- No secrets in the image. Nothing sensitive is copied or set. The application reads its configuration from the environment when it starts.
- Pin versions deliberately. Image tags such as
21-jremove over time. Decide how you track updates and security patches of the base image. - Possible improvements: layer the dependencies for better build caching, a
.dockerignorefile, a health check instruction, and memory settings for the JVM in a container.
Docker Compose describes several containers together. This file starts PostgreSQL and the API, waits for the database to be healthy, and passes all configuration through environment variables, with the secrets coming from the shell or an .env file that is not committed (the project's .gitignore lists .env):
services: db: image: postgres:18 environment: POSTGRES_DB: jobtracker POSTGRES_USER: jobtracker POSTGRES_PASSWORD: ${DB_PASSWORD} healthcheck: test: ["CMD-SHELL", "pg_isready -U jobtracker -d jobtracker"] interval: 5s timeout: 3s retries: 10 api: build: . depends_on: db: condition: service_healthy environment: DB_URL: jdbc:postgresql://db:5432/jobtracker DB_USER: jobtracker DB_PASSWORD: ${DB_PASSWORD} JWT_SECRET: ${JWT_SECRET} SECURITY_ALLOWEDORIGINS: http://localhost:3000 ports: - "8080:8080"To use it you would set the two secrets in your shell (for example export DB_PASSWORD=... and export JWT_SECRET=$(head -c 32 /dev/urandom | base64)), run docker compose up --build, and call http://localhost:8080/actuator/health. This was not run here. Note that postgres:18 and the base image names are the ones expected to exist, so check them against the current Docker Hub tags.
A production checklist
#- HTTPS terminates in front of the application, and plain HTTP redirects or is refused.
- Secrets come from the platform, are unique per environment, and are not in Git, images or logs.
- Database: migrations tested, a restricted application user, backups taken and restored in a test, connection pool sized to the database limit.
- Health checks configured for liveness and readiness, with sensible timeouts.
- Logging and monitoring: logs collected, errors alerting someone, basic metrics (request rate, errors, latency).
- Limits: memory and CPU limits for the container, request size limits, rate limiting on login.
- Dependencies updated and scanned, and the base image patched.
- CORS limited to the real front-end origins.
- A rollback plan: you can deploy the previous version, and the database changes allow it.
- Documentation: a README with how to build, configure, run and diagnose.
Common mistakes
#- Different jars for different environments, so what you tested is not what runs.
- Secrets in the image, the repository or the logs.
- Exposing all Actuator endpoints, or health details, to the public.
- Logging in files inside the container, which disappear with it.
- Running the container as root, and using the
latesttag everywhere. - Letting the application start with missing configuration, then failing later.
- Never testing a restore of the backups.
- Applying breaking schema changes while the old version still runs.
Exercises
#Exercise 1
Where does it go?
#For each value say where it belongs: (a) the page-size default, (b) the production database password, (c) the production database host, (d) the JWT signing secret.
Exercise 2: Liveness or readiness?
#The database is down. Should the liveness probe fail? Should the readiness probe? Explain.
Exercise 3: Spot the problems
#A Dockerfile does COPY . ., sets ENV JWT_SECRET=abc123, and runs as root. List three problems.
Exercise 4: What to log
#A user logs in. Which of these should be logged: the email, the password, the token, the user id, the outcome?
Exercise 5: Safe migration
#You must rename a column used by the running version. Describe a safe sequence of releases.
Show solutionHide solution
(a) application.properties in the jar: a non-secret default. (b) A secret: an environment variable or secret manager, never in the repository. (c) Environment-specific but not secret: an environment variable (for example DB_URL) or a profile file. (d) A secret, like (b), different per environment.
Exercise 2
Show solutionHide solution
Readiness should fail, liveness should not. The process itself is healthy: restarting it would not bring the database back, and it would only create restart loops. But the application cannot serve useful requests, so the platform should stop sending traffic to it (readiness down) until the database returns. Failing liveness for an external dependency is a common mistake that turns one outage into many.
Exercise 3
Show solutionHide solution
(1) COPY . . copies everything, possibly including .env files, Git history and local files, into the image. (2) ENV JWT_SECRET=abc123 bakes a secret into the image, readable by anyone who can pull it, and the same in every environment. (3) Running as root gives an attacker who exploits the application full rights inside the container. Also: the secret is a weak, guessable value, and a single-stage build ships the build tools.
Exercise 4
Show solutionHide solution
Log the user id and the outcome (success or failure and a reason code). The email may be personal data, so log it only if you have a reason and a retention policy (or log a hash or id). Never log the password or the token.
Exercise 5
Show solutionHide solution
Use the expand-and-contract pattern across releases: (1) add the new column and keep writing both, with the application reading the old one; (2) deploy the version that reads the new column (still writing both), and migrate existing data; (3) deploy a version that uses only the new column; (4) drop the old column in a later migration. At no point does a running version depend on something that has been removed.
Where to go next
#- Next: Project 3: The Job Application Tracker API assembles and reviews the whole backend.
- For interviews: Interview Concepts, Part 2 covers configuration, logging, health checks and containers as questions.
Sources & further reading
- Technology
- Software Engineering
- #Actuator
- #configuration
- #deployment
- #Docker
- #Java
- #Java backend
- #Java interview
- #JPA
- #learn Java
- #logging
- #Spring Boot
- #SQL
Connected lessons
References
Linked from 4 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Docker for Beginners: What It Is, Why Developers Use It and How to Start
Guide · Technology
Heaps and Priority Queues
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

