Writing a Dockerfile

Docker & DevOps · Lesson 8 of 26 7 min read Updated

So far you have run images that other people built. Now you will build your own. A Dockerfile is a plain text file with step-by-step instructions that tell Docker how to build an image for your app: which base image to start from, which files to copy, which commands to run and how to start the app.

In this lesson you will containerise a small Node.js API for ferry timings in Kochi, build it, run it and change it. The same steps work for PHP, Python, Java or any other language; only the base image and commands change.

The app

The project folder has three files. You do not need Node.js installed on your computer; it will live inside the image.

Text
ferry-api/
├── package.json
├── package-lock.json
└── server.js
JavaScript
// server.js
import express from "express";

const app = express();
const ferries = [
  { from: "Ernakulam", to: "Fort Kochi", departs: "09:30" },
  { from: "Fort Kochi", to: "Vypin", departs: "10:15" },
];

app.get("/ferries", (req, res) => res.json(ferries));
app.get("/health", (req, res) => res.send("ok"));

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Ferry API listening on port ${port}`));

package.json sets "type": "module" and lists express as the only dependency, and package-lock.json pins the exact versions. If you do not have Node.js locally, you can even create the lock file with a container: docker run --rm -v "$PWD":/app -w /app node:26-alpine npm install express.

The Dockerfile

Create a file named exactly Dockerfile (capital D, no extension) in the project folder:

Dockerfile
FROM node:26-alpine

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

ENV NODE_ENV=production PORT=3000
EXPOSE 3000

CMD ["node", "server.js"]

Each line is an instruction, written in capitals by convention, followed by its arguments. Docker runs them from top to bottom:

InstructionWhat it does here
FROM node:26-alpineStart from the official Node.js 26 image on Alpine. Every Dockerfile begins with FROM.
WORKDIR /appCreate the folder /app and make it the current folder for the following instructions and for the running container.
COPY package.json package-lock.json ./Copy the two files from your project into /app.
RUN npm ci --omit=devRun a command during the build: install exactly the locked packages, without development tools.
COPY . .Copy the rest of the project into /app.
ENV NODE_ENV=production PORT=3000Set environment variables that the app sees when it runs.
EXPOSE 3000Document that the app listens on port 3000. It does not publish the port by itself.
CMD ["node", "server.js"]The default command when a container starts.

Why copy package.json first and the code later? Docker can reuse the result of npm ci from earlier builds as long as those two files have not changed, so editing server.js does not reinstall every package. You will learn the details in Layers & the Build Cache.

Building the image

Run docker build in the project folder. -t gives the image a name and tag. The final . is the build context: the folder whose files Docker may copy into the image.

Terminal
docker build -t ferry-api:1.0 .
Output
[+] Building 9.1s (10/10) FINISHED                                          docker:default
 => [internal] load build definition from Dockerfile                                  0.0s
 => => transferring dockerfile: 196B                                                  0.0s
 => [internal] load metadata for docker.io/library/node:26-alpine                     1.8s
 => [internal] load .dockerignore                                                     0.0s
 => => transferring context: 2B                                                       0.0s
 => [1/5] FROM docker.io/library/node:26-alpine@sha256:9a0b4c1d2e3f5a6b7c8d9e0f1a     3.4s
 => [internal] load build context                                                     0.0s
 => => transferring context: 1.94kB                                                   0.0s
 => [2/5] WORKDIR /app                                                                0.2s
 => [3/5] COPY package.json package-lock.json ./                                      0.1s
 => [4/5] RUN npm ci --omit=dev                                                       2.9s
 => [5/5] COPY . .                                                                    0.1s
 => exporting to image                                                                0.6s
 => => exporting layers                                                               0.4s
 => => naming to docker.io/library/ferry-api:1.0                                      0.0s
 => => unpacking to docker.io/library/ferry-api:1.0                                   0.1s

This progress display comes from BuildKit, Docker's build engine. The steps [1/5] to [5/5] match the five instructions that change files: FROM, WORKDIR, two COPY and RUN. ENV, EXPOSE and CMD only change settings, so they do not appear as steps. The last line on the right, docker:default, is the name of the builder; Docker Desktop shows docker:desktop-linux.

If a RUN command fails, the build stops and shows the command's error output. Fix the Dockerfile and build again.

Running your image

Your image is now in the local image list, and you run it like any other:

Terminal
docker run -d --name ferry -p 3000:3000 ferry-api:1.0
docker logs ferry
curl http://localhost:3000/ferries
Output
Ferry API listening on port 3000
[{"from":"Ernakulam","to":"Fort Kochi","departs":"09:30"},{"from":"Fort Kochi","to":"Vypin","departs":"10:15"}]

On Windows PowerShell, use curl.exe instead of curl, or simply open the address in a browser.

Changing the app and rebuilding

Images never change after they are built. To ship a change, you edit the code, build a new image with a new tag, and replace the container:

Terminal
docker build -t ferry-api:1.1 .
docker rm -f ferry
docker run -d --name ferry -p 3000:3000 ferry-api:1.1

You can give one image several tags at build time, such as -t ferry-api:1.1 -t ferry-api:latest, or add a tag later with docker tag ferry-api:1.1 ferry-api:stable. Keeping old tags means you can go back to 1.0 in seconds if 1.1 has a bug.

Common mistakes

  • Naming the file dockerfile.txt or DockerFile. Docker looks for Dockerfile by default. Use -f to point at another name, for example docker build -f Dockerfile.dev ..
  • Forgetting the dot. docker build -t ferry-api:1.0 without a context path fails with "requires 1 argument".
  • Expecting EXPOSE to open a port. It is documentation. You still need -p when you run the container.
  • Using RUN to start the app. RUN happens once during the build. The command that runs when the container starts belongs in CMD.
  • Installing packages with npm install in the image. npm ci installs exactly what the lock file says, which makes builds repeatable.

Key points

  • A Dockerfile lists instructions that Docker runs from top to bottom to build an image.
  • FROM picks the base image; WORKDIR sets the folder; COPY adds files; RUN runs build-time commands.
  • ENV sets variables, EXPOSE documents ports and CMD sets the default start command.
  • docker build -t name:tag . builds the image using the current folder as the build context.
  • To change an app, rebuild with a new tag and replace the container; images themselves never change.

Exercise

Rohan has a simple PHP site (one index.php that prints today's date) in a folder called rohan-site. Write a Dockerfile that uses the official php:8.5-apache image, copies the site into Apache's web folder /var/www/html, and documents port 80. Then write the commands to build it as rohan-site:1.0 and run it on host port 8085.

Show solution
Dockerfile
FROM php:8.5-apache
COPY . /var/www/html/
EXPOSE 80
Terminal
docker build -t rohan-site:1.0 .
docker run -d --name rohan-site -p 8085:80 rohan-site:1.0

The php:8.5-apache image already contains Apache with PHP and a default command that starts Apache, so no CMD is needed: your image inherits it from the base image.

Quick Quiz

1. When does a RUN instruction execute?

2. What does the final . in docker build -t ferry-api:1.0 . mean?

3. Does EXPOSE 3000 make the app reachable from your browser?

4. Why does the Dockerfile copy package.json before the rest of the code?

Learn Docker & DevOps with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call