Docker in a Nutshell: A Practical Guide to Containerization

Marwan Jaber
13 min readOct 24, 2023

--

Introduction

The concept of containerization has revolutionized the way we develop and deploy applications. This article offers a comprehensive yet practical guide to understanding and using Docker, with hands-on examples. In a step-by-step fashion, we will introduce you to the Docker ecosystem by creating a sample Node.js application. We’ll then proceed to build a Dockerfile for this application, build an image out of it, and push this image to Docker Hub. Finally, we’ll run the containerized application, illustrating how Docker simplifies the entire software development life cycle.

The goal is to cover the essentials of Docker, making it easier for you to integrate containerization into your development workflow. Whether you’re new to Docker or looking for a refresher, this guide aims to provide a solid foundation.

Life Before Containerization

Before the age of containerization, deploying and managing applications at scale was a cumbersome and expensive process. Organizations had to rely on physical machines, laden with heavy resources, to run their applications. Each application often required its own set of dedicated servers. Not only did this method require a substantial upfront capital expenditure for hardware, but it also came with ongoing operational costs such as maintenance, electricity, and cooling.

The equation for determining the cost was complex, involving both the Total Cost of Operations (TCO) and the Total Cost of Ownership (TCO). The former encapsulated the direct costs of running and maintaining the servers, while the latter included indirect costs like software licensing, training, and support services. Overall, the process was resource-intensive, both in terms of human labor and computing power, with a high total cost that made scaling difficult and often prohibitive.

Docker and containerization technologies emerged as a game-changer, offering a more streamlined, efficient, and cost-effective way to deploy and manage applications.

Containers:

A container is a lightweight, standalone, and executable package that houses both an application and its surrounding environment. It ensures that the application runs consistently across multiple environments. This encapsulation eliminates the common “it works on my machine” issue, as the container provides the same behavior regardless of where it’s deployed. With containers, developers can focus more on building features and less on setting up environments, making the software development process more efficient and streamlined.

Practical Example

Throughout this article, we’ll be working with a sample Node.js application that serves as a perfect practical example. The app is a REST API for managing books, with functionalities to get all books, get a book by its ISBN, insert a new book, and delete a book. It’s designed to connect to a MongoDB database, and it’s structured to accept database connection parameters from environment variables, adhering to the Twelve-Factor App methodology.

This Node.js app is a comprehensive example that incorporates various best practices and features you’d find in a real-world application. It will serve as our guide through the subsequent sections, where we’ll containerize it using Docker, build an image, push that image to Docker Hub, and finally run the containerized app.

Below is the simple Node.js application using Express and MongoDB. This example assumes you have Node.js and npm installed on your computer, as well as a MongoDB server running either locally or remotely.

First, initialize a new Node.js project and install the required packages:

npm init -y
npm install express mongoose dotenv

From the command line terminal, Create.env file to store your MongoDB connection parameters:

MONGODB_URI=mongodb://localhost:27017/booksDB

Now, create a file named app.js and paste the following code:

const express = require('express');
const mongoose = require('mongoose');
const dotenv = require('dotenv');

dotenv.config();

// Initialize Express app
const app = express();
app.use(express.json());

// MongoDB connection
mongoose.connect(process.env.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true })
.then(() => console.log('MongoDB connected'))
.catch(err => console.log(err));

// Define the Book schema
const bookSchema = new mongoose.Schema({
title: String,
author: String,
isbn: String
});

const Book = mongoose.model('Book', bookSchema);

// GET all books
app.get('/books', async (req, res) => {
const books = await Book.find();
res.json(books);
});

// GET a book by ISBN
app.get('/books/:isbn', async (req, res) => {
const book = await Book.findOne({ isbn: req.params.isbn });
res.json(book);
});

// Insert a book
app.post('/books', async (req, res) => {
const newBook = new Book({
title: req.body.title,
author: req.body.author,
isbn: req.body.isbn
});
const savedBook = await newBook.save();
res.json(savedBook);
});

// Delete a book
app.delete('/books/:isbn', async (req, res) => {
const deletedBook = await Book.findOneAndDelete({ isbn: req.params.isbn });
res.json(deletedBook);
});

// Start the server
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});

Docker

Docker is an open-source platform designed to make running applications in containers easy and efficient. The project is actively maintained on GitHub, and it comes in two editions: the Community Edition, which is free to use, and the Enterprise Edition, which comes with a license and offers additional features suitable for large-scale deployments.

When an application is encapsulated in a Docker container, it becomes a “containerized app,” simplifying both development and deployment. Docker takes the complexities out of container management, offering a seamless way to package and distribute applications.

Getting Docker Installed and Understanding Its Core Components

Before diving into the world of containers and Docker-based application development, you’ll need to install Docker on your system. Docker is available for multiple platforms, including Windows, macOS, and Linux.

Installation Steps:

Windows:

  1. Download Docker Desktop from the official Docker website.
  2. Run the installer and follow the on-screen instructions.
  3. Restart your system if prompted.

macOS:

  1. Download Docker Desktop for Mac from the official Docker website.
  2. Drag and drop the Docker.app into the Applications folder.
  3. Launch Docker from Applications or Spotlight.

Linux (Ubuntu as an example):

  1. Update your package index:
sudo apt update

2. Install prerequisite packages:

sudo apt install apt-transport-https ca-certificates curl software-properties-common

3. Add Docker’s official GPG key:

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

3. Add Docker repository:

sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

4. Install Docker:

sudo apt update sudo apt install docker-ce

Core Components of Docker

After successfully installing Docker, it’s essential to understand its core components to make the most out of it. Here are some of the key components:

  • Docker Engine:
    The heart of the Docker platform, responsible for building, running, and managing containers.
  • Docker CLI:
    The Command-Line Interface (CLI) allows you to interact with Docker using commands like docker build, docker run, and so on.
  • Docker Daemon:
    A background service running on your host machine is responsible for building, running, and distributing Docker containers.
  • Docker Image:
    A template containing the application code, libraries, and dependencies required to run an application. You build your container based on an image.
  • Docker Container:
    A runtime instance of a Docker image, encapsulating the application and its dependencies.
  • Docker Hub:
    A cloud-based registry where you can push and pull Docker images. It’s the default registry used by Docker but you can use other third-party registries as well.
  • Dockerfile:
    A text file containing a set of instructions to build a Docker image.
  • Docker Compose:
    A tool for defining and running multi-container Docker applications. It uses a docker-compose.yml file to configure the application's services.
  • Docker Network:
    Facilitates communication between containers, as well as between containers and the host.
  • Docker Volume:
    Provides persistent storage for use with containers, allowing you to persist data across container restarts.

Building a Docker Image with Our Node.js App

To containerize the Node.js application mentioned earlier, you’ll need a Dockerfile. Create a new file named Dockerfile in the root directory of your Node.js project and paste the following content:

# Use the official Node.js image as a base image
FROM node:14

# Set the working directory inside the container
WORKDIR /usr/src/app

# Copy package.json and package-lock.json into the container
COPY package*.json ./

# Install npm dependencies
RUN npm install

# Copy the remaining application code into the container
COPY . .

# Expose port 3000 to interact with the application
EXPOSE 3000

# Command to run the application
CMD ["node", "app.js"]

This Dockerfile is designed to:

  1. Start with an official Node.js base image, using version 14.
  2. Set the working directory in the container to /usr/src/app .
  3. Copy package.json and package-lock.json into the container.
  4. Install any required npm dependencies.
  5. Copy the rest of the application code into the container.
  6. Expose port 3000 so that the application can accept connections.
  7. Specify the command to run the application using node app.js.

Building a Docker Image

The command to build a Docker image is docker build .This command is followed by various options and arguments, including the build context. The build context is usually specified as the path to a directory containing the Dockerfile and all the files that should be included in the image. The command syntax is:

docker build [OPTIONS] PATH | URL | -

Breaking Down the Command

  • -t or --tag: Name and optionally a tag in the 'name:tag' format.
  • PATH | URL | -: Specifies the build context location. This is often a period (.) when running the command in the same directory as the Dockerfile.

How It Works

  1. Reads Dockerfile: The docker build command starts by reading the Dockerfile in the build context specified.
  2. Resolves Base Image: If the base image is not available locally, Docker pulls it from a remote registry (e.g., Docker Hub).
  3. Executes Build Steps: Docker processes each instruction in the Dockerfile, in the order they appear. For each instruction, Docker performs the required action and then creates a new layer for the resulting filesystem state.
  4. Caches Layers: Docker caches the layers to speed up future builds. If a layer hasn’t changed (for example, if the instructions and the files involved are the same), Docker simply reuses the cached layer in the subsequent builds.
  5. Tags Image: The resulting image is tagged with the name specified in the -t option.

Example for Our Node.js App

Assuming you’re in the directory containing the Dockerfile, run the following command to build an image named nodejs-book-api:

docker build -t nodejs-book-api .

In this example:

  • -t nodejs-book-api: Specifies the name of the image as nodejs-book-api .
  • .: Indicates that the current directory is the build context and contains the Dockerfile.

After running this command, Docker will go through the steps described above and create an image named nodejs-book-api

Pushing an Image to Docker Hub

Once you’ve successfully built your Docker image, the next step is to share it with others or deploy it to a remote server. Docker images are typically stored in a registry like Docker Hub, and you can push your local images to these registries using the docker push command.

Command Syntax

docker push [OPTIONS] NAME[:TAG]

Breaking Down the Command

  • NAME[:TAG]: Specifies the name of the image you want to push. If you've tagged the image, include the tag; otherwise, the latest tag is assumed.

How It Works

  1. Authentication: If you’re pushing to a private registry, including private repositories on Docker Hub, you’ll need to authenticate using docker login
  2. Upload Layers: Docker starts uploading the layers of the image to the specified registry. If some layers already exist in the registry (from a previous push or shared base image), those layers are skipped to optimize the upload process.
  3. Tag Resolution: If you’ve specified a tag, Docker pushes that particular version of the image. If no tag is specified, the latest tag is used by default.
  4. Confirmation: Upon successful upload, you’ll receive a confirmation from the CLI, and your image will be available for anyone to pull from the registry (unless it’s a private repository).

Example for Our Node.js App

After building your image with the name nodejs-book-api, you can push it to Docker Hub (or another registry) with the following command, assuming you've logged in:

docker push nodejs-book-api:latest

In this example:

  • nodejs-book-api:latest: Specifies the name of the image (nodejs-book-api) and the tag (latest).

Running the Docker Container from the Image

After building and pushing an image to a registry, the next step is to run a container based on that image. This is where the docker container run command comes into play. It's one of the most frequently used Docker commands because it combines several operations into a single step: pulling an image, creating a new container from that image, and running the container.

Command Syntax

docker container run [OPTIONS] IMAGE [COMMAND] [ARG...]

Breaking Down the Command

  • OPTIONS: Various options to modify the behavior, like port mapping (-p), volume mounting (-v), and so on.
  • IMAGE: The image you want to run, specified by name and optionally by tag (name:tag).
  • COMMAND and ARG: Optional command and arguments to override the default CMD specified in the Dockerfile.

How It Works

  1. Image Resolution: Docker first checks if the specified image is available locally. If not, it pulls the image from the remote registry.
  2. Create Container: Docker creates a new container based on the image. At this step, any specified options like port mappings, volume mounts, and environment variables are configured.
  3. Start Container: Docker starts the container and executes the command specified in the CMD instruction of the Dockerfile, unless overridden by the user.
  4. Output Stream: Logs and output from the container’s main process are streamed back to the console unless detached mode (-d) is specified.

Example for Our Node.js App

To run our nodejs-book-api image as a container, you might use the following command:

docker container run -p 3000:3000 nodejs-book-api

In this example:

  • -p 3000:3000: Maps port 3000 in the container to port 3000 on the host.
  • nodejs-book-api: Specifies the image to use, which is the one we built and pushed earlier.

Running MongoDB in a Separate Container with Volumes

The Need for a Volume

When you run MongoDB (or any database) inside a Docker container, the data is stored within the container’s filesystem by default. While this works, it has a significant drawback: if the container is removed, all its data is lost as well. This is where Docker volumes come into play. Volumes are Docker’s way of enabling persistent storage that can be accessed by containers, even if those containers are removed or updated. By mounting a volume to the MongoDB container, you can ensure that the database files persist beyond the lifecycle of the container itself.

Creating and Using a Docker Volume

Creating a Docker volume is a straightforward process and can be accomplished using the docker volume create command. Here is how to create a volume and then mount it to the /data/db directory of a MongoDB container.

Step 1: Create a Docker Volume

To create a new Docker volume named mongodb-data, you can use the following command:

bashCopy code
docker volume create mongodb-data

Step 2: Run MongoDB with the Volume Mounted

After the volume is created, you can run a MongoDB container and mount this volume to the /data/db directory, which is the default location where MongoDB stores its data files.

docker container run -p 27017:27017 -v mongodb-data:/data/db --name mongo-container mongo:latest

In this example:

  • -p 27017:27017: Maps port 27017 in the container to port 27017 on the host.
  • -v mongodb-data:/data/db: Mounts the mongodb-data volume to the /data/db directory in the container.
  • --name mongo-container: Assign a name (mongo-container) to the running container.
  • mongo:latest: Specifies the MongoDB image to use (latest version).

Connecting the Node.js Application to MongoDB Container

When running your application and MongoDB as separate containers, you need to ensure that they can communicate with each other. Docker provides several networking features to facilitate this communication. Here are the steps you need to follow to connect your Node.js application with the MongoDB container.

Step 1: Create a Docker Network

Creating a Docker network will enable containers to communicate with each other using the network’s bridge. To create a new network named app-network, execute:

docker network create app-network

Step 2: Run Containers on the Created Network

MongoDB Container

You need to attach your MongoDB container to this newly created network. If it’s already running, you can either stop it and restart it with the network option or connect it to the network using docker network connect. Here's how to run it with the network option:

docker container run -p 27017:27017 -v mongodb-data:/data/db --name mongo-container --network app-network mongo:latest

Node.js Application Container

Likewise, you need to run your Node.js application container on the same network. Here’s an example:

docker container run -p 3000:3000 --name nodejs-app --network app-network nodejs-book-api

Step 3: Update MongoDB Connection Parameters

Your Node.js application needs to know how to find the MongoDB service. In the MongoDB URI within your application, instead of using localhost, use the name of the MongoDB container (mongo-container in our example).

Here’s how your MongoDB URI might look:

const MONGO_URI = process.env.MONGO_URI || "mongodb://mongo-container:27017/mydatabase";

By specifying the MongoDB container’s name (mongo-container), Docker resolves this to the appropriate internal IP address within the app-network.

Step 4: Inject Environment Variables (Optional)

If you want to make the MongoDB connection parameters configurable, you can pass them as environment variables when you run your Node.js container

docker container run -p 3000:3000 -e MONGO_URI=mongodb://mongo-container:27017/mydatabase --name nodejs-app --network app-network nodejs-book-api

Summary

In this comprehensive guide, we delved into the essential aspects of Docker, starting with the challenges faced in the pre-containerization era and transitioning to how Docker revolutionizes application deployment and management. We introduced Docker’s core components and key functionalities, explaining how they interact to offer a seamless and efficient development and deployment environment. We used a practical Node.js application connected to a MongoDB database as a running example to illustrate key concepts like building Docker images, understanding Docker layers, pushing images to Docker Hub, and running containers.

We also covered advanced topics like Docker volumes for persistent data storage and Docker networking for inter-container communication. The guide walked you through the step-by-step process of setting up, running, and connecting multiple containers, including a detailed discussion on modifying the application to communicate with the MongoDB container effectively. By the end of the article, you should have a solid understanding of Docker’s ecosystem, its advantages over traditional virtual machines, and how to use it for developing, deploying, and scaling applications.

--

--