Introduction
Containers provide a consistent way to package applications together with the dependencies and configuration required to run them.
A Dockerfile defines how that container image is built.
In this project, a Node.js application was containerized using a custom Dockerfile, with the image built, tested, inspected, modified, and rebuilt throughout the implementation.
The project demonstrates how Dockerfile instructions influence the resulting image and, ultimately, the behavior of the running container.
What Is a Dockerfile?
A Dockerfile is a text file containing a sequence of instructions Docker uses to build an image.
A simplified workflow looks like this:
Application Source Code
│
▼
Dockerfile
│
│ docker build
▼
Docker Image
│
│ docker run
▼
Docker Container
│
▼
Running Application
The Dockerfile describes the environment and instructions required to turn application source code into a runnable container image.
Dockerfile vs Docker Image vs Container
These three concepts are closely related but serve different purposes.
Dockerfile
The Dockerfile is the build blueprint.
It defines instructions such as:
FROM
WORKDIR
COPY
RUN
EXPOSE
CMD
Docker Image
The image is the built artifact produced from the Dockerfile.
It contains the application, dependencies, filesystem layers, and configuration required to create a container.
Docker Container
The container is a running instance of an image.
In simple terms:
Dockerfile
│
│ docker build
▼
Docker Image
│
│ docker run
▼
Docker Container
Project Overview
The application used for this project is a simple Node.js web application.
The containerized application consists of:
- A Node.js application
- A
server.jsentry point - A
package.jsonfile - Installed Node.js dependencies
- A Dockerfile defining the image build process
The Dockerfile is responsible for packaging these components into a reproducible container image.
Project Structure
Dockerfiles-lab/
├── screenshots/
├── .gitignore
├── Dockerfile
├── package.json
├── package-lock.json
├── README.md
└── server.js
The repository also contains implementation screenshots documenting the different stages of the Dockerfile and container lifecycle.
The complete implementation is available in the GitHub repository linked at the end of this article.
The Node.js Application
Before building the container, there needs to be an application for Docker to package.
The application entry point is:
server.js
A simplified Node.js server can look like:
const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello from Docker!");
});
server.listen(3000, () => {
console.log("Server running on port 3000");
});
The application listens on port 3000.
This gives Docker something concrete to package and run inside the container.
Why package.json Matters
The project also contains:
package.json
For Node.js applications, package.json provides project metadata and dependency information.
It can also define scripts used to start or manage the application.
For example:
{
"scripts": {
"start": "node server.js"
}
}
The associated lock file:
package-lock.json
records the resolved dependency versions.
Together, these files make the Node.js application's dependencies and runtime configuration reproducible.
Building the First Docker Image
The first stage of the implementation involved building a Docker image from the Dockerfile.
The basic command is:
docker build -t docker-node-app:1.0 .
The components of the command are:
docker build
│
├── -t docker-node-app:1.0
│ └── image name and tag
│
└── .
└── current directory as build context
The . tells Docker to use the current directory as the build context.
This makes files in the project directory available to Docker during the build, subject to .dockerignore rules where applicable.
Understanding the Dockerfile
A Dockerfile is processed from top to bottom.
A typical Node.js Dockerfile can contain instructions such as:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Each instruction has a specific purpose.
FROM: Choosing the Base Image
FROM node:20-alpine
FROM specifies the base image from which the new image is built.
For a Node.js application, using a Node.js base image provides the Node.js runtime and supporting environment required to execute the application.
The alpine variant is based on Alpine Linux and is commonly used when a smaller image footprint is desirable.
The base image is therefore the starting point for the container environment.
WORKDIR: Setting the Working Directory
WORKDIR /app
WORKDIR establishes the working directory for subsequent Dockerfile instructions.
Instead of placing application files throughout the container filesystem, the application can be organized under:
/app
It also determines the default working directory when the container starts unless another directory is explicitly configured.
COPY: Adding Application Files
The application files need to be transferred from the build context into the image.
For example:
COPY package*.json ./
This copies the Node.js package definition files into the working directory.
Later:
COPY . .
copies the remaining application files into the image.
This separation is useful because dependency installation and application source code can be handled as separate build layers.
RUN: Installing Dependencies
RUN npm install
RUN executes a command while the image is being built.
Here, npm installs the dependencies defined by the Node.js project.
The resulting files become part of the image layer produced by this instruction.
This is different from CMD.
RUN happens during image build time.
EXPOSE: Documenting the Application Port
EXPOSE 3000
The Node.js application listens on port 3000, so the Dockerfile declares:
EXPOSE 3000
It is important to understand that EXPOSE does not publish the port to the host by itself.
It documents the port the application expects to use inside the container.
Host-to-container port publishing is configured when the container is started.
For example:
docker run -d -p 3000:3000 --name docker-node-container docker-node-app:1.0
The mapping is:
Host Container
3000 ───────► 3000
CMD: Defining the Default Container Command
One of the most important Dockerfile instructions in this project was:
CMD ["node", "server.js"]
CMD defines the default command executed when a container is started from the image.
In this case, Docker starts:
node server.js
inside the container.
This is what launches the Node.js application.
Why CMD Matters
During the implementation, the application command was changed between:
CMD ["node", "server.js"]
and:
CMD ["node", "app.js"]
This demonstrated an important container behavior.
If the file referenced by CMD does not exist in the image, the container cannot successfully start the application.
This can result in a container that starts and immediately exits.
For example, if the Dockerfile contains:
CMD ["node", "app.js"]
but the image does not contain:
/app/app.js
Node.js cannot execute the requested entry point.
This is why examining container logs is an important troubleshooting step.
Building After a Dockerfile Change
A Docker image is a built artifact.
Changing the Dockerfile does not automatically modify an existing image.
After making changes, the image must be rebuilt:
docker build -t docker-node-app:2.0 .
This creates a new image version containing the updated Dockerfile instructions.
The general workflow is:
Modify Dockerfile
│
▼
docker build
│
▼
New Image
│
▼
docker run
│
▼
Test Application
This build-test cycle was repeated during the project to validate changes to the container configuration.
Inspecting Docker Images
After building an image, available images can be inspected with:
docker images
This provides information such as:
- Repository
- Tag
- Image ID
- Creation time
- Image size
For example:
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-node-app 1.0 abc123... ... ...
Tags such as:
1.0
2.0
final
can be used to distinguish different image versions during development and testing.
Running the Container
Once the image has been built, it can be started with:
docker run -d -p 3000:3000 \
--name docker-node-container \
docker-node-app:final
The important options are:
| Option | Purpose |
|---|---|
-d |
Runs the container in detached mode |
-p 3000:3000 |
Maps host port 3000 to container port 3000 |
--name |
Assigns a name to the container |
docker-node-app:final |
Specifies the image to run |
The application can then be accessed from the host through:
http://localhost:3000
Verifying a Running Container
After starting the container, its status can be checked with:
docker ps
This displays currently running containers.
Typical information includes:
- Container ID
- Image
- Command
- Creation time
- Status
- Port mappings
- Container name
The port mapping should show that host port 3000 is forwarded to port 3000 inside the container.
Testing the Application
Once the container is running, the application can be accessed through a browser:
http://localhost:3000
At this point, the request flow is:
Browser
│
│ localhost:3000
▼
Host Port 3000
│
│ Docker port mapping
▼
Container Port 3000
│
▼
Node.js
│
▼
server.js
│
▼
HTTP Response
This demonstrates the relationship between the host, container networking, and the application process.
Troubleshooting a Failed Container
Not every container starts successfully on the first attempt.
During the implementation, an invalid application entry point was used to demonstrate container troubleshooting.
A failed run can be identified using:
docker ps -a
Unlike docker ps, the -a option also displays stopped containers.
This is particularly useful when a container starts and then exits immediately.
Using Docker Logs for Troubleshooting
When a container fails, logs are often the fastest way to determine why.
The logs can be viewed with:
docker logs docker-node-container
For example, if the Dockerfile attempts to execute a file that does not exist:
CMD ["node", "app.js"]
Node.js may report that it cannot find the specified module.
The important troubleshooting pattern is:
Container exits
│
▼
docker ps -a
│
▼
Identify exited container
│
▼
docker logs <container>
│
▼
Identify application/configuration error
│
▼
Fix Dockerfile or application
│
▼
Rebuild image
│
▼
Run container again
This workflow is useful well beyond simple Docker labs.
Stopping and Removing a Container
Once a container is no longer needed, it can be stopped and removed with:
docker stop docker-node-container && docker rm docker-node-container
The first command:
docker stop docker-node-container
stops the running container.
The second:
docker rm docker-node-container
removes the stopped container.
The && operator ensures the second command runs only if the first command succeeds.
Rebuilding and Retesting
After correcting the Dockerfile, the image was rebuilt and the container started again.
A typical sequence is:
docker build -t docker-node-app:final .
followed by:
docker run -d -p 3000:3000 \
--name docker-node-container \
docker-node-app:final
Then:
docker ps
and finally:
http://localhost:3000
can be used to verify the result.
This build → run → inspect → troubleshoot → rebuild cycle is fundamental to working effectively with Dockerfiles.
Changing Application Output
The application response was also modified during testing.
For example:
res.end("Welcome to my Docker Node.js application!");
After changing application source code, the image must be rebuilt if the source code is copied into the image during the build:
docker build -t docker-node-app:final .
The updated image then needs to be used when starting a new container.
This demonstrates an important principle:
A running container does not automatically become a new image when the source code or Dockerfile changes.
The image must be rebuilt when the application contents packaged into it need to change.
Docker Build Cache
Docker builds are made up of layers.
For example:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Each instruction can contribute to the image's layer structure.
Docker can reuse unchanged layers during subsequent builds, which can significantly improve build performance.
This is one reason Dockerfiles are commonly structured to copy dependency definition files before copying the rest of the application source:
COPY package*.json ./
RUN npm install
COPY . .
If only application source code changes, Docker may be able to reuse the dependency installation layer.
Dockerfile Best Practices
A production-oriented Dockerfile should be designed with efficiency, security, and reproducibility in mind.
1. Use an appropriate base image
Choose a maintained base image that provides only what the application needs.
For example:
FROM node:20-alpine
A smaller base image can reduce the image footprint and attack surface, although compatibility should always be considered.
2. Keep the image focused
Only copy files required by the application.
A .dockerignore file can prevent unnecessary files from being included in the build context.
Example:
node_modules
.git
.env
npm-debug.log
3. Take advantage of layer caching
Copy dependency files before application source code:
COPY package*.json ./
RUN npm install
COPY . .
This can prevent unnecessary dependency reinstallation when only source files change.
4. Avoid embedding secrets
Do not place passwords, API keys, tokens, or other sensitive values directly into a Dockerfile.
Use appropriate runtime configuration and secrets-management mechanisms instead.
5. Run as a non-root user
Where appropriate, configure the container to run the application as a non-root user.
This reduces the impact of a potential application compromise.
6. Use explicit image tags
Rather than relying blindly on:
FROM node:latest
consider using an explicit version:
FROM node:20-alpine
This makes builds more predictable and reproducible.
Dockerfile Instructions at a Glance
| Instruction | Purpose |
|---|---|
FROM |
Defines the base image |
WORKDIR |
Sets the working directory |
COPY |
Copies files into the image |
RUN |
Executes commands during image build |
EXPOSE |
Documents a container port |
CMD |
Defines the default runtime command |
A useful distinction is:
RUN
└── Build time
CMD
└── Container runtime
Understanding this difference is essential when diagnosing why a container behaves differently from the image build process.
Dockerfile Workflow
The complete workflow demonstrated in this project can be summarized as:
Application Source
│
▼
Dockerfile
│
│ docker build
▼
Docker Image
│
│ docker images
▼
Image Inspection
│
│ docker run
▼
Docker Container
│
├── docker ps
│
├── Browser Test
│
└── docker logs
│
▼
Troubleshooting
│
▼
Modify Application
│
▼
Rebuild Image
│
▼
Run & Test Again
This workflow provides a repeatable approach to packaging and validating containerized applications.
Dockerfile vs Docker Compose
Dockerfiles and Docker Compose solve different problems, but they complement each other.
| Dockerfile | Docker Compose |
|---|---|
| Defines how an image is built | Defines how multiple services run together |
| Creates a custom image | Orchestrates application services |
| Focuses on image construction | Focuses on service configuration |
| Uses Dockerfile instructions | Uses YAML configuration |
docker build |
docker compose up |
docker run starts the resulting image |
Compose manages the defined application stack |
A Dockerfile might create the image for an application:
Dockerfile
│
▼
Application Image
Docker Compose can then use that image as part of a larger application:
Docker Compose
│
┌──────────┴──────────┐
▼ ▼
Application Database
Container Container
Understanding both technologies provides a stronger foundation for building and operating containerized applications.
Key Docker Commands
The primary commands used throughout the project include:
Build an image
docker build -t docker-node-app:final .
List images
docker images
Run a container
docker run -d -p 3000:3000 \
--name docker-node-container \
docker-node-app:final
List running containers
docker ps
List all containers
docker ps -a
View container logs
docker logs docker-node-container
Stop and remove a container
docker stop docker-node-container && docker rm docker-node-container
Skills Demonstrated
This implementation demonstrates practical experience with:
- Dockerfile authoring
- Docker image creation
- Docker image tagging
- Docker build context
- Docker build layers and caching
- Node.js containerization
- Base image selection
- Working directory configuration
- Application file management with
COPY - Dependency installation with
RUN - Container port configuration
- Runtime commands with
CMD - Docker container lifecycle management
- Port mapping
- Container inspection
- Container log analysis
- Troubleshooting failed containers
- Image rebuild and validation workflows
- Linux command-line operations
- Git and GitHub
- Technical documentation
Conclusion
A Dockerfile provides the foundation for turning application source code into a portable, reproducible container image.
By defining the base runtime, working directory, application files, dependencies, exposed port, and startup command, the Dockerfile establishes how the application is packaged and executed.
The implementation also demonstrates that containerization is more than simply building an image. A reliable workflow includes:
Build
↓
Run
↓
Inspect
↓
Test
↓
Troubleshoot
↓
Rebuild
↓
Validate
These concepts form a practical foundation for container-based application development and provide the building blocks for more advanced workflows involving Docker Compose, CI/CD pipelines, container registries, orchestration platforms, and cloud-native deployments.
Project Repository
The complete implementation, Dockerfile iterations, application files, and supporting screenshots are available on GitHub:
https://github.com/rahimahisah17/dockerfiles-lab
The repository provides the complete implementation and evidence of the build, run, troubleshooting, and validation process documented in this article.
Related Work
This Dockerfile implementation pairs naturally with a multi-container deployment workflow using Docker Compose.
Docker Compose can be used to take individually containerized applications and combine them with supporting services such as databases, caches, and other dependencies into a complete application stack.
Top comments (3)
A Dockerfile may look simple, but understanding how each instruction affects the final image and container behavior makes a huge difference. Which Dockerfile instruction do you find most important in your workflow?
The build → run → inspect → troubleshoot → rebuild cycle is a practical part of working with containers.
Dockerfiles and Docker Compose solve different problems but work extremely well together. How are you currently using Dockerfiles in your development or deployment workflows?