Docker Docker Compose Kubernetes
These exercises accompany the Containerisation module of the Java, React, and MySQL full-stack bootcamp. You will package applications, run a multi-service stack, debug and shrink an image, and deploy to a local Kubernetes cluster.
There is one exercise per session:
- Introduction to Containerisation in
dockerfile-react/ - Multi-Container Applications in
compose-stack/ - Debugging and Optimisation in
debug-optimise/ - Introduction to Orchestration in
kubernetes-intro/
Work through them in order. Each exercise has its own directory with a starter project and a short brief below. Commit your work as you go. When you push to main, the autograder runs and reports a score on your feedback pull request.
Open the repository in the provided dev container (or GitHub Codespaces). It includes Docker, the Docker CLI, kubectl, and k3d, so you do not need to install anything locally.
To check your tools are available:
docker --version
docker compose version
kubectl version --client
k3d versionThe exercises publish services on ports 5173, 8080, and 8081. If something else is already using one of those, Docker refuses to start the container with port is already allocated, so stop whatever holds the port or publish on a different one.
Each push to main triggers the shared autograding workflow. It runs the test suites it finds in tests/ and awards two marks out of five: Functionality (from the automated tests) and Code Quality (from an automated review of your work).
The tests for exercises 2, 3, and 4 are initially disabled, so that exercises you have not reached yet are not counted against you. Each one is a file in tests/ ending in .bats.disabled:
tests/compose-stack.bats.disabled
tests/debug-optimise.bats.disabled
tests/kubernetes-intro.bats.disabled
The workflow only runs files ending in .bats, so you enable the checks for an exercise checks by dropping the .disabled suffix. The instructions below include the command to rename the relevant file; ensure you run that before pushing and committing your work. The tests for Exercise 1 are enabled already.
IMPORTANT: Do not change the contents of any file in tests/ — renaming the files is the only edit you should make there.
Directory: dockerfile-react/
The starter is a plain Vite and React app. Your task is to write a Dockerfile that builds and serves it.
- Open
dockerfile-react/and readpackage.jsonto see how the app runs. - Create a file named
Dockerfileindockerfile-react/. - Start from a Node base image with
FROM. - Use
WORKDIR,COPY, andRUN npm cito install dependencies. - Add a
CMDthat serves the app on port 5173 and binds to all interfaces (npm run preview -- --host 0.0.0.0). Build the app first so preview has something to serve. - Expose the port with
EXPOSE 5173. - Build the image and tag it:
docker build -t dockerfile-react ./dockerfile-react- Run the container in the background with port mapping, giving it a name so you can refer to it:
docker run -d --name react-app -p 5173:5173 dockerfile-react- Read the logs to confirm the preview server started, then open
localhost:5173in your browser. Add-fto follow the output if the container is still starting up:
docker logs react-app
# or
docker logs -f react-app- Stop and remove the container when you have finished:
docker stop react-app
docker rm react-appNote that docker logs reads the output of a container that already exists. If the build itself fails, there is no container yet and the error is in the build output instead. You will practise diagnosing a container that fails to start in exercise 3.
docker buildcompletes without errors.- The app responds on the mapped port when the container runs.
docker logsshows the preview server running and the address it is serving on.- Your
DockerfileusesFROM,COPY,RUN, andCMD.
Directory: compose-stack/
The starter has a Spring Boot API in backend/ and a static frontend in frontend/. Your task is to write a Dockerfile for the backend and a docker-compose.yml that runs the frontend, the backend, and a MySQL database together.
- Write
compose-stack/backend/Dockerfilefor the Spring Boot app, using a multi-stage build: compile the jar with Maven in the first stage, then copy just the jar into a JRE image to run it withjava -jar. Note thatpom.xmlsets<finalName>app</finalName>, so the jar Maven produces isapp.jar. - Write
compose-stack/docker-compose.ymlwith three services namedfrontend,backend, anddb. - For
frontend, serve the static files infrontend/with thenginx:alpineimage. Mount the directory into the image's web root (/usr/share/nginx/html) and publish it on port 8081. If 8081 is taken on your machine, make the mapping overridable with"${FRONTEND_PORT:-8081}:80"so you can runFRONTEND_PORT=8082 docker compose ... upinstead of editing the file. - Use the official
mysql:8.4image fordb. Set the database name, user, and password with environment variables. - Mount
db/init.sqlinto/docker-entrypoint-initdb.d/on thedbservice. MySQL runs the scripts it finds there the first time it starts, which creates themessagestable and seeds a row. - Configure the
backendservice to read its database connection from environment variables, and point it at thedbservice by name. The variables the app expects areSPRING_DATASOURCE_URL,SPRING_DATASOURCE_USERNAME, andSPRING_DATASOURCE_PASSWORD, and the host in the JDBC URL is the service namedb. - Add a named volume for the database so MySQL data survives a restart.
- Make the backend wait for the database. MySQL takes a few seconds to accept connections on first start, and the backend exits if it cannot connect, so add a
healthchecktodband adepends_oncondition ofservice_healthyonbackend. - Bring the stack up and confirm the API responds. The first run has to download the Maven dependencies and build the jar, so allow a few minutes:
docker compose -f compose-stack/docker-compose.yml up --build
curl http://localhost:8080/api/messagesYou should get back the row seeded by db/init.sql. Open localhost:8081 to see the frontend list it.
- Add a message of your own, so you can tell data you created from the seeded row:
curl -X POST -H 'Content-Type: application/json' \
-d '{"body":"added before restart"}' http://localhost:8080/api/messages- Tear the stack down and bring it back, then confirm both messages are still there:
docker compose -f compose-stack/docker-compose.yml down
docker compose -f compose-stack/docker-compose.yml up
curl http://localhost:8080/api/messages- Enable the tests for this exercise, then commit and push your work:
git mv tests/compose-stack.bats.disabled tests/compose-stack.batsdocker compose configreports a valid file.- All three services are defined, with a named volume on the database.
- The backend reads its database settings from environment variables.
curl http://localhost:8080/api/messagesreturns the seeded row, and the frontend page lists it.- Data survives a
downandupcycle. Note thatdown -vremoves the volume as well, which resets the database.
Directory: debug-optimise/
The starter includes a Spring Boot app and a Dockerfile that is both broken and much larger than it needs to be. Your task is to fix the fault and shrink the image.
- Build the provided image and record its size. The build itself succeeds, so this step should complete without errors:
docker build -t debug-optimise ./debug-optimise
docker images --filter reference=debug-optimise- Run it, and notice that the container starts and then immediately stops:
docker run -d --name debug-optimise -p 8080:8080 debug-optimise
docker ps -a --filter name=debug-optimise- Read the logs to find the cause, then fix it:
docker logs debug-optimiseThe container exits within a second, so there is no running process for docker exec to attach to. If you want to look around the filesystem to confirm what the build actually produced, start a throwaway shell in the image instead:
docker run --rm -it --entrypoint sh debug-optimise- Reorder the
Dockerfileinstructions so dependency layers are cached and only your code invalidates the cache. - Switch to a smaller base image (a slim or alpine variant) and use a multi-stage build so the final image contains only what it needs to run.
- Add a
.dockerignorefile indebug-optimise/to keep the build context small. - Rebuild and run again, then confirm the container stays up and answers on the mapped port. The old container still holds the name, so remove it first:
docker rm debug-optimise
docker build -t debug-optimise ./debug-optimise
docker run -d --name debug-optimise -p 8080:8080 debug-optimise
curl http://localhost:8080/- Compare before and after image sizes with
docker imagesto confirm the optimisation worked. - Enable the tests for this exercise, then commit and push your work:
git mv tests/debug-optimise.bats.disabled tests/debug-optimise.bats- The image builds and the container starts cleanly.
- The final image is meaningfully smaller than the original, and under 300MB.
- A
.dockerignorefile is present and excludes build output.
Directory: kubernetes-intro/
Your task is to write Kubernetes manifests that deploy a container image and expose it inside the cluster.
- Start a local cluster:
k3d cluster create dev- Write
kubernetes-intro/deployment.yamlfor a Deployment namedapiwith 2 replicas. Give the Pods the labelapp: apiand use the imagetraefik/whoami:lateston container port 8080. That image echoes back details of each request it receives, which makes it easy to see which Pod answered. It listens on port 80 unless you tell it otherwise, so set the environment variableWHOAMI_PORT_NUMBERto8080:
env:
- name: WHOAMI_PORT_NUMBER
value: 8080- Write
kubernetes-intro/service.yamlfor a Service namedapithat selectsapp: apiand exposes port 80 targeting container port 8080. - Apply both manifests and confirm both Pods are running:
kubectl apply -f kubernetes-intro/
kubectl rollout status deployment/api
kubectl get pods- Check that the Service reaches the Pods by name from inside the cluster.
whoamireports the hostname of the Pod that answered, so sending a batch of requests shows the Service spreading them across both replicas:
kubectl run curl-test --rm -i --restart=Never --image=curlimages/curl --command -- \
sh -c 'for i in $(seq 10); do curl -s http://api/ | grep Hostname; done'Two notes on the output. The Service picks a Pod at random for each connection rather than strictly alternating, so several identical hostnames in a row are normal and an uneven split such as six and four does not mean anything is misconfigured; across ten requests you should simply see both Pod names. You will also see a warning about credentials being recorded and the line "If you don't see a command prompt, try pressing enter" — both come from the -i flag, and neither needs any action from you.
- Delete one Pod and watch Kubernetes replace it:
kubectl delete pod <pod-name>
kubectl get pods --watch- Inspect a Pod with
kubectl describe pod <pod-name>andkubectl logs <pod-name>. - Delete the cluster when you have finished:
k3d cluster delete dev- Enable the tests for this exercise, then commit and push your work:
git mv tests/kubernetes-intro.bats.disabled tests/kubernetes-intro.bats- Both manifests are valid Kubernetes YAML.
- The Deployment requests 2 replicas, and both Pods reach
Running. - The Service selector matches the Pod labels in the Deployment.
- Calling the Service by name from inside the cluster returns a response from either replica.