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 BasithUpdated 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.
- 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.
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.
| Docker | Java comparison |
|---|---|
| Image | A class: a blueprint, not running |
| Container | An object: one running copy made from the blueprint, and you can make many |
| Dockerfile | The 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:
$ mvn package -DskipTests...$ java -jar target/library-0.0.1-SNAPSHOT.jar...2026-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:
# Stage 1: build the JAR with a full JDKFROM eclipse-temurin:25-jdk AS buildWORKDIR /appCOPY .mvn .mvnCOPY mvnw pom.xml ./RUN ./mvnw dependency:go-offlineCOPY src srcRUN ./mvnw package -DskipTests # Stage 2: run it on a smaller Java runtimeFROM eclipse-temurin:25-jreWORKDIR /appCOPY --from=build /app/target/library-0.0.1-SNAPSHOT.jar app.jarEXPOSE 8080ENTRYPOINT ["java", "-jar", "app.jar"]FROMstarts from an existing image.eclipse-temurin:25-jdkis Linux with the Temurin JDK 25 that this course has used throughout;:25-jrehas only the runtime, which is smaller.WORKDIRpicks the folder inside the image,COPYcopies files in from your project, andRUNruns 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.xmland fetching the libraries before copyingsrcis a speed trick. Docker reuses unchanged steps from the last build, so after a code change it skips the download and only recompiles. EXPOSE 8080documents the port the app listens on, andENTRYPOINTis the command a container runs when it starts.
Building and running the image#
$ docker build -t library .$ docker run -p 8080:8080 librarydocker 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 ideaAn 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:
<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope></dependency>services: db: image: postgres:18 environment: POSTGRES_DB: library POSTGRES_USER: library POSTGRES_PASSWORD: change-me volumes: - db-data:/var/lib/postgresql app: build: . ports: - "8080:8080" environment: SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/library SPRING_DATASOURCE_USERNAME: library SPRING_DATASOURCE_PASSWORD: change-me depends_on: - db volumes: db-data:- Two services:
dbruns the officialpostgres:18image, andappis 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_URLbecomesspring.datasource.url. The same image can then point at a test database or a production one without being rebuilt. - The
db-datavolume keeps the database's files outside the container, so the books survivedocker compose downand 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.
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:
name: CI on: push: branches: [main] pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v6 with: distribution: temurin java-version: '25' cache: maven - run: ./mvnw --batch-mode verifyonsays when it runs: on every push tomainand on every pull request.runs-on: ubuntu-latestasks 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
usesa published action orruns a command.actions/checkoutfetches your code;actions/setup-javainstalls Temurin JDK 25 and keeps Maven's downloads between runs (cache: maven). ./mvnw --batch-mode verifycompiles, runs every test and packages the JAR.--batch-modekeeps 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.
FROM lines?Try it
Answer each one in your head or on paper first, then open the answer.
docker run library (no -p) logs “Tomcat started on port 8080”, but curl http://localhost:8080/books on your laptop fails. Why?compose.yaml, the app connects to jdbc:postgresql://db:5432/library. What is db, and why not localhost?pull_request and not only on pushes to main?Practice
Work through these on your own. Each one builds on this page.
docker images to compare the size of an image built from eclipse-temurin:25-jdk alone with the two-stage one.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.docker build -t library . after the tests.Further reading#
Spotted a mistake, or a part that did not make sense? Report it by email