nubpaws.dev
Back to Articles
Article

How to Containerize Your Project with Docker

Step-by-step tutorial on containerizing applications using Docker. Covers Dockerfiles, Docker Compose, volumes, networking, and production tips.

March 28, 2025 NubPaws
Docker DevOps Tutorial

Table of Contents

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.yml file 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 compose commands 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, the docker command 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:

  1. C++ TCP Server - Listens on a port, generates random numbers on request, and logs each request to a file.
  2. Node.js Backend - Connects to the C++ server, fetches random numbers, and serves them over HTTP.
  3. 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 using latest to ensure reproducible builds. If you use latest, 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. The rm -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:latest or node:latest means 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:

  1. We copy package.json first and run npm install.
  2. 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 -slim variant of Node images excludes tools like gcc, make, and Python that are included in the full image. Unless you need to compile native modules, -slim gives 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 use expose instead of ports because this service only needs to be reachable by other containers on the Docker network, not from the host machine.

  • backend - Builds from ./backend/Dockerfile. The ports: "3000:3000" mapping makes it accessible from the host (so the frontend in the browser can reach it). The environment section sets variables that the Node.js code reads with process.env.

  • frontend - Instead of building a custom image, we use the official nginx:alpine image 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_on only 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:

  1. Create a volume named server-data (if it doesn’t already exist).
  2. Mount it at /data inside the server container.

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 in docker-compose.yml.
  • --build - Forces Docker to rebuild the images before starting. Without this flag, Docker uses cached images if they exist. Always include --build when 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:

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 prune deletes volumes that aren’t currently mounted to any container. If you’ve run docker compose down and 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!