A few days ago, I started learning Docker.
Like many beginners, I started with the commands:
docker build
docker run
docker ps
docker images
But after seeing a few commands, I realized something.
I could memorize them.
But I didn't really understand what was happening behind them.
What exactly is a Docker image?
What is a container?
Why do we need a Dockerfile?
What does docker run actually do?
And why does -p 3000:3000 suddenly make my application accessible?
So instead of learning Docker as a list of commands, I decided to follow one simple application and understand what happens at each step.
And that's the story I want to share here.
It Works on My Machine
Imagine you're working on a Node.js application.
You clone the project, install the dependencies, and start it:
npm install
npm start
Everything works.
Your application needs:
Node.js
npm
Dependencies
Application code
Configuration
Then someone else clones the same project.
They run:
npm install
npm start
And suddenly...
It doesn't work.
Maybe they have a different Node.js version.
Maybe a dependency behaves differently.
Maybe some configuration is missing.
The code is the same.
But the environment is different.
That's where Docker starts becoming interesting.
Instead of asking every developer or server to manually recreate the environment, we can describe how our application should be packaged.
So, What Does Docker Actually Give Us?
At first, Docker looked like a collection of commands to me.
But the basic idea is actually simple:
Application
+
Dependencies
+
Required environment
↓
Docker Image
↓
Container
↓
Running Application
And this introduced three words I needed to understand:
Dockerfile. Image. Container.
Let's build our way through them.
First: How Do We Tell Docker What We Need?
Our application needs Node.js.
It needs dependencies.
It needs our source code.
It needs to know what command to run.
How do we describe all of that?
We create a file called:
Dockerfile
Think of the Dockerfile as a set of instructions for building our application's environment.
For our simple Node.js application:
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
At first, this looked like a lot of new syntax.
But once I understood what each line was doing, it became much easier.
Reading Our First Dockerfile
Let's go through it.
Start with Node.js
FROM node:22
Our application needs Node.js.
Instead of installing Node.js manually, we start from an existing Node.js image.
So we're saying:
Give me an environment that already has Node.js 22.
Create a working directory
WORKDIR /app
This sets /app as the working directory inside the image.
Our application will live there.
Copy the package files
COPY package*.json ./
We copy our package files into the image.
Install dependencies
RUN npm install
Docker runs npm install while building the image.
That's important:
docker build
↓
RUN npm install
RUN happens during the image build.
Copy our application
COPY . .
Now we copy the rest of our source code into the image.
Tell Docker about the application port
EXPOSE 3000
Our Node.js application listens on port 3000.
This tells Docker that the application uses that port inside the container.
It does not yet make the application accessible from our computer.
We'll get to that.
Tell Docker how to start the application
CMD ["npm", "start"]
When the container starts, Docker will run:
npm start
So the application can start.
We Have Instructions. But We Don't Have an Application Running Yet.
This was one of the first things that confused me.
We have a Dockerfile.
But a Dockerfile isn't our running application.
It's just instructions.
So we need to use those instructions to create something.
That's where the Docker image comes in.
Building the Image
We run:
docker build -t my-app .
Docker reads the Dockerfile and builds an image.
The flow is:
Dockerfile
│
│ docker build
↓
Docker Image
The -t my-app gives our image a name.
And that final .?
It tells Docker:
Use the current directory as the build context.
This small . is easy to overlook when you're starting.
Now we can check our images:
docker images
We should see our:
my-app
At this point, we have an image.
But our application still isn't running.
Why?
Because an image isn't a running application.
From Image to Container
Now we finally use:
docker run my-app
Docker uses our image to create a container and starts the application.
The flow is:
Dockerfile
↓
docker build
↓
Image
↓
docker run
↓
Container
↓
npm start
↓
Application
This was the mental model that made Docker much easier for me:
Dockerfile → Image → Container
A useful analogy is:
Dockerfile = Recipe
Image = Prepared package / blueprint
Container = Running instance
And one image can create multiple containers:
my-app Image
/ | \
↓ ↓ ↓
Container Container Container
The image is the reusable package.
The containers are the instances created from it.
The Application Is Running... So Why Can't I Open It?
Now comes another small surprise.
Our Node.js application is running inside the container.
It's listening on:
3000
But our browser is outside the container.
So if I simply run:
docker run my-app
I can't necessarily access it through:
localhost:3000
We need to connect the port on our computer to the port inside the container.
That's what this does:
docker run -p 3000:3000 my-app
The format is:
-p HOST_PORT:CONTAINER_PORT
So:
-p 3000:3000
means:
Your Computer
localhost:3000
│
↓
Container
port 3000
│
↓
Node.js Application
Now:
http://localhost:3000
should show:
Hello from Docker!
That was one of those Docker commands that makes much more sense once you understand why it exists.
But What About EXPOSE 3000?
At first, I also wondered:
If we already wrote
EXPOSE 3000, why do we need-p 3000:3000?
They're different.
EXPOSE 3000
means:
The application uses port 3000 inside the container.
Whereas:
-p 3000:3000
means:
Connect port 3000 on my computer to port 3000 inside the container.
So:
EXPOSE
↓
Application uses port 3000
-p
↓
Connect host port → container port
That distinction is important when you're starting with Docker.
What Actually Happens When We Run docker run?
When we run:
docker run -p 3000:3000 my-app
Docker is doing more than simply starting our Node.js application.
Conceptually:
Docker Image
↓
Create Container
↓
Set up container filesystem
↓
Set up networking
↓
Map port 3000
↓
Start container process
↓
npm start
↓
Application runs
We don't need to understand every internal Docker component yet.
For now, understanding this flow is enough.
Then the Container Stops...
Now imagine the application crashes.
Or we stop the container.
We can check running containers:
docker ps
But what if the container already stopped?
Use:
docker ps -a
And then:
docker logs <container-id>
This became one of the first useful debugging habits for me:
Something isn't working
↓
docker ps -a
↓
Find the container
↓
docker logs
↓
Understand what happened
Maybe the application crashed.
Maybe a dependency is missing.
Maybe an environment variable isn't configured.
Docker doesn't automatically fix application problems.
It gives us a consistent environment to run the application.
One More Thing I Learned: Docker Images Have Layers
Look at these lines again:
COPY package*.json ./
RUN npm install
COPY . .
Why do we copy the package files separately?
One reason is Docker's image layering and build cache.
Docker builds images in layers.
If we change our source code but don't change package.json, Docker may be able to reuse the layer where the dependencies were installed.
Conceptually:
Package files
↓
Install dependencies
↓
Application source
So the next build doesn't necessarily have to redo everything from scratch.
This becomes increasingly useful as applications become larger.
And There Is a Small File Called .dockerignore
Our project might contain:
node_modules
.git
.env
coverage
We don't necessarily want all of those sent as part of the Docker build context.
So we can create:
.dockerignore
and add:
node_modules
.git
.env
coverage
It's a small thing, but it's useful to know early.
So What Have We Actually Learned?
Let's stop for a moment.
We started with:
"It works on my machine."
Then we needed a consistent way to describe our application's environment.
That led us to:
Dockerfile
We used the Dockerfile to create:
Image
We used the image to create:
Container
The container runs our application.
Then we needed our browser to reach the application.
That introduced:
Port Mapping
Then we needed to understand what happened when something went wrong.
That introduced:
docker ps
docker ps -a
docker logs
And we learned that images are built in layers and can use caching.
So our journey currently looks like:
"It works on my machine."
↓
Need a consistent environment
↓
Dockerfile
↓
docker build
↓
Image
↓
docker run
↓
Container
↓
Port Mapping
↓
Running Application
And this is already enough to understand the core Docker workflow.
But Our Application Will Eventually Grow
Right now, we're running a simple Node.js application.
Real applications are rarely this simple.
Eventually, our application might need:
Backend
Database
Cache
Other Services
And that's where Docker introduces more concepts.
For example:
Our application needs persistent data
We'll need to understand Docker Volumes.
Multiple containers need to communicate
We'll need to understand Docker Networking.
We have multiple containers to manage
We'll need to understand Docker Compose.
We want to build smaller production images
We'll need to understand Multi-stage Builds.
We want automated deployments
We'll need to understand Docker + CI/CD.
But I don't think we need to learn all of these at once.
For me, the important first step was understanding:
How does my application become an image, and how does that image become a running container?
Where Does the Image Go When We Want to Share It?
Our image currently exists on our computer.
But imagine we want to deploy the application somewhere else.
We don't want to manually rebuild the image on every machine.
Instead, we can store the image in a container registry.
The flow becomes:
My Computer
↓
Docker Image
↓
push
↓
Container Registry
↓
pull
↓
Another Environment
↓
Docker Image
↓
Container
For example, Docker Hub is a container registry.
We can download an existing image using:
docker pull nginx
And then run it:
docker run nginx
So images can be:
Built locally
OR
Pulled from a registry
Either way, we eventually use the image to create a container.
And This Is Where Docker Connects to Deployment
Now the whole idea starts to make sense.
We have:
Application
↓
Dockerfile
↓
Docker Image
That image can be stored in a registry:
Docker Image
↓
Container Registry
And a server or cloud platform can use that image:
Container Registry
↓
Docker Image
↓
Container
↓
Application
So a simplified real-world workflow looks like:
Developer
↓
Code
↓
Dockerfile
↓
Docker Image
↓
Container Registry
↓
Production Environment
↓
Container
↓
Application
This is one of the reasons Docker is so useful in modern development and deployment workflows.
The Mental Model I Finally Have
After going through this example, Docker doesn't feel like a list of random commands anymore.
I can now think about it as a journey:
Application
│
↓
Dockerfile
│
docker build
↓
Docker Image
│
docker run
↓
Docker Container
│
↓
Application
And when I need to share it:
Docker Image
↓
docker push
↓
Container Registry
↓
docker pull
↓
Docker Image
↓
Container
That's the foundation.
What I'm Learning Next
I'm intentionally stopping here.
I haven't learned every Docker concept yet, and I don't think beginners need to learn everything in one sitting.
My next topics are:
Docker Volumes
↓
Docker Networking
↓
Docker Compose
↓
Multi-stage Builds
↓
Docker Security
↓
CI/CD with Docker
↓
Cloud Deployment
Each of those solves a different problem that appears as our application becomes more complex.
And that's probably the biggest thing I learned from starting Docker:
Don't just memorize the command. Understand why the command exists.
Once the reason is clear, the command becomes much easier to remember.
Final Takeaway
If you're just starting Docker, don't worry about memorizing everything.
Start with these three relationships:
Dockerfile
↓
Image
↓
Container
And understand what happens around them:
Dockerfile
↓
docker build
↓
Image
↓
docker run
↓
Container
↓
Port Mapping
↓
Running Application
That's where I would start.
Docker becomes much easier when you stop looking at it as a collection of commands and start seeing it as a journey your application goes through.
Top comments (0)