Welcome! In this workshop you will take a working web app and learn how to package and run it with Docker — covering images, containers, ports, volumes, environment variables, and networking.
If you do not have Docker installed yet, do this first.
- Go to https://www.docker.com/products/docker-desktop
- Download Docker Desktop for your operating system (Windows, macOS, or Linux)
- Run the installer and follow the prompts
- After installation, restart your computer if prompted
- Open Docker Desktop and wait for it to show "Engine running"
Verify Docker is working by opening a terminal and running:
docker --versionYou should see something like Docker version 26.x.x. If you get an error, ask your instructor.
Before we containerise anything, make sure the app works on your machine.
- Click Fork at the top-right of this GitHub page
- Clone your fork to your machine:
git clone https://github.com/YOUR-USERNAME/docker-workshop.git
cd docker-workshopnpm installnpm startOpen your browser and go to: http://localhost:3000
You should see the workshop app. Try adding a note — you will notice that notes are saved to the data/notes.txt file on your machine.
Stop the app with Ctrl + C when you are done.
A Dockerfile is a text file that describes how to build a Docker image. Think of an image as a snapshot of your app and everything it needs to run.
You will write this file together with your instructor. Create a file called Dockerfile (no extension) in the root of the project.
# Start from an official Node.js base image
FROM node:20-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy dependency files first (layer caching optimisation)
COPY package*.json ./
# Install dependencies
RUN npm install --omit=dev
# Copy the rest of the app source code
COPY . .
# Tell Docker which port the app listens on (documentation only)
EXPOSE 3000
# The command to run when the container starts
CMD ["node", "app.js"]| Instruction | What it does |
|---|---|
FROM |
Sets the base image. Every image starts from another image. |
WORKDIR |
Creates and switches into a directory inside the container. |
COPY |
Copies files from your machine into the image. |
RUN |
Executes a command at build time (e.g. installing packages). |
EXPOSE |
Documents that the container listens on a port. Does not actually publish it. |
CMD |
The command that runs when the container starts. |
docker build -t workshop-app .-t workshop-appgives the image a name (tag).tells Docker to look for the Dockerfile in the current directory
List all images on your machine:
docker imagesYou should see workshop-app in the list.
docker run -p 3000:3000 workshop-appOpen http://localhost:3000 — the app is now running inside a container.
-p HOST_PORT:CONTAINER_PORT
The container has its own isolated network. Port 3000 inside the container is not automatically available on your machine. The -p flag creates a mapping:
Your browser -> localhost:3000 -> Docker -> container:3000
Try a different host port:
docker run -p 8080:3000 workshop-appNow visit http://localhost:8080 — same app, different port on your machine.
Stop the container with Ctrl + C, or from another terminal:
docker ps # list running containers
docker stop <CONTAINER_ID> # stop by IDdocker run -d -p 3000:3000 --name my-app workshop-app-druns the container in the background (detached)--name my-appgives the container a friendly name
Useful commands:
docker ps # list running containers
docker logs my-app # view output logs
docker logs -f my-app # follow logs in real time
docker stop my-app # stop the container
docker rm my-app # remove the stopped container
docker stop my-app && docker rm my-app # stop and remove in one goThe app reads environment variables to change its behaviour without rebuilding the image. This is a core Docker best practice.
docker run -d -p 3000:3000 \
-e GREETING="Hey there from Docker!" \
-e AUTHOR="Your Name" \
--name my-app workshop-appReload http://localhost:3000 — you should see your custom greeting and name.
Variables the app supports:
| Variable | Default | Effect |
|---|---|---|
PORT |
3000 |
Port the app listens on |
GREETING |
Hello from the workshop app! |
The greeting shown on the page |
AUTHOR |
Anonymous |
Author name shown on the page |
NOTES_FILE |
data/notes.txt |
Path to the notes file |
Start the app, add a note, then remove the container:
docker run -d -p 3000:3000 --name my-app workshop-app
# add a note in the browser
docker stop my-app && docker rm my-app
docker run -d -p 3000:3000 --name my-app workshop-app
# notes are gone!A volume mount links a directory on your machine to a path inside the container. Writes inside the container go to your machine — and survive restarts.
docker run -d -p 3000:3000 \
-v "$(pwd)/data:/app/data" \
--name my-app workshop-app$(pwd)/data— thedata/folder on your machine/app/data— where the app writes notes inside the container
Now add a note, stop and remove the container, start it again with the same mount — your notes persist.
Check that the file was written on your host machine:
cat data/notes.txtEvery container gets its own isolated network stack by default. Docker provides several networking modes.
When you run containers without specifying a network, they join Docker's default bridge network. Containers on the same bridge can reach each other by IP, but not by name (unless you use a custom network).
docker network create workshop-net
docker network ls# App container
docker run -d -p 3000:3000 \
--network workshop-net \
--name app \
workshop-app
# A second container (e.g. a utility) can now reach "app" by its name
docker run --rm \
--network workshop-net \
alpine \
wget -qO- http://app:3000On a custom network, Docker provides automatic DNS resolution — containers can reach each other by their --name. This is how multi-container apps (e.g. web + database) communicate.
If your web project has a separate database or API service, each will run in its own container. They talk to each other using service names over a shared Docker network — exactly what Docker Compose manages automatically.
Writing long docker run commands gets tedious. Docker Compose lets you define all your containers, ports, volumes, and networks in a single docker-compose.yml file.
You will write this file together with your instructor:
services:
app:
build: .
ports:
- "3000:3000"
environment:
- GREETING=Hello from Compose!
- AUTHOR=Workshop Student
volumes:
- ./data:/app/data
networks:
- workshop-net
networks:
workshop-net:Start everything with one command:
docker compose up # foreground
docker compose up -d # background
docker compose down # stop and remove containers
docker compose logs -f # follow logs# Images
docker build -t <name> . # build image from Dockerfile
docker images # list images
docker rmi <image> # remove an image
# Containers
docker run -p 3000:3000 <image> # run a container
docker run -d ... # run detached (background)
docker run -e KEY=VALUE ... # pass environment variable
docker run -v host:container ... # mount a volume
docker run --network <net> ... # attach to a network
docker ps # list running containers
docker ps -a # list all containers (including stopped)
docker stop <id/name> # stop a container
docker rm <id/name> # remove a container
docker logs <id/name> # view logs
docker exec -it <id/name> sh # open a shell inside a running container
# Networks
docker network create <name> # create a network
docker network ls # list networks
# Compose
docker compose up -d # start all services
docker compose down # stop all services
docker compose logs -f # stream logsPort already in use
Error: listen EADDRINUSE: address already in use :::3000
Another process is using that port. Either stop it or use a different host port: -p 3001:3000
Docker command not found Docker Desktop is not running. Open it and wait for the engine to start.
Permission denied on volume mount (Linux/macOS) Try running with your current user's UID:
docker run -u $(id -u):$(id -g) ...Changes to code not reflected after rebuild
Make sure you rebuild the image: docker build -t workshop-app . — running docker run again without rebuilding uses the old cached image.