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:
- Creating a minimal Node.js Express application.
- Writing a
Dockerfileto define the container environment and application build steps. - Building a Docker image from the
Dockerfile. - Running a Docker container from the newly built image.
- 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:
Build Plan: Milestones for Containerization
We’ll break down the containerization process into these concrete steps:
- Project Setup: Create the root directory for our application.
- Application Development: Write a simple Node.js Express server.
- Dockerfile Creation: Define the image build instructions.
- Image Building: Execute the
docker buildcommand. - Container Execution: Run the image as a container with port mapping.
- 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
└── DockerfileStep-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-app2. 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 -yThe -y flag accepts all default prompts, creating a basic package.json file. Next, install Express:
npm install expressThis 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
alpinefor 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 basenames 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, orADDinstructions. - Why it’s used: Keeps our application files organized within the container’s filesystem.
- What it does: Sets the working directory inside the container for any subsequent
COPY package*.json ./:- What it does: Copies
package.jsonandpackage-lock.json(if it exists) from your host machine’s current directory (the build context) into the/appdirectory 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 installlayer. Ifpackage.jsondoesn’t change, Docker reuses the cached layer, speeding up subsequent builds.
- What it does: Copies
RUN npm install --omit=dev:- What it does: Executes the
npm installcommand inside the container. - Why it’s used: Installs all the production dependencies listed in
package.json. The--omit=devflag 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.
- What it does: Executes the
COPY . .:- What it does: Copies all remaining files from your current host directory (
., the build context) into the/appdirectory inside the container (.). - Why it’s used: Brings your application source code into the image. This step is placed after
npm installto benefit from build caching if only code changes, not dependencies.
- What it does: Copies all remaining files from your current host directory (
EXPOSE 3000:- What it does: Informs Docker that the container listens on port
3000at 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.
- What it does: Informs Docker that the container listens on port
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-appis the image name, and1.0.0is the tag (version). It’s good practice to tag images with meaningful, version-controlled names..: The build context. This tells Docker to look for theDockerfileand 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.0Command 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 isHOST_PORT:CONTAINER_PORT. Here, we’re mapping host port3000to container port3000. 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.
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.Inspect Running Containers: Open a new terminal window (keep the one running your container open). Use
docker psto see currently running containers.docker psYou should see an entry for
my-web-app:1.0.0, showing itsCONTAINER ID,IMAGE,COMMAND,CREATED,STATUS(e.g.,Up X seconds),PORTS(e.g.,0.0.0.0:3000->3000/tcp), andNAMES. Note thePORTScolumn, which explicitly shows the host-to-container port mapping.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 thedocker pscommand.docker logs <CONTAINER_ID_FROM_DOCKER_PS>You should see the output from your
app.jslikeWeb server listening on port 3000andReceived 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-alpineis 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.
- Why it matters: Smaller images mean faster image pulls, less disk usage, and a reduced attack surface.
- Security (Least Privilege):
- What can go wrong: Our current
Dockerfilerunsnpm installand thenode app.jscommand as therootuser 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
USERinstruction in the Dockerfile. We will explore this in a later chapter to keep this introduction focused.
- What can go wrong: Our current
- 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 --cpusand--memory. When we introduce Docker Compose, we’ll see how to define these limits declaratively.
- Port Exposure:
- Why it matters: We only exposed port
3000using-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.
- Why it matters: We only exposed port
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
3000on 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).
- Cause: Port
- “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 imagesto see a list of all locally available images and verify the name and tag. Re-run thedocker buildcommand 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.jsor theCMDinstruction. - 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.
- Cause: The container starts but immediately exits, or you can’t access it. This often means there’s an error in your
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 buildto create a reusable, versioned image. - Using
docker runto 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.