Building and Running Your First Container Image

In this chapter, we’ll take our first concrete step towards a production-ready application stack: containerizing a simple web application. You’ll learn how to define a Docker image using a Dockerfile, build that image, and then run it as a Docker container. This is the foundational skill for all subsequent containerized deployments and is essential for achieving consistent, isolated environments.

By the end of this milestone, you will have a working “Hello World” web server running inside its own isolated Docker container, accessible from your host machine. This demonstrates the core Docker workflow of packaging an application and its dependencies into a portable unit, a critical step for modern deployments.

Project Overview for This Chapter

Our objective for this chapter is straightforward: transform a basic Node.js Express web application into a Docker image and run it as a container. This involves:

  1. Creating a minimal Node.js Express application.
  2. Writing a Dockerfile to define the container environment and application build steps.
  3. Building a Docker image from the Dockerfile.
  4. Running a Docker container from the newly built image.
  5. Verifying the application is running and accessible.

This process lays the groundwork for understanding how individual services are packaged and executed in a containerized world.

Tech Stack

For this chapter, we’re focusing on:

  • Docker Engine: The core platform for building and running containers. (Version unknown, checked 2026-05-22)
  • Node.js: The JavaScript runtime for our simple web application. We’ll use Node.js 20, which is the current LTS release as of 2026-05-22.
  • Express.js: A minimal and flexible Node.js web application framework.

Architecture: Docker Image Build and Run Flow

The Dockerfile acts as a blueprint, specifying everything needed to run our application, from the base operating system to the application code itself and its dependencies. This ensures that our application runs consistently across different environments.

The process of creating and running our container will follow these steps:

flowchart TD A[Application Code] --> B[Dockerfile] B --> C[Docker Build Command] C --> D[Docker Engine] D --> E[Docker Image] E --> F[Docker Run Command] F --> G[Docker Container] G --> H[Access Application]

Build Plan: Milestones for Containerization

We’ll break down the containerization process into these concrete steps:

  1. Project Setup: Create the root directory for our application.
  2. Application Development: Write a simple Node.js Express server.
  3. Dockerfile Creation: Define the image build instructions.
  4. Image Building: Execute the docker build command.
  5. Container Execution: Run the image as a container with port mapping.
  6. Verification: Test the running application and inspect container status.

Initial Project Structure

We’ll start with a clean directory, then add our application files and Dockerfile to it.

my-docker-app/
├── app.js
├── package.json
└── Dockerfile

Step-by-Step Implementation

Let’s get hands-on and build our first container.

1. Set Up Your Project Directory

First, create a new directory for our project and navigate into it. This will be the “build context” for our Docker image.

mkdir my-docker-app
cd my-docker-app

2. Create a Simple Node.js Application

We’ll create a basic Express application that listens on port 3000 and responds with “Hello from Docker!”.

File: package.json

Start by initializing a package.json file. This manages our project’s metadata and dependencies.

npm init -y

The -y flag accepts all default prompts, creating a basic package.json file. Next, install Express:

npm install express

This command adds express as a dependency to your package.json and installs it into node_modules.

File: app.js

Create app.js in your my-docker-app directory. This file contains our simple web server logic.

// my-docker-app/app.js
const express = require('express');
const app = express();
const port = 3000; // The port our app will listen on inside the container

app.get('/', (req, res) => {
  console.log('Received request for /'); // Log requests for visibility
  res.send('Hello from Docker!');
});

app.listen(port, () => {
  console.log(`Web server listening on port ${port}`);
});

This is a straightforward Express server. It’s designed to be minimal to focus on the Docker concepts without application complexity.

3. Craft Your Dockerfile

The Dockerfile contains instructions for Docker on how to build your image. Create a file named Dockerfile (no extension) in the my-docker-app directory.

File: Dockerfile

# my-docker-app/Dockerfile

# Stage 1: Use a minimal Node.js base image for building
# Node.js 20 is the current LTS as of 2026-05-22.
# 'alpine' variants are much smaller and more secure than full Debian images.
FROM node:20-alpine AS base

# Set the working directory inside the container
# All subsequent commands will execute relative to this directory.
WORKDIR /app

# Copy package.json and package-lock.json to leverage Docker's build cache.
# This crucial step ensures npm install only runs if dependency definitions change,
# speeding up subsequent builds.
COPY package*.json ./

# Install application dependencies
# The --omit=dev flag ensures only production dependencies are installed,
# significantly reducing the image size and potential attack surface.
RUN npm install --omit=dev

# Copy the rest of the application code into the container's working directory.
COPY . .

# Expose the port the app runs on.
# This is documentation; it informs Docker that the container listens on this port
# but does not actually publish it to the host.
EXPOSE 3000

# Define the command to run when the container starts.
# This uses the default Node.js executable from the base image to start our app.
CMD ["node", "app.js"]

Explanation of Dockerfile Instructions:

  • FROM node:20-alpine AS base:
    • What it does: Specifies the base image for our build.
    • Why it’s used: Provides a pre-configured environment with Node.js 20.
    • Tradeoffs/Decisions: We choose alpine for its small size and minimal footprint, which translates to faster downloads, less disk space, and a reduced attack surface compared to larger Debian-based images. AS base names this build stage, a practice that becomes powerful with multi-stage builds (covered later).
  • WORKDIR /app:
    • What it does: Sets the working directory inside the container for any subsequent RUN, CMD, ENTRYPOINT, COPY, or ADD instructions.
    • Why it’s used: Keeps our application files organized within the container’s filesystem.
  • COPY package*.json ./:
    • What it does: Copies package.json and package-lock.json (if it exists) from your host machine’s current directory (the build context) into the /app directory inside the container.
    • Why it’s used: This is a key optimization. By copying only the dependency manifest files before installing dependencies, Docker can cache the npm install layer. If package.json doesn’t change, Docker reuses the cached layer, speeding up subsequent builds.
  • RUN npm install --omit=dev:
    • What it does: Executes the npm install command inside the container.
    • Why it’s used: Installs all the production dependencies listed in package.json. The --omit=dev flag is crucial for production images, as it prevents development dependencies from being installed, significantly reducing image size and potential vulnerabilities.
    • 📌 Key Idea: Always minimize dependencies in production images.
  • COPY . .:
    • What it does: Copies all remaining files from your current host directory (., the build context) into the /app directory inside the container (.).
    • Why it’s used: Brings your application source code into the image. This step is placed after npm install to benefit from build caching if only code changes, not dependencies.
  • EXPOSE 3000:
    • What it does: Informs Docker that the container listens on port 3000 at runtime.
    • Why it’s used: It’s documentation for anyone inspecting the image or for tools like docker inspect. It does not actually publish the port to the host system.
  • CMD ["node", "app.js"]:
    • What it does: Specifies the default command that will be executed when the container starts.
    • Why it’s used: This is how our application is launched when a container is created from this image. This command can be overridden when running the container.

4. Build the Docker Image

Now, let’s build the image using the docker build command. Make sure you are in the my-docker-app directory.

docker build -t my-web-app:1.0.0 .

Command Explanation:

  • docker build: The command to build a Docker image.
  • -t my-web-app:1.0.0: The -t (tag) flag names and optionally tags your image. my-web-app is the image name, and 1.0.0 is the tag (version). It’s good practice to tag images with meaningful, version-controlled names.
  • .: The build context. This tells Docker to look for the Dockerfile and associated files in the current directory. All files in this directory are sent to the Docker daemon for the build process.

You will see output as Docker executes each step in your Dockerfile. Each RUN command creates a new layer in the image. If successful, the last line will indicate that the image was built and tagged.

5. Run Your Container

With the image built, we can now run it as a container.

docker run -p 3000:3000 my-web-app:1.0.0

Command Explanation:

  • docker run: The command to create and start a new container from an image.
  • -p 3000:3000: The -p (publish) flag is critical. It maps a port from your host machine to a port inside the container. The format is HOST_PORT:CONTAINER_PORT. Here, we’re mapping host port 3000 to container port 3000. This is essential for accessing your web application from your browser or other clients on your host machine.
  • my-web-app:1.0.0: The name and tag of the image you want to run.

You should see output similar to: Web server listening on port 3000. This confirms your Node.js application is running inside the container and listening on its internal port.

Testing & Verification

Now that the container is running, let’s verify that our application is accessible and behaving as expected.

  1. Access the Application: Open your web browser and navigate to http://localhost:3000. You should see the text “Hello from Docker!”. This confirms your application is serving content through the mapped port.

  2. Inspect Running Containers: Open a new terminal window (keep the one running your container open). Use docker ps to see currently running containers.

    docker ps

    You should see an entry for my-web-app:1.0.0, showing its CONTAINER ID, IMAGE, COMMAND, CREATED, STATUS (e.g., Up X seconds), PORTS (e.g., 0.0.0.0:3000->3000/tcp), and NAMES. Note the PORTS column, which explicitly shows the host-to-container port mapping.

  3. Check Container Logs: You can also inspect the logs generated by your application inside the container. In your new terminal, replace <CONTAINER_ID_FROM_DOCKER_PS> with the actual ID from the docker ps command.

    docker logs <CONTAINER_ID_FROM_DOCKER_PS>

    You should see the output from your app.js like Web server listening on port 3000 and Received request for / if you accessed it via the browser. This is crucial for debugging and understanding container behavior.

Production Considerations

Even for a simple “Hello World,” thinking about production best practices from the start is crucial. These early habits prevent major issues down the line.

  • Image Size:
    • Why it matters: Smaller images mean faster image pulls, less disk usage, and a reduced attack surface. node:20-alpine is a prime example of this.
    • Real-world insight: In CI/CD pipelines, smaller images drastically reduce build and deployment times. In cloud environments, they can lower storage costs.
  • Security (Least Privilege):
    • What can go wrong: Our current Dockerfile runs npm install and the node app.js command as the root user by default inside the container. Running as root is a security risk because if an attacker compromises your application, they gain root privileges within the container.
    • Optimization / Pro tip: In production, you should ideally run containers with a non-root user. This is achieved using the USER instruction in the Dockerfile. We will explore this in a later chapter to keep this introduction focused.
  • Resource Management:
    • What can go wrong: By default, Docker containers can consume all available CPU and memory on the host. One misbehaving container (e.g., a memory leak) could starve other containers or even crash the host system.
    • Real-world insight: In production, it’s critical to set resource limits (CPU, memory) for containers using docker run --cpus and --memory. When we introduce Docker Compose, we’ll see how to define these limits declaratively.
  • Port Exposure:
    • Why it matters: We only exposed port 3000 using -p 3000:3000. Only expose ports that are absolutely necessary for your application to function.
    • What can go wrong: Exposing unnecessary ports is a significant security risk, as it opens up potential attack vectors to services that shouldn’t be publicly accessible.

Common Issues & Solutions

Here are some common issues you might encounter and how to troubleshoot them:

  • “Cannot connect to the Docker daemon”:
    • Cause: The Docker Engine is not running on your machine.
    • Solution: Start your Docker Desktop application (on Windows/macOS) or ensure the Docker service is running (on Linux: sudo systemctl start docker).
  • “Error response from daemon: driver failed programming external connectivity on endpoint…” or “port is already allocated”:
    • Cause: Port 3000 on your host machine is already in use by another process.
    • Solution: Stop the other process, or choose a different host port to map (e.g., docker run -p 8080:3000 my-web-app:1.0.0).
  • “No such image: my-web-app:1.0.0”:
    • Cause: You might have a typo in the image name or tag, or the image didn’t build successfully.
    • Solution: Run docker images to see a list of all locally available images and verify the name and tag. Re-run the docker build command if the image is missing or the tag is incorrect.
  • Application not starting/crashing:
    • Cause: The container starts but immediately exits, or you can’t access it. This often means there’s an error in your app.js or the CMD instruction.
    • Solution: Check the container logs using docker logs <CONTAINER_ID>. This will show you any errors or output from your Node.js application, helping you pinpoint the problem.

Summary & Next Step

Congratulations! You’ve successfully built your first Docker image and run it as a container. You now understand the fundamental process of:

  • Defining application dependencies and execution steps in a Dockerfile.
  • Using docker build to create a reusable, versioned image.
  • Using docker run to launch an isolated container, mapping necessary ports for external access.

This containerized web application is a standalone, portable unit. It can now run consistently across any environment with Docker installed. In the next chapter, we’ll introduce another service—a database—and learn how to make these services communicate with each other, moving us closer to a full multi-service application.


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.

References