Learn Java

Lesson 74Phase 9: Data and the web

Shipping it: Docker and GitHub Actions

Packaging an app and its runtime in a container, running a database next to it, and testing every push automatically.

By Updated 50 min

  • Docker
  • image
  • container
  • Dockerfile
  • multi-stage build
  • Docker Compose

The library API runs on your laptop. To run it on a server, the server needs the right version of Java, the right settings, a database, and the same steps you followed, in the same order. Miss one and you hear the oldest excuse in software: “it works on my machine”. This lesson packs the app and everything it needs into a container, runs a database next to it with one command, and sets up GitHub to build and test the code on every push, so a broken change is caught before anyone ships it.

By the end of this lesson
  • Explain what an image and a container are, and why they solve “it works on my machine”.
  • Write a two-stage Dockerfile that builds a Spring Boot app and runs it on a small Java runtime image.
  • Run the app and a PostgreSQL database together with Docker Compose.
  • Write a GitHub Actions workflow that builds and tests a Maven project on every push and pull request.
From before
Packaging made fat JARs and small runtimes, and promised that servers are usually shipped as container images. Spring Data JPA built the app this lesson ships. Git and GitHub pushed code and opened pull requests; GitHub Actions runs on exactly those events.

Images and containers#

A container image is a packed, read-only bundle: a small Linux file system with everything a program needs (here a Java runtime and your JAR) plus the command that starts it. A containeris that image running, in its own walled-off space on a machine. The same image runs the same way on your laptop, a colleague's laptop and a server, because everything the program relies on is inside it. Docker is the tool most people use to build and run them, and images are shared through a registry such as Docker Hub, the way libraries are shared through Maven Central.

DockerJava comparison
ImageA class: a blueprint, not running
ContainerAn object: one running copy made from the blueprint, and you can make many
DockerfileThe source code of an image
Registry (Docker Hub)Maven Central, but for images

Step 1: one JAR that runs on its own#

Spring Boot's Maven plugin, already in the generated pom.xml, turns mvn package into a fat JAR with Tomcat and every library inside, like the Shade plugin in packaging. Check that it runs before putting it in a container:

Terminal
read along
1$ mvn package -DskipTests
2...
3$ java -jar target/library-0.0.1-SNAPSHOT.jar
4...
52026-10-19T16:00:22.157+03:00 INFO 60923 --- [library] [ main] com.example.library.LibraryApplication : Started LibraryApplication in 1.753 seconds (process running for 1.992)

The JAR in target is 54,107,012 bytes, about 54 megabytes, and it starts with nothing but Java. (-DskipTests skips the tests to save time here; the CI workflow below runs them.) That one file is what the container will run.

Step 2: a Dockerfile#

To use Docker, install Docker Desktop (on Windows or macOS) or Docker Engine (on Linux) from docs.docker.com. Then put a file called Dockerfile, with no extension, in the project's top folder:

Dockerfile
read along
1# Stage 1: build the JAR with a full JDK
2FROM eclipse-temurin:25-jdk AS build
3WORKDIR /app
4COPY .mvn .mvn
5COPY mvnw pom.xml ./
6RUN ./mvnw dependency:go-offline
7COPY src src
8RUN ./mvnw package -DskipTests
9
10# Stage 2: run it on a smaller Java runtime
11FROM eclipse-temurin:25-jre
12WORKDIR /app
13COPY --from=build /app/target/library-0.0.1-SNAPSHOT.jar app.jar
14EXPOSE 8080
15ENTRYPOINT ["java", "-jar", "app.jar"]
  • FROM starts from an existing image. eclipse-temurin:25-jdk is Linux with the Temurin JDK 25 that this course has used throughout; :25-jre has only the runtime, which is smaller.
  • WORKDIR picks the folder inside the image, COPY copies files in from your project, and RUN runs a command while the image is built.
  • The first stage is only a workshop: it downloads the libraries, then compiles and packages. The second stage starts fresh and copies just the finished JAR across with COPY --from=build. The JDK, Maven's downloads and the source code stay behind, so the final image is much smaller and holds nothing it does not need. This is a multi-stage build.
  • Copying pom.xml and fetching the libraries before copying src is a speed trick. Docker reuses unchanged steps from the last build, so after a code change it skips the download and only recompiles.
  • EXPOSE 8080 documents the port the app listens on, and ENTRYPOINT is the command a container runs when it starts.

Building and running the image#

Terminal
read along
1$ docker build -t library .
2$ docker run -p 8080:8080 library

docker build -t library . follows the Dockerfile in the current folder (.) and names the result library. The first build takes a while, because it downloads the base images and the libraries. docker run starts a container from the image, and you see the same Spring Boot log as before, now from inside the container. -p 8080:8080 connects port 8080 on your machine to port 8080 in the container; without it the app runs but nothing outside can reach it. Then curl http://localhost:8080/books answers exactly as it did without Docker.

A few more commands cover daily use: docker ps lists running containers with their ids, docker logs followed by an id shows a container's output, docker stop with an id stops it, and docker run -d starts a container in the background instead of taking over the terminal.

Key idea

An image carries the program and everything it needs. If it runs in a container on your machine, it runs the same way on any machine that has Docker.

Step 3: a real database next to the app, with Docker Compose#

The H2 database lives inside the app and forgets everything on restart. A real deployment uses a database server such as PostgreSQL. Docker Compose starts several containers that work together, from one file, compose.yaml. First give the app PostgreSQL's JDBC driver in pom.xml:

pom.xml (added)
read along
1<dependency>
2 <groupId>org.postgresql</groupId>
3 <artifactId>postgresql</artifactId>
4 <scope>runtime</scope>
5</dependency>
compose.yaml
read along
1services:
2 db:
3 image: postgres:18
4 environment:
5 POSTGRES_DB: library
6 POSTGRES_USER: library
7 POSTGRES_PASSWORD: change-me
8 volumes:
9 - db-data:/var/lib/postgresql
10
11 app:
12 build: .
13 ports:
14 - "8080:8080"
15 environment:
16 SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/library
17 SPRING_DATASOURCE_USERNAME: library
18 SPRING_DATASOURCE_PASSWORD: change-me
19 depends_on:
20 - db
21
22volumes:
23 db-data:
  • Two services: db runs the official postgres:18 image, and app is built from your Dockerfile.
  • Inside Compose, each service is reachable by its name, so the app finds the database at db:5432, the standard PostgreSQL port.
  • Spring Boot reads settings from environment variables as well as from application.properties: SPRING_DATASOURCE_URL becomes spring.datasource.url. The same image can then point at a test database or a production one without being rebuilt.
  • The db-data volume keeps the database's files outside the container, so the books survive docker compose down and up again.

docker compose up --buildbuilds the app's image and starts both containers, with both logs in one terminal; docker compose down stops and removes them. One command now starts the whole system on any machine with Docker.

Passwords in compose.yaml are for your laptop only
change-me is fine on your own machine. For anything shared, never commit a real password to Git. Compose can read variables from a .env file that is listed in .gitignore, and servers and GitHub keep secrets in their own secret stores.

Step 4: build and test every push with GitHub Actions#

Continuous integration (CI) means every change is built and tested automatically, as soon as it is pushed, on a clean machine that knows nothing about your laptop. GitHub Actions runs it for repositories on GitHub. A workflow is a YAML file in the folder .github/workflows:

.github/workflows/ci.yml
read along
1name: CI
2
3on:
4 push:
5 branches: [main]
6 pull_request:
7
8jobs:
9 build:
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v7
13 - uses: actions/setup-java@v6
14 with:
15 distribution: temurin
16 java-version: '25'
17 cache: maven
18 - run: ./mvnw --batch-mode verify
  • on says when it runs: on every push to main and on every pull request.
  • runs-on: ubuntu-latest asks GitHub for a fresh Linux machine. Each run starts from nothing, which is the point: if the build only works because of something on your laptop, it fails here.
  • Each step either uses a published action or runs a command. actions/checkout fetches your code; actions/setup-java installs Temurin JDK 25 and keeps Maven's downloads between runs (cache: maven).
  • ./mvnw --batch-mode verify compiles, runs every test and packages the JAR. --batch-mode keeps the log free of progress bars. If any test fails, the command fails and so does the run.

Commit the file and push. The repository's Actions tab lists every run with its log, and each commit and pull request gets a green tick or a red cross. With branch protection turned on in the repository's settings, a pull request with a red cross cannot be merged, so broken code never reaches main. Adding a step that builds the Docker image, or deploys it, turns CI into continuous delivery.

Quick check
Why does the Dockerfile have two FROM lines?
Quick check
The workflow fails on GitHub with a test failure, but every test passes on your laptop. What is the most likely reason?

Try it

Answer each one in your head or on paper first, then open the answer.

1
A container started with docker run library (no -p) logs “Tomcat started on port 8080”, but curl http://localhost:8080/books on your laptop fails. Why?
2
In compose.yaml, the app connects to jdbc:postgresql://db:5432/library. What is db, and why not localhost?
3
Why does the workflow run on pull_request and not only on pushes to main?

Practice

Work through these on your own. Each one builds on this page.

1
Install Docker on your own computer, build the image for your library API, and run it. Use docker images to compare the size of an image built from eclipse-temurin:25-jdk alone with the two-stage one.
2
Run the app and PostgreSQL with Compose. Add a few books, run docker compose down and then up again, and check that they are still there. Then remove the volume with docker compose down -v and explain what changed.
3
Push your project to a GitHub repository with the workflow above. Break a test on purpose in a pull request and watch the red cross appear, then fix it. Finally, add a step to the workflow that runs docker build -t library . after the tests.

Further reading#

Spotted a mistake, or a part that did not make sense? Report it by email