Skip to content
C Codeloom
Docker

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.

·8 min read · By Yash Kesharwani
Beginner 12 min read

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:

  1. Reads the first instruction to pick a base image.
  2. Runs each subsequent instruction in a temporary container.
  3. Commits the result as a new layer.
  4. Repeats until the file ends.
  5. 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, not localhost. Inside a container, localhost only refers to the container itself. Binding to 0.0.0.0 makes 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.0tag the resulting image with the name docker-demo and the version 1.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 the COPY instructions 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:

  • CMD is the default command, easily overridden. docker run docker-demo:1.0 ls /app will run ls /app instead of node server.js.
  • ENTRYPOINT is the fixed command. Anything passed at docker run is 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, not node: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"], not node server.js. Signals work correctly and you skip an extra shell process.
  • Keep images small. Use Alpine or -slim variants where possible. Add a .dockerignore from 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 Dockerfile is a top-to-bottom script of instructions that produces an image
  • FROM, WORKDIR, COPY, RUN, EXPOSE, and CMD cover the vast majority of real Dockerfiles
  • Ordering instructions from least- to most-frequently-changed gives you a fast incremental build cache
  • .dockerignore keeps unwanted files out of the build context
  • CMD is the easily-overridden default; ENTRYPOINT fixes 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.