Writing Your First Dockerfile
Learn how to write a Dockerfile from scratch. Understand each instruction, build a real image for a small Node application, and run it as a container.
What you'll learn
- ✓What a Dockerfile is and how Docker reads it
- ✓The role of FROM, WORKDIR, COPY, RUN, EXPOSE, and CMD
- ✓How to build an image and tag it sensibly
- ✓How to use a .dockerignore file to keep images lean
- ✓The difference between CMD and ENTRYPOINT
Prerequisites
- •Docker installed and working — see Install Docker
- •Comfort running commands in a terminal
In the previous post you ran containers built from images on Docker Hub. This post is about building your own image. The recipe lives in a plain-text file called a Dockerfile, and once you understand the half-dozen instructions that appear in nearly every one, you can package almost any application.
What a Dockerfile is
A Dockerfile is a script of instructions that tells Docker how to construct an image. Each instruction creates a layer — a small, cached delta on top of the previous layer. The final image is the stack of all the layers, and any container started from it begins life with that exact filesystem.
The Dockerfile is processed top to bottom by docker build, which:
- Reads the first instruction to pick a base image.
- Runs each subsequent instruction in a temporary container.
- Commits the result as a new layer.
- Repeats until the file ends.
- Tags the final image with the name you choose.
We will now build a real one.
A small Node application
Create a folder called docker-demo and put two files in it.
package.json
{
"name": "docker-demo",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.19.2"
}
}
server.js
const express = require('express');
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.send('Hello from inside a container!');
});
app.listen(port, '0.0.0.0', () => {
console.log(`Listening on http://0.0.0.0:${port}`);
});
This is a minimal Express web server that responds to GET / with a short message. We are using Node only as a familiar example — the same Dockerfile shape works for Python, Go, Ruby, and most other languages with small adjustments.
Note. Notice that the server binds to
0.0.0.0, notlocalhost. Inside a container,localhostonly refers to the container itself. Binding to0.0.0.0makes the server reachable from outside the container, which is what we want.
Writing the Dockerfile
In the same folder, create a file called Dockerfile (no extension, capital D). Type this in:
# 1. Start from an official Node image
FROM node:20-alpine
# 2. Set the working directory inside the image
WORKDIR /app
# 3. Copy dependency manifests first (for caching)
COPY package*.json ./
# 4. Install dependencies
RUN npm install --omit=dev
# 5. Copy the rest of the application source
COPY . .
# 6. Document the port the app listens on
EXPOSE 3000
# 7. Define the default command
CMD ["node", "server.js"]
Each instruction does something specific. Let us walk through them.
FROM
FROM node:20-alpine
Every Dockerfile begins with FROM. It picks the base image — the starting filesystem your own layers will be built on top of. node:20-alpine means “the official Node.js image, version 20, built on top of Alpine Linux.” Alpine is a small Linux distribution; the resulting image is a fraction of the size of a Debian-based equivalent.
Pinning a specific major version (20, not latest) is good practice. It keeps future builds reproducible.
WORKDIR
WORKDIR /app
Sets the working directory inside the image. Every subsequent COPY, RUN, and CMD runs relative to this path. If /app does not exist, Docker creates it. Without WORKDIR, your commands run in /, which is messy.
COPY (manifests first)
COPY package*.json ./
Copies package.json and package-lock.json from the host into /app inside the image. Doing this before copying the rest of the code is a deliberate caching trick — explained in the next section.
RUN
RUN npm install --omit=dev
Executes a command during the build. The result is committed as a new layer. Here we install production dependencies. --omit=dev skips devDependencies like test runners, which the running container does not need.
COPY (source)
COPY . .
Copies the rest of the working directory into /app. The reason we copied package*.json separately is that changes to source code are far more frequent than changes to dependencies. By copying the manifests first and running npm install before the source copy, the dependency layer stays cached across edits — builds become much faster.
EXPOSE
EXPOSE 3000
Documents that the container listens on port 3000. This does not actually publish the port — -p 3000:3000 at docker run does. EXPOSE is metadata for tools and humans.
CMD
CMD ["node", "server.js"]
Defines the default command that runs when a container starts from this image. The exec-form (a JSON array) is preferred — it runs the binary directly without invoking a shell, which is faster and signals like Ctrl+C are propagated correctly.
Building the image
From inside the docker-demo folder, run:
docker build -t docker-demo:1.0 .
The pieces:
docker build— invoke the builder.-t docker-demo:1.0— tag the resulting image with the namedocker-demoand the version1.0. Without a tag, the image only gets a hash, which is awkward to reference..— the build context. The dot means “the current directory.” Docker sends the contents of this folder to the builder so theCOPYinstructions can find files.
Watch the output. Docker prints each step ([1/6] FROM ..., [2/6] WORKDIR ...) and tells you whether each layer was built or reused from cache. The first build takes a minute; later builds, with only source changes, finish in seconds.
When it completes, check:
docker images docker-demo
You should see your new image listed with a size somewhere between 100 and 200 MB.
Running the image
docker run -d -p 3000:3000 --name demo docker-demo:1.0
Visit http://localhost:3000. You should see “Hello from inside a container!”.
docker logs demo
# Listening on http://0.0.0.0:3000
To stop and clean up:
docker stop demo
docker rm demo
Try it yourself. Change the message in server.js to something new. Save the file and run docker build -t docker-demo:1.1 . again. Notice how Docker reuses the cached npm install layer — only the source copy and later steps rebuild. Run the new tag with docker run -d -p 3000:3000 --name demo docker-demo:1.1 and verify the new message.
The .dockerignore file
You almost certainly do not want node_modules, .git, log files, or local environment files inside your image. They bloat the image, slow down builds, and may leak secrets.
Create a file called .dockerignore next to your Dockerfile:
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
README.md
*.md
dist
.DS_Store
.dockerignore works just like .gitignore: anything matching a pattern is excluded from the build context Docker sends to the builder. This makes COPY . . safe and keeps your images small.
CMD vs ENTRYPOINT
You will see both CMD and ENTRYPOINT in Dockerfiles. The distinction:
CMDis the default command, easily overridden.docker run docker-demo:1.0 ls /appwill runls /appinstead ofnode server.js.ENTRYPOINTis the fixed command. Anything passed atdocker runis appended as arguments to it.
A common pattern combines them:
ENTRYPOINT ["node"]
CMD ["server.js"]
By default the container runs node server.js. But docker run myimg --version would run node --version, because --version becomes the argument to node. For most beginner cases, plain CMD is enough.
Try it yourself. From the image you built earlier, override the default command at run time:
docker run --rm docker-demo:1.0 node -e "console.log(2 + 2)"The --rm flag deletes the container automatically when it exits, which is handy for one-off commands. You should see 4 printed.
A few good habits
- Pin base image versions.
node:20-alpine, notnode:latest. Reproducible builds matter. - Order instructions from least to most frequently changed. Manifests, then
RUN npm install, then source code. This maximises layer cache hits. - Use exec-form for CMD and ENTRYPOINT.
["node", "server.js"], notnode server.js. Signals work correctly and you skip an extra shell process. - Keep images small. Use Alpine or
-slimvariants where possible. Add a.dockerignorefrom day one. - Do not bake secrets into images. Use environment variables at run time, or proper secrets management. Anything copied into the image is visible to anyone who can pull it.
Recap
You now know:
- A
Dockerfileis a top-to-bottom script of instructions that produces an image FROM,WORKDIR,COPY,RUN,EXPOSE, andCMDcover the vast majority of real Dockerfiles- Ordering instructions from least- to most-frequently-changed gives you a fast incremental build cache
.dockerignorekeeps unwanted files out of the build contextCMDis the easily-overridden default;ENTRYPOINTfixes the binary and treats arguments as parameters
Next steps
A single container is rarely the whole story. Real applications usually involve at least a web service and a database, often more. The next post introduces Docker Compose — the declarative tool for defining and running multi-container applications with one command.
→ Next: Docker Compose for Multi-Container Apps
Questions or feedback? Email codeloomdevv@gmail.com.