Overview
The previous labs containerised applications that were chosen to be easy. Real projects come in many shapes and languages. This lab gives you a method that works for any of them, applies it to a front end that needs a very different image from an API, shows the same method in Python and Go, and then asks you to apply it to a project of your own and publish the result.
Learning Objectives
By the end of this lab you will be able to:
- Apply a systematic set of questions to decide how any application should be containerised
- Containerise a static front end with a Node.js build stage and an nginx runtime
- Connect containers on a user-defined network and reach one from another by name
- Recognise the patterns for interpreted and compiled languages
- Diagnose the most common containerisation errors
- Containerise, run and publish a project of your own
Concept
Before writing a Dockerfile for any application, answer eight questions. The answers translate almost directly into instructions.
| Question | Where the answer goes |
|---|---|
| 1. Which language runtime and version does it need? | The FROM of the final stage, pinned to a version |
| 2. Does it need a build step, such as compiling or bundling? | A build stage with the full toolchain |
| 3. What exactly is needed to run it? | The COPY --from lines and production dependencies of the final stage |
| 4. How is it configured? | Environment variables read at start-up, with defaults in ENV. Never secrets. |
| 5. Which port does it listen on, and on which address? | EXPOSE, and the application must listen on 0.0.0.0 |
| 6. Does it keep any state, such as uploads or a database file? | A volume, or better, an external service |
| 7. How does it start, and does it stop cleanly? | CMD or ENTRYPOINT in JSON form, running the real process |
| 8. How can you tell it is working? | HEALTHCHECK |
Question 5 catches many people. Inside a container, localhost or 127.0.0.1 means the container itself. An application that listens only on 127.0.0.1 cannot receive traffic forwarded from outside the container, even with the port published. Development servers often do this by default, so it must be changed to 0.0.0.0, meaning every network interface.
Most applications then fall into one of four patterns. A compiled server (Go, Java, C#, TypeScript, Rust) builds in one stage and ships the output on a small runtime image. An interpreted server (Python, Ruby, PHP, plain JavaScript) installs dependencies and copies source into a slim runtime image. A static front end (React, Vue, Angular, plain HTML) builds with Node.js and ships the files on a web server such as nginx, with no Node.js at all. A command-line tool uses ENTRYPOINT so that arguments pass straight to it.
Tasks
Task 1 - Apply the questions to a front end
The quotes application has a web front end built with Vite, a build tool that bundles HTML, CSS and JavaScript into a few static files. In development it is served by Vite. In production it should be served by a fast web server that also forwards any request starting /api/ to the quotes API. Create a folder called quotes-web inside your quotes folder, beside quotes-api, containing these files. Run the commands in Tasks 1 to 4 from inside quotes-web.
{
"name": "quotes-web",
"version": "1.0.0",
"private": true,
"description": "Static front end for the quotes API, built with Vite",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build"
},
"devDependencies": {
"vite": "^7.0.0"
}
}
import { defineConfig } from 'vite';
// During development Vite forwards /api to the API, just as nginx does in production.
export default defineConfig({
server: {
proxy: { '/api': process.env.API_URL ?? 'http://localhost:3000' },
},
});
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quotes</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<main>
<h1>Quotes</h1>
<p id="status">Connecting to the API...</p>
<form id="add">
<input name="text" placeholder="Quote" required />
<input name="author" placeholder="Author" required />
<button>Add</button>
</form>
<ul id="quotes"></ul>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>
// Talks to the API with relative URLs, so it works behind any proxy that serves /api.
const list = document.querySelector('#quotes');
const status = document.querySelector('#status');
const form = document.querySelector('#add');
async function load() {
const health = await fetch('/api/health').then((r) => r.json());
status.textContent = `API version ${health.version}, storage: ${health.store}`;
const quotes = await fetch('/api/quotes').then((r) => r.json());
list.replaceChildren(
...quotes.map((q) => {
const li = document.createElement('li');
li.textContent = q.text;
const by = document.createElement('span');
by.textContent = q.author;
li.append(by);
return li;
}),
);
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const body = Object.fromEntries(new FormData(form));
await fetch('/api/quotes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
form.reset();
load();
});
load().catch(() => {
status.textContent = 'The API is not reachable.';
});
body { font-family: system-ui, sans-serif; margin: 0; background: #f4f4f6; color: #222; }
main { max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
input { flex: 1; padding: 0.5rem; }
li { background: #fff; padding: 0.75rem; margin-bottom: 0.5rem; list-style: none; border-radius: 4px; }
li span { display: block; color: #666; font-size: 0.9rem; }
#status { color: #666; }
node_modules
dist
.git
Dockerfile
.dockerignore
docker run --rm -v "$(pwd):/app" -w /app node:22-alpine npm install --package-lock-only
Question 1. Which runtime does the final image need?
Question 2. Is there a build step?
Question 6. Does this front end keep any state on the server?
Task 2 - Configure the web server
nginx is configured with a short text file. This one serves the built files, sends any address that is not a file back to index.html so client-side routing works, and forwards /api/ requests to a host called api on port 3000. Save it as quotes-web/nginx.conf.
server {
listen 8080;
root /usr/share/nginx/html;
# Docker's built-in DNS server. Looking the API up per request means
# nginx still starts when the API is not running yet.
resolver 127.0.0.11 valid=5s;
set $api http://api:3000;
location /api/ {
proxy_pass $api;
}
location / {
try_files $uri /index.html;
}
}
resolver 127.0.0.11 is the address of the DNS server that Docker provides inside every user-defined network. Looking the API up through a variable on each request, instead of once at start-up, means nginx starts even if the API is not running yet.
Task 3 - Write the Dockerfile
Complete the Dockerfile for quotes-web.
- The build stage starts from
node:22-alpineand is namedbuild. - It copies the package files, runs
npm ci, copies everything else and runsnpm run build. - The final stage copies
nginx.confto/etc/nginx/conf.d/default.conf. - It copies
/app/distfrom the build stage to/usr/share/nginx/html, where nginx looks for files.
# Stage 1: Node.js is needed only to build the static files
# TODO: FROM ... AS build
WORKDIR /app
# TODO: copy package files and install dependencies
# TODO: copy the rest and run the build
# Stage 2: a web server and the built files. No Node.js at all.
FROM nginxinc/nginx-unprivileged:1.29-alpine
# TODO: copy nginx.conf into place
# TODO: copy the built files from the build stage
EXPOSE 8080
The final stage uses nginx-unprivileged, a variant of nginx that runs as a normal user. A normal user cannot listen on ports below 1024, so it listens on 8080 instead of 80.
docker build -t quotes-web:1.0 .
docker image ls quotes-web
Success condition: the image is roughly 82 MB. It contains nginx and a few kilobytes of your files. Node.js, Vite and every development package stayed in the build stage.
Task 4 - Connect the front end to the API
Containers on the default network cannot find each other by name. Create a user-defined network, which gives every container on it a DNS name equal to its container name. Start the API from the Building Efficient Images lab with the name api, then the front end, on that network.
docker network create quotes-net
docker run -d --name api --network quotes-net quotes-api:1.0
docker run -d --name web --network quotes-net -p 8080:8080 quotes-web:1.0
Open http://localhost:8080 and add a quote.
Success condition: the page shows the two starting quotes and your new one. Notice that the API container has no published port. Your browser talks only to nginx, and nginx reaches the API across the private network by the name api. Only the front door is open to the outside world.
docker exec web wget -qO- http://api:3000/api/health
curl http://localhost:3000/api/health
The first command succeeds from inside the network. The second fails from your machine, because nothing is published on port 3000. In the next lab Docker Compose will create this network and these containers for you.
docker rm -f web api
docker network rm quotes-net
Task 5 - The same method in other languages
The questions do not change between languages, only the answers. Here is an interpreted example, a Python web API using Flask. There is no build stage. Dependencies are installed before the code is copied, exactly as with Node.js, and it runs under gunicorn, a production server, bound to 0.0.0.0.
# Interpreted language: no build stage, but keep the image slim
FROM python:3.13-slim
# Do not write .pyc files, and print logs immediately
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
# Dependencies first, so they stay cached while code changes
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ./
# The slim image has no unprivileged user, so create one
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
# A production server, listening on every interface, not just localhost
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
Here is a compiled example in Go. Go can produce a single self-contained binary, so the final image is distroless, an image with no shell, no package manager and no operating system tools at all, just enough for one program to run. When this lab was written the Go image was 14 MB and the Python one 186 MB. Java, C# and Rust follow the Go pattern, shipping a runtime such as a JRE, or a binary, in the final stage.
# Compiled language: build a single static binary with the full toolchain
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY *.go ./
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app
# Ship only the binary. Distroless has no shell and no package manager.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
A distroless image has no shell. What is the practical consequence when debugging a running container?
Task 6 - Read the common errors
When you containerise your own project you will almost certainly meet some of these. Each has a recognisable symptom.
| Symptom | Likely cause | Fix |
|---|---|---|
| Container runs, port is published, browser says the connection was reset | The application listens on 127.0.0.1 | Listen on 0.0.0.0, often with a --host option |
A COPY step fails with failed to compute cache key and not found | The file is outside the build context or listed in .dockerignore | Build from the right folder, check .dockerignore |
| Container exits immediately with code 0 | The command finished, for example a script that starts a server in the background | Run the server in the foreground as the main process |
| Container exits with code 1 or 137 | The application crashed, or was killed for using too much memory | Read docker logs, check memory limits |
exec format error | The image was built for a different CPU, such as ARM on an Apple laptop and Intel on a server | Build with --platform linux/amd64, or build multi-platform images |
permission denied writing a file | The image runs as a normal user who does not own that folder | COPY --chown or write to a volume or a folder the user owns |
| Works in the container, but changes to code do not appear | The code was copied at build time | Rebuild, or use a bind mount during development |
Task 7 - Containerise your own project
Choose a project of your own, from an earlier module, a personal project or your dissertation. If you have nothing suitable, take a small open source web application in a language you know.
- Write down your answers to the eight questions in the concept section.
- Write a
.dockerignorethat excludes dependency folders, build output, version control and any files containing secrets such as.env. - Write a
Dockerfilefollowing the pattern that fits your project. Use a pinned, slim base image. Use a multi-stage build if there is a build step. Copy dependency lists before source code. Run as a non-root user. - Build it, run it, and prove it works with a browser,
curlor the program's output. - Check
docker image lsanddocker history. Can you make it smaller? - Tag it with a version and push it to Docker Hub, as in the Docker Hub and Registries lab.
docker build -t YOUR_DOCKERHUB_USERNAME/YOUR_PROJECT:0.1 .
docker run --rm -p 8080:YOUR_APP_PORT YOUR_DOCKERHUB_USERNAME/YOUR_PROJECT:0.1
docker push YOUR_DOCKERHUB_USERNAME/YOUR_PROJECT:0.1
Success condition: a classmate can run your project with a single docker run command and nothing else installed.
Task 8 - Evaluate
Describe the most difficult decision you made while containerising your own project, the options you considered, and why you chose as you did.
The quotes front end and API are separate images. Give one advantage and one disadvantage of that compared with one image containing both.
Solution
Try the tasks above before revealing the solution.