Table of Contents
- Introduction
- Terminology
- Installing Docker
- The Example Project
- Writing Dockerfiles
- Docker Compose
- Volumes
- Building and Running
- Cleaning Up
- Tips for Production
- What’s Next
Introduction
Imagine being able to set up any project on a new machine with just two commands:
git clone repo-url
docker compose up
No installing compilers, no managing runtime versions, no “it works on my machine.” That’s the promise of containerization - packaging your application and all of its dependencies into a portable, reproducible environment that runs the same way everywhere.
This tutorial walks through the process of containerizing a multi-service project from scratch. By the end, you’ll have a working setup with a C++ TCP server, a Node.js backend, and an HTML frontend - all orchestrated with Docker Compose.
This guide assumes you’re comfortable with the command line and have a basic understanding of how web applications work. No prior Docker experience is required.
Terminology
Before diving in, here are the key concepts you’ll encounter throughout this guide:
-
Docker - A platform for building, shipping, and running applications in containers. It provides the tools to package your app and its dependencies into a standardized unit.
-
Docker Hub - A public registry where Docker images are stored and shared. Think of it like GitHub, but for container images. When you write
FROM node:20-slim, Docker pulls that image from Docker Hub. -
Image - A read-only template that contains your application code, runtime, libraries, and configuration. Images are built from Dockerfiles and used to create containers.
-
Container - A running instance of an image. Containers are isolated from each other and from the host system. You can run multiple containers from the same image.
-
Dockerfile - A text file with instructions for building a Docker image. Each instruction creates a layer in the image.
-
Volume - A mechanism for persisting data generated by containers. Without volumes, all data inside a container is lost when the container is removed.
-
Network - Docker creates virtual networks that allow containers to communicate with each other. Docker Compose automatically creates a shared network for all services in a project.
-
Docker Compose - A tool for defining and running multi-container applications. You describe your services in a
docker-compose.ymlfile and manage them all with a single command.
Installing Docker
Rather than walking through platform-specific installation steps, head to the official Docker documentation to install Docker Desktop for your operating system:
Get Docker - Download Docker Desktop for Windows, macOS, or Linux.
Important:Docker Desktop must be installed and running for
docker composecommands to work. On macOS, Docker runs inside a Linux VM managed by Docker Desktop. On Windows with WSL, Docker Desktop provides the Docker engine via WSL integration - without it running, thedockercommand won’t be available inside WSL.
WSL Users:Make sure Docker Desktop is running on Windows and that WSL integration is enabled. You can check this in Docker Desktop under Settings > Resources > WSL Integration.
Once installed, verify that Docker Compose is available by running:
docker compose version
You should see output like Docker Compose version v2.x.x. If so, you’re ready to go.
The Example Project
We’ll containerize a project with three services:
- C++ TCP Server - Listens on a port, generates random numbers on request, and logs each request to a file.
- Node.js Backend - Connects to the C++ server, fetches random numbers, and serves them over HTTP.
- HTML Frontend - A simple page that calls the backend API and displays the results.
Here’s the directory structure:
project/
├── server/
│ ├── main.cpp
│ └── Dockerfile
├── backend/
│ ├── server.js
│ ├── package.json
│ ├── Dockerfile
│ └── .dockerignore
├── frontend/
│ └── index.html
└── docker-compose.yml
All of the source files are available for download below. You can grab them individually or just follow along with the descriptions.
C++ TCP Server - server/main.cpp
A bare-bones TCP server using POSIX sockets. It binds to port 8080, accepts connections in an infinite loop, and for each connection generates 5 random numbers between 0-99. The numbers are sent back as a comma-separated string (e.g. "42,7,91,3,68"). Every request is also logged with a timestamp to /data/requests.log - this log path will matter later when we set up volumes.
For the C++ connoisseurs out there, this might not be the nicest C++ code you’ve seen, but for the example, this code will suffice. And if this is an actual service you require, don’t imitate this code, as random numbers generated this way can be easily predicted by an intermediate attacker.
Node.js Backend - backend/server.js
An HTTP server that acts as a bridge between the frontend and the C++ server. When a request hits /api/random, it opens a TCP connection to the C++ server (using the hostname and port from environment variables SERVER_HOST and SERVER_PORT), sends a request, parses the comma-separated response into a JSON array, and returns it with CORS headers so the browser can call it.
The package.json is minimal - just a name, version, and a start script that runs node server.js.
HTML Frontend - frontend/index.html
A single HTML page with a button. Clicking it fetches http://localhost:3000/api/random, parses the JSON response, and displays the numbers. Nothing fancy - just enough to demonstrate the full pipeline from browser to Node.js to C++ and back.
Writing Dockerfiles
A Dockerfile is a set of instructions that tells Docker how to build an image for your application. Each instruction in the file creates a new layer - Docker caches these layers, so unchanged layers don’t need to be rebuilt. This makes subsequent builds much faster.
C++ Server Dockerfile - server/Dockerfile
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y g++ && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY main.cpp .
RUN g++ -o server main.cpp
RUN mkdir -p /data
EXPOSE 8080
CMD ["./server"]
Let’s break this down line by line:
FROM ubuntu:24.04- Start from the Ubuntu 24.04 base image. We pin a specific version instead of usinglatestto ensure reproducible builds. If you uselatest, your image could break when Ubuntu publishes a new release.RUN apt-get update && apt-get install -y g++ && rm -rf /var/lib/apt/lists/*- Install the C++ compiler. Therm -rf /var/lib/apt/lists/*at the end cleans up the package cache to keep the image smaller. We chain these with&&so they happen in a single layer.WORKDIR /app- Set the working directory inside the container. All subsequent commands run from here.COPY main.cpp .- Copy our source code from the build context into the container.RUN g++ -o server main.cpp- Compile the C++ code.RUN mkdir -p /data- Create the directory where request logs will be written.EXPOSE 8080- Document that this container listens on port 8080. This doesn’t actually publish the port - it’s metadata for anyone reading the Dockerfile.CMD ["./server"]- The default command to run when the container starts.
Why pin image versions?:Using
ubuntu:latestornode:latestmeans your build could break at any time when a new version is published. Always pin to a specific version (e.g.,ubuntu:24.04,node:20-slim) so your builds are reproducible.
Node.js Backend Dockerfile - backend/Dockerfile
FROM node:20-slim
WORKDIR /app
COPY package.json .
RUN npm install
COPY server.js .
EXPOSE 3000
CMD ["npm", "start"]
This Dockerfile uses a deliberate ordering to take advantage of Docker’s layer caching:
- We copy
package.jsonfirst and runnpm install. - Only then do we copy our application code (
server.js).
Why? Because Docker caches each layer. If you change server.js but not package.json, Docker reuses the cached npm install layer instead of reinstalling all dependencies. This saves significant time during development when you’re rebuilding frequently.
If we had instead written COPY . . followed by RUN npm install, every code change would trigger a full npm install, even if the dependencies haven’t changed.
Why node:20-slim?:The
-slimvariant of Node images excludes tools likegcc,make, and Python that are included in the full image. Unless you need to compile native modules,-slimgives you a smaller and faster-to-pull image.
.dockerignore Files
Just like .gitignore tells Git which files to skip, a .dockerignore file tells Docker which files to exclude from the build context - the set of files sent to the Docker daemon when building an image.
The backend/.dockerignore excludes node_modules and npm-debug.log. This prevents your local node_modules from being copied into the container (we want npm install to create a fresh one inside the container) and keeps the build context small and fast to transfer.
Docker Compose
With Dockerfiles for each service, we could build and run them individually with docker build and docker run. But managing multiple containers that way gets tedious fast - you’d need to create networks, set environment variables, and start everything in the right order manually.
Docker Compose solves this. You define all your services in a single docker-compose.yml file and manage them with one command.
docker-compose.yml
services:
server:
build:
context: ./server
dockerfile: Dockerfile
expose:
- "8080"
volumes:
- server-data:/data
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- SERVER_HOST=server
- SERVER_PORT=8080
depends_on:
- server
frontend:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./frontend:/usr/share/nginx/html:ro
depends_on:
- backend
volumes:
server-data:
Let’s walk through each part:
Services
Each top-level key under services defines a container:
-
server- Builds from./server/Dockerfile. We useexposeinstead ofportsbecause this service only needs to be reachable by other containers on the Docker network, not from the host machine. -
backend- Builds from./backend/Dockerfile. Theports: "3000:3000"mapping makes it accessible from the host (so the frontend in the browser can reach it). Theenvironmentsection sets variables that the Node.js code reads withprocess.env. -
frontend- Instead of building a custom image, we use the officialnginx:alpineimage and mount our HTML file as a read-only volume. Nginx serves it on port 80 inside the container, which we map to port 8080 on the host.
depends_on
The depends_on key controls startup order. Here, backend waits for server to start, and frontend waits for backend.
depends_on caveat:
depends_ononly waits for the container to start, not for the application inside it to be ready. If your backend needs the C++ server to be fully initialized before connecting, you’ll need a healthcheck or a retry mechanism in your application code. For this tutorial, the simple TCP server starts almost instantly, so startup order alone is sufficient.
Networking
Docker Compose automatically creates a default network for all services defined in the file. Containers can reach each other using their service name as a hostname.
That’s why the backend uses SERVER_HOST=server - Docker’s internal DNS resolves the name server to the IP address of the C++ server container. No manual network configuration needed.
expose vs ports
expose- Makes a port available to other containers on the same Docker network. The port is not accessible from the host machine.ports- Maps a container port to a host port (host:container). This makes the service accessible from your browser or other tools running on the host.
Use expose for internal services and ports for anything that needs to be reached from outside the Docker network.
No version field?:Older Docker Compose files often start with
version: "3.9"at the top. This field is deprecated in modern versions of Docker Compose and can be safely omitted. Docker Compose now infers the format automatically.
Volumes
If you’ve been following along and wondering what happens to the request log when the container stops - it’s gone. By default, all data inside a container is ephemeral. When you run docker compose down, the containers are removed and everything inside them disappears.
Volumes solve this by providing persistent storage that lives outside the container’s filesystem.
The Problem
Our C++ server writes request logs to /data/requests.log inside the container. Without a volume, this file is destroyed every time the container is removed. Restart the container, and the log starts fresh - all previous entries are lost.
The Solution: Named Volumes
In our docker-compose.yml, we defined a named volume:
services:
server:
# ...
volumes:
- server-data:/data
volumes:
server-data:
This tells Docker:
- Create a volume named
server-data(if it doesn’t already exist). - Mount it at
/datainside theservercontainer.
Now the /data directory is backed by the volume instead of the container’s ephemeral filesystem. When the container is removed and recreated, the volume persists - and so does everything in /data.
Seeing It in Action
Start the project and make a few requests:
docker compose up --build -d
Open http://localhost:8080 in your browser and click the button a few times to generate some numbers. Each click triggers a request that gets logged.
Now tear everything down and bring it back up:
docker compose down
docker compose up -d
Make a few more requests, then check the log. You can peek inside the running container:
docker compose exec server cat /data/requests.log
You’ll see log entries from both sessions - the data survived the docker compose down because it’s stored in the volume, not in the container.
Inspecting Volumes
To see details about a volume:
docker volume inspect <project-name>_server-data
This shows you where Docker stores the volume data on your host filesystem (typically under /var/lib/docker/volumes/ on Linux).
Bind Mounts
Named volumes are managed by Docker, but sometimes you want to mount a host directory directly into a container. This is called a bind mount. We actually used one for the frontend:
volumes:
- ./frontend:/usr/share/nginx/html:ro
This mounts the ./frontend directory from your host into the container. The :ro suffix makes it read-only. Changes you make to the files on your host are immediately reflected in the container - useful during development.
The trade-off: bind mounts depend on the host filesystem, so they’re less portable than named volumes. Use named volumes for data that should persist (databases, logs) and bind mounts for development workflows where you want live file syncing.
Building and Running
With all the files in place, you can bring up the entire project with a single command:
docker compose up --build
up- Creates and starts all the containers defined indocker-compose.yml.--build- Forces Docker to rebuild the images before starting. Without this flag, Docker uses cached images if they exist. Always include--buildwhen you’ve changed code or Dockerfiles.
You should see output from all three services as they start up. Once you see the server and backend listening messages, open your browser:
- Frontend: http://localhost:8080
- Backend API: http://localhost:3000/api/random
Running in the Background
Add the -d (detached) flag to run containers in the background:
docker compose up --build -d
Your terminal is freed up, and the containers run quietly in the background.
Viewing Logs
When running in detached mode, use logs to see output:
docker compose logs
Follow logs in real-time (like tail -f):
docker compose logs -f
View logs for a specific service:
docker compose logs backend
Stopping
To stop and remove all containers:
docker compose down
This stops the containers and removes them, along with the default network. Named volumes are preserved - your data is still there the next time you run docker compose up.
To also remove volumes (destroying all persisted data):
docker compose down --volumes
Cleaning Up
Docker can accumulate unused images, containers, and volumes over time. Here are the most useful cleanup commands:
Remove everything unused
docker system prune
This removes all stopped containers, unused networks, and dangling images. Add -a to also remove all images not associated with a running container:
docker system prune -a
Selective cleanup
Remove dangling images (untagged layers from old builds):
docker image prune
Remove all stopped containers:
docker container prune
Remove unused volumes:
docker volume prune
Be careful with volume prune:
docker volume prunedeletes volumes that aren’t currently mounted to any container. If you’ve rundocker compose downand then prune volumes, your persisted data will be deleted. Only prune volumes when you’re sure you don’t need the data.
Tips for Production
The Dockerfiles in this tutorial are optimized for clarity, not for production. Here are a few improvements to consider when deploying for real.
Multi-Stage Builds
Multi-stage builds let you use one image for building and a different (smaller) image for running. This is especially useful for compiled languages:
# Build stage
FROM ubuntu:24.04 AS builder
RUN apt-get update && apt-get install -y g++ && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY main.cpp .
RUN g++ -o server main.cpp
# Runtime stage
FROM ubuntu:24.04
WORKDIR /app
COPY --from=builder /app/server .
RUN mkdir -p /data
EXPOSE 8080
CMD ["./server"]
The final image doesn’t include g++ or any build tools - just the compiled binary. This dramatically reduces the image size and attack surface.
Run as a Non-Root User
By default, processes inside containers run as root. It’s a good practice to create and switch to a non-root user:
RUN useradd -m appuser
USER appuser
Use npm ci Instead of npm install
For production Node.js images, prefer npm ci over npm install. It installs dependencies from the lockfile exactly, ensuring reproducible builds and faster installation:
COPY package.json package-lock.json .
RUN npm ci --omit=dev
Health Checks
Add health checks so Docker (and orchestrators like Kubernetes) can detect when a service is unhealthy:
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:3000/ || exit 1
What’s Next
This tutorial covered the fundamentals: Dockerfiles, Docker Compose, volumes, networking, and some production best practices. From here, there’s a lot more to explore:
- Container Orchestration - Tools like Kubernetes and Docker Swarm manage containers across multiple machines, handling scaling, load balancing, and self-healing.
- CI/CD Integration - Automate building and deploying your containers with GitHub Actions, GitLab CI, or similar tools.
- Docker Official Documentation - The Docker docs are an excellent resource for diving deeper into any topic covered here.
Happy containerizing!