Containerize a Java App with Docker
Build and containerize a Java HTTP server using Docker and Docker Compose.
Introduction
30 Second Summary
Every developer eventually faces the "it works on my machine" problem. Containers solve this by packaging your app with everything it needs to run identically on any machine.
In this project, you will containerize a Java HTTP server with Docker, progressing from a basic Dockerfile to a multi-stage build and finally orchestrating multiple containers with Docker Compose.
What You'll Build
A Java HTTP API server running in Docker containers behind an nginx reverse proxy, built using production best practices like multi-stage builds and Docker Compose orchestration.
By the end of this project, you'll have:
- A working multi-stage Dockerfile that compiles your Java app with the full JDK and runs it with only a lightweight JRE.
- A running multi-container application using Docker Compose with networking between your Java app and an nginx reverse proxy.
- A solid understanding of core Docker concepts that come up in developer interviews: images vs containers, layers, Dockerfile instructions, and Compose.
- Secret Mission: Add a health check and environment variable configuration to your Docker Compose setup.
Are there any prerequisites?
You should have basic Java knowledge (enough to read a simple class with a main method). You also need Docker Desktop installed and running on your machine.
Create the Java App
Your Docker environment is confirmed and ready to go. Now you need something to actually put inside a container.
In this step, you will build a simple Java HTTP server that responds to web requests. The app uses Java's built-in HTTP server library, so there are no frameworks or build tools required. Just a single file that compiles and runs instantly.
In this step, get ready to:
- Create the project folder and write a Java HTTP server.
- Compile and run the app locally to verify it works.
Create the project folder and Java server
Before writing any code, you need a dedicated folder for the project. This folder will eventually hold your Java source code, Dockerfile, and Docker Compose configuration.
- Move to your Desktop by running this command in the terminal you used earlier:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir docker-java-app
cd docker-java-app
- Confirm the folder exists by running:
ls
The folder is empty for now. Next, open it in VS Code.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the docker-java-app folder, then click Open.
You should see docker-java-app in the VS Code Explorer sidebar on the left. The folder is empty.
- In the Explorer sidebar, hover over the DOCKER-JAVA-APP heading and click the New File... button (the file icon with a plus sign).
- Type App.java and press Enter.
The file opens in the editor. Now paste in the HTTP server code.
- Copy the following code into App.java:
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
public class App {
public static void main(String[] args) throws IOException {
// Create an HTTP server listening on port 8080
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
// Map the root URL path to our handler
server.createContext("/", new RootHandler());
// Use the default executor (single-threaded)
server.setExecutor(null);
server.start();
System.out.println("Server started on port 8080");
}
// Handles incoming HTTP requests to the root path
static class RootHandler implements HttpHandler {
public void handle(HttpExchange exchange) throws IOException {
String response = "Hello from Docker! This Java app is running in a container.";
// Send a 200 OK response with the message length
exchange.sendResponseHeaders(200, response.getBytes().length);
// Write the response body and close the stream
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
}
}
}
What does this code do?
This creates a minimal web server using Java's built-in HttpServer class from the jdk.httpserver module. No external libraries needed.
- HttpServer.create(new InetSocketAddress(8080), 0) starts a server that listens for requests on port 8080.
- server.createContext("/", new RootHandler()) routes all requests to the root path to your custom handler.
- RootHandler responds with a plain-text message and a 200 status code.
- Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
Don't see App.java in the sidebar?
- Make sure you opened the docker-java-app folder itself, not the Desktop folder that contains it.
- Check that the filename is exactly App.java with a capital A. Java requires the filename to match the class name.
Need more help? Why isn't my App.java file showing up in VS Code?
Compile and test your app locally
Before containerizing anything, you should confirm the app works on your machine. This way you know any issues later are Docker-related, not Java-related.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Compile the Java file by running:
javac App.java
- Confirm the compiled file exists by running:
ls
You should see both App.java and App.class in the output. The .class file is the compiled bytecode that Java can execute.
- Start the server by running:
java App
You should see Server started on port 8080 printed in the terminal.
- Open your browser and visit http://localhost:8080.
You should see the message: Hello from Docker! This Java app is running in a container.
- Return to your VS Code terminal and press Ctrl+C to stop the server.
Seeing an error when compiling or running?
- If you see javac: file not found: App.java, make sure your terminal is inside the docker-java-app folder. Run pwd to check.
- If the class name error mentions a mismatch, confirm your file is named App.java with a capital A and the class inside is public class App.
- If port 8080 is already in use, stop whatever else is using it (check for other running servers) and try again.
Still stuck? Help me debug my Java compilation or runtime error.
Why test locally before using Docker?
Testing locally first isolates problems. If the app works outside a container, you know any future failures come from Docker configuration. If it fails here, the issue is in your Java code.
✔️ Awesome, I've got everything!
Great. Make sure you have saved App.java and that the server responded correctly at http://localhost:8080.
ⓧ I'd like to double check the full code
Here is the complete App.java file for comparison:
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
public class App {
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/", new RootHandler());
server.setExecutor(null);
server.start();
System.out.println("Server started on port 8080");
}
static class RootHandler implements HttpHandler {
public void handle(HttpExchange exchange) throws IOException {
String response = "Hello from Docker! This Java app is running in a container.";
exchange.sendResponseHeaders(200, response.getBytes().length);
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
}
}
}
Your Java app is working locally. Next up, you will package it into a Docker container using a Dockerfile.
Write Your First Dockerfile
Your Java app compiles and runs locally. You've confirmed it responds at http://localhost:8080. The next challenge is packaging it so it runs the same way on any machine.
A Dockerfile is a text file with instructions that tell Docker how to build an image of your application. In this step, you'll write your first Dockerfile, build an image from it, and run your app inside a container.
In this step, get ready to:
- Write a Dockerfile that packages your Java app.
- Build a Docker image from the Dockerfile.
- Run, inspect, and clean up a container.
Create a Dockerfile
A Dockerfile tells Docker exactly how to assemble your application into a portable Docker image. Each line in the file is an instruction that creates a new layer in the image.
- In VS Code, create a new file called Dockerfile in the root of your docker-java-app folder (no file extension).
- Paste the following content into Dockerfile:
FROM eclipse-temurin:25
WORKDIR /app
COPY App.java .
RUN javac App.java
EXPOSE 8080
CMD ["java", "App"]
What does each instruction do?
- FROM eclipse-temurin:25 sets the base image to Eclipse Temurin JDK 25. This gives your container a full Java development kit to compile and run code.
- WORKDIR /app creates a /app directory inside the container and makes it the working directory for all following instructions.
- COPY App.java . copies your source file from your machine into the container's /app directory.
- RUN javac App.java compiles the Java source code during the build. This happens once when the image is created, not every time a container starts.
- EXPOSE 8080 documents that the container listens on port 8080. It does not actually publish the port.
- CMD ["java", "App"] sets the default command that runs when a container starts from this image.
- Save Dockerfile.
File not recognized as a Dockerfile?
Make sure the file is named exactly Dockerfile with a capital D and no extension. Names like dockerfile.txt or Dockerfile.txt will not work.
Help me fix my Dockerfile naming issue.
Build the Docker image
Now you'll turn your Dockerfile into a Docker image. An image is a read-only template that contains everything needed to run your application.
- Build the image by running this command in your terminal:
docker build -t java-app .
What does this command do?
- docker build reads your Dockerfile and executes each instruction to create an image.
- -t java-app tags (names) the resulting image as java-app so you can reference it later.
- . sets the build context to the current directory. Docker looks here for the Dockerfile and any files it needs to copy.
Watch the terminal output. You'll see numbered steps like [1/4] FROM docker.io/library/eclipse-temurin:25 as Docker executes each instruction and creates a layer. Each Dockerfile instruction becomes its own layer in the final image.
- Wait for the build to finish. You should see a line containing naming to docker.io/library/java-app at the end, confirming the image was created and tagged.
Build failed with 'no such file or directory'?
Make sure your terminal is inside the docker-java-app folder. The . at the end of the build command tells Docker to look in the current directory for both the Dockerfile and App.java.
Help me troubleshoot my Docker build failure.
Run and inspect the container
An image is a blueprint. A container is a running instance of that image. You'll now start a container from your image and confirm the app works inside it.
- Start a container from your image by running this command:
docker run -d -p 8080:8080 --name my-java-app java-app
What do these flags mean?
- -d runs the container in detached mode (in the background), so you get your terminal prompt back.
- -p 8080:8080 maps port 8080 on your machine to port 8080 inside the container. Without this, the app is unreachable from your browser.
- --name my-java-app gives the container a human-readable name instead of a random string.
- java-app at the end tells Docker which image to use.
- Open your browser and visit http://localhost:8080.
- You should see the message: Hello from Docker! This Java app is running in a container..
Your app is running inside a container. Now inspect it with a couple of useful Docker commands.
- List running containers by running:
docker ps
- You should see my-java-app listed with status Up and port 0.0.0.0:8080->8080/tcp in the output.
- View the container's logs by running:
docker logs my-java-app
- You should see Server started on port 8080 in the output. This is the System.out.println from your App.java running inside the container.
Now clean up the container so you start fresh for the next step.
- Stop the container by running:
docker stop my-java-app
- Remove the stopped container by running:
docker rm my-java-app
- Confirm it's gone by running docker ps -a. You should no longer see my-java-app in the list.
Port already in use?
If you see an error about port 8080 being in use, your Java app from Step 1 might still be running. Make sure you stopped it with Ctrl+C in the terminal where it was running. Then try the docker run command again.
Help me resolve my port conflict with Docker.
✔️ Awesome, I've got everything!
Great. Make sure you've stopped and removed the container before moving on.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile you should have at this point:
FROM eclipse-temurin:25
WORKDIR /app
COPY App.java .
RUN javac App.java
EXPOSE 8080
CMD ["java", "App"]
You've just containerized your Java app. You built an image, ran it as a container, and confirmed it works exactly the same as it did locally. Next up, you'll optimize this Dockerfile with a multi-stage build to produce a smaller, production-ready image.
Refactor to a Multi-Stage Build
In Step 2, you containerized your Java app with a single-stage Dockerfile. It works, but the final image ships with the entire JDK, compiler tools, and source code that your app doesn't need at runtime.
Multi-stage builds solve this by separating the build environment from the runtime environment. In this step, you'll refactor your Dockerfile so only the compiled .class file and a lightweight JRE end up in the final image.
In this step, get ready to:
- Create a .dockerignore file to optimize the build context.
- Refactor the Dockerfile into a two-stage multi-stage build.
- Compare image sizes and confirm the optimized image still works.
Create a .dockerignore and refactor the Dockerfile
Before you touch the Dockerfile, you'll add a .dockerignore file. This tells Docker which files to skip when it copies your project into the build context.
- In VS Code, create a new file called .dockerignore in your docker-java-app folder.
- Add the following content to exclude files Docker doesn't need:
.git
*.class
README.md
.env
What does this file do?
- .git excludes the entire Git history folder, which can be large and is never needed inside a container.
- *.class excludes any locally compiled Java files. The Dockerfile compiles fresh inside the container.
- README.md and .env are development files that don't belong in a production image.
- Save .dockerignore.
- Confirm the file exists by running:
ls -a
You should see .dockerignore listed alongside App.java and Dockerfile.
Don't see .dockerignore in the list?
Files starting with a dot are hidden by default. The -a flag in ls -a reveals them. If it's still missing, check you created the file in the docker-java-app folder and not a subfolder.
Help me troubleshoot my .dockerignore file
Now for the main event. You'll rewrite the Dockerfile to use two stages: one for compiling and one for running.
- Open Dockerfile in VS Code and replace its entire contents with this multi-stage version:
# Stage 1: Build
FROM eclipse-temurin:25 AS build
WORKDIR /app
COPY App.java .
RUN javac App.java
# Stage 2: Run
FROM eclipse-temurin:25-jre AS final
WORKDIR /app
COPY --from=build /app/App.class .
EXPOSE 8080
CMD ["java", "App"]
What's happening in each stage?
Stage 1 (build) uses the full JDK image to compile your source code into a .class file. This stage is temporary and never ships.
Stage 2 (final) starts fresh from a JRE-only image. It copies just the compiled App.class from Stage 1 using COPY --from=build. The result is a smaller image that contains only what your app needs to run.
Why a JRE instead of a JDK?
The JDK includes the compiler (javac), development tools, and source code that are only needed at build time. The JRE is a stripped-down runtime that can execute compiled Java but can't compile new code.
Using the JRE for your final image removes hundreds of megabytes of unnecessary tooling. This is the exact pattern used in production Java deployments.
- Save Dockerfile.
Seeing a syntax error in VS Code?
Make sure the file is named exactly Dockerfile with no extension. Check that COPY --from=build has two dashes before from. A single dash will cause a build error.
Help me fix my multi-stage Dockerfile
✔️ Awesome, I've got everything!
Great. Double check you've saved both .dockerignore and Dockerfile.
ⓧ I'd like to double check the full code
Here is .dockerignore:
.git
*.class
README.md
.env
Here is the full Dockerfile:
# Stage 1: Build
FROM eclipse-temurin:25 AS build
WORKDIR /app
COPY App.java .
RUN javac App.java
# Stage 2: Run
FROM eclipse-temurin:25-jre AS final
WORKDIR /app
COPY --from=build /app/App.class .
EXPOSE 8080
CMD ["java", "App"]
Build the multi-stage image and compare sizes
Time to see multi-stage builds in action. You'll build a new image with a different tag so you can compare it side-by-side with the original.
- Build the multi-stage image by running this command:
docker build -t java-app:multi .
What does the tag mean?
The -t java-app:multi flag gives your image the name java-app with the tag multi. Tags let you keep multiple versions of the same image. Your original single-stage image is tagged latest (the default when no tag is specified).
- Compare both images by running:
docker images
You should see both java-app:latest and java-app:multi listed. The multi image is significantly smaller because it only contains the JRE, not the full JDK and source code.
Both images showing the same size?
Make sure you saved the Dockerfile with the two-stage version before building. If both stages still use eclipse-temurin:25 (full JDK), the sizes will be similar. The second stage must use eclipse-temurin:25-jre.
Help me figure out why my multi-stage image isn't smaller
Run and test the optimized image
- Run a container from the multi-stage image by running this command:
docker run -d -p 8080:8080 --name my-java-app java-app:multi
What do these flags do?
- -d runs the container in detached mode (in the background).
- -p 8080:8080 maps port 8080 on your machine to port 8080 inside the container.
- --name my-java-app gives the container a friendly name you can use in other commands.
- java-app:multi tells Docker which image to use.
- Open your browser and visit http://localhost:8080.
You should see the same response as before: "Hello from Docker! This Java app is running in a container." The app runs identically, but the image behind it is much smaller.
- Stop and remove the container by running these commands:
docker stop my-java-app
docker rm my-java-app
This frees up port 8080 and removes the container so it doesn't clutter your docker ps -a list.
Port 8080 already in use?
If you see an error about the port being allocated, a previous container may still be running. Run docker ps to check for running containers, then stop and remove any that use port 8080.
Help me fix a port conflict with Docker
Your optimized image is ready. Next up, you'll orchestrate your app alongside an nginx reverse proxy using Docker Compose.
Orchestrate with Docker Compose and Nginx
Your multi-stage Dockerfile produces a lean, optimized image. But real applications rarely run alone. They sit behind load balancers, connect to databases, and communicate with other services.
In this step, you will orchestrate multiple containers using Docker Compose. You will add an Nginx reverse proxy in front of your Java app so that traffic flows through port 80, just like a production setup.
In this step, get ready to:
- Create an nginx configuration that forwards traffic to your Java app.
- Define a multi-container setup with Docker Compose.
- Start, test, and shut down the full application stack.
Create the nginx reverse proxy configuration
A reverse proxy sits between the internet and your application. It receives all incoming requests on port 80 and forwards them to your Java app running on port 8080 inside the container network.
- In VS Code, create a new file called nginx.conf in your docker-java-app folder.
- Paste the following configuration into nginx.conf:
server {
listen 80;
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
What does this configuration do?
This tells nginx to listen on port 80 and forward every request to http://app:8080. The hostname app is the service name you will define in Docker Compose. Compose creates a shared network where containers reach each other by service name.
The two proxy_set_header lines pass the original client information through to your Java app so it knows the real requester's hostname and IP address.
- Save nginx.conf.
You won't see a change yet. The next file (compose.yaml) will use this config to spin up the nginx container.
✔️ Awesome, I've got everything!
Great. Double check you have saved nginx.conf before moving on.
ⓧ I'd like to double check the full code
server {
listen 80;
location / {
proxy_pass http://app:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Create the Docker Compose file
Docker Compose lets you define and run multiple containers from a single file. Instead of long docker run commands, you declare your services, ports, and volumes in YAML.
- Create a new file called compose.yaml in your docker-java-app folder.
- Paste the following into compose.yaml:
services:
app:
build: .
ports:
- "8080:8080"
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
What does this file do?
This file defines two services that Compose will run as separate containers on a shared network:
- app builds your Java image from the Dockerfile and exposes port 8080.
- nginx uses the official nginx:alpine image, maps port 80 on your machine to port 80 in the container, and mounts your nginx.conf as a read-only volume.
- depends_on tells Compose to start the app service before nginx.
Why nginx instead of a database?
An nginx reverse proxy demonstrates container networking (service-to-service communication by name) without requiring data persistence, schema setup, or health-check delays. It keeps the focus squarely on Docker Compose orchestration.
- Save compose.yaml.
- Confirm both new files exist by running:
ls
You should see nginx.conf and compose.yaml alongside your existing App.java, Dockerfile, and .dockerignore.
Don't see the new files?
Make sure you created both files inside the docker-java-app folder, not in a subfolder. Check that compose.yaml uses a .yaml extension, not .yml.
help me find my compose.yaml and nginx.conf files
✔️ Awesome, I've got everything!
Great. Double check you have saved compose.yaml before moving on.
ⓧ I'd like to double check the full code
services:
app:
build: .
ports:
- "8080:8080"
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
Start and test the multi-container application
With both configuration files in place, a single command builds your Java image and starts both containers on a shared network.
- Start the full application stack by running:
docker compose up --build -d
What does this command do?
- --build rebuilds the image from your Dockerfile before starting.
- -d runs containers in detached mode (in the background).
- Compose automatically creates a default network connecting both services.
- Verify both containers are running by running:
docker compose ps
You should see two services listed with a status of Up: one for app and one for nginx.
- Open your browser and visit http://localhost (port 80, no port number needed).
You should see the same response from your Java app: Hello from Docker! This Java app is running in a container. This time the request traveled through nginx on port 80, which forwarded it to your app on port 8080.
What is container networking?
Docker Compose automatically connects all services to a default network. Services reach each other by service name as a hostname. In your nginx config, http://app:8080 resolves to the Java app container because app is the service name defined in compose.yaml.
Not seeing the response at localhost?
- Make sure port 80 is not already in use by another application. Run docker compose ps to check if both containers show a status of Up.
- If the nginx container exited, run docker compose logs nginx to check for configuration errors in your nginx.conf.
- On Windows, make sure Docker Desktop is running and you are not blocked by a firewall.
help me debug why my Docker Compose services are not responding on localhost
View logs and shut everything down
Docker Compose gives you centralized logging across all services. You can see the output from both containers in one stream.
- View the combined logs from both services by running:
docker compose logs
You should see output from both the app container (your Java server's startup message) and the nginx container (access logs from your browser request).
- Shut down the entire application stack by running:
docker compose down
What does docker compose down remove?
This command stops and removes all containers defined in your Compose file. It also removes the default network that Compose created for inter-container communication. Your images stay on disk for fast restarts later.
You should see output confirming each container was stopped and removed, plus the network removal.
Seeing a 10-second delay during shutdown?
Docker sends a SIGTERM signal and waits 10 seconds before force-killing. Your Java app uses the exec form of CMD (["java", "App"]), which handles signals properly. If you see a delay, it is normal for the graceful shutdown period.
help me understand why docker compose down takes so long
You now have a working multi-container application. Your Java app and nginx reverse proxy run together, communicate over Docker's internal network, and shut down cleanly with a single command. Next up, you will take this further with health checks and environment variables in the Secret Mission.
Secret mission
Add Health Checks and Environment Variables
In production, containers need to prove they're alive before receiving traffic. Your mission: add a health check endpoint and environment variable configuration to your Docker Compose setup, so nginx only starts forwarding requests once your Java app is verified healthy.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally using Docker, so there are no ongoing cloud costs.
Resources you used:
- Docker images (java-app and java-app:multi) stored locally on your machine.
- Docker containers managed by Docker Compose (only running when you start them).
- Project folder docker-java-app on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or plan to keep experimenting with Docker.
- Your images remain on disk and take up no CPU or memory when containers are stopped.
- Run docker compose up -d from your docker-java-app folder anytime to restart your containers.
- Docker Desktop itself stays installed for future projects.
Pause - I'll come back to this later
Shut down running containers to free up memory. Your images and project files remain on disk for a fast restart later.
- Stop and remove all containers by running this command from your docker-java-app folder:
docker compose down
Your images stay cached locally so next time you run docker compose up -d, containers start in seconds without rebuilding.
Delete - I don't want to use this again
Remove all project resources and start fresh. This reclaims disk space used by Docker images and project files.
- Stop and remove all containers by running:
docker compose down
- Remove the Docker images you built:
docker image rm java-app java-app:multi
- Remove any dangling images left over from builds:
docker image prune
Docker will ask you to confirm. Type y and press Enter.
- Delete the project folder. On macOS/Linux:
cd ~/Desktop
rm -rf docker-java-app
- On Windows (PowerShell):
cd ~/Desktop
Remove-Item -Recurse -Force docker-java-app
Nice Work!
Nice Work!
Nice work! You just containerized a Java application with Docker, built a production-grade multi-stage Dockerfile, and orchestrated multiple services with Docker Compose.
You've learned how to:
- Written a multi-stage Dockerfile that compiles Java with a full JDK and runs with only a lightweight JRE, producing a smaller production-ready image.
- Orchestrated a multi-container application using Docker Compose with a Java app and an nginx reverse proxy communicating over a shared network.
- Used container networking where services reach each other by name, and mapped ports to expose your app to the host machine.
- Secret Mission: Added health checks, environment variable configuration, and dependency ordering to your Docker Compose setup so nginx only starts after the app is healthy.
Ready to quiz yourself?