Writing a Dockerfile
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.
ferry-api/
├── package.json
├── package-lock.json
└── server.js// 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:
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:
| Instruction | What it does here |
|---|---|
FROM node:26-alpine | Start from the official Node.js 26 image on Alpine. Every Dockerfile begins with FROM. |
WORKDIR /app | Create 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=dev | Run 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=3000 | Set environment variables that the app sees when it runs. |
EXPOSE 3000 | Document 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.
docker build -t ferry-api:1.0 .[+] 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:
docker run -d --name ferry -p 3000:3000 ferry-api:1.0
docker logs ferry
curl http://localhost:3000/ferriesFerry 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:
docker build -t ferry-api:1.1 .
docker rm -f ferry
docker run -d --name ferry -p 3000:3000 ferry-api:1.1You 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.txtorDockerFile. Docker looks forDockerfileby default. Use-fto point at another name, for exampledocker build -f Dockerfile.dev .. - Forgetting the dot.
docker build -t ferry-api:1.0without a context path fails with "requires 1 argument". - Expecting
EXPOSEto open a port. It is documentation. You still need-pwhen you run the container. - Using
RUNto start the app.RUNhappens once during the build. The command that runs when the container starts belongs inCMD. - Installing packages with
npm installin the image.npm ciinstalls 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.
FROMpicks the base image;WORKDIRsets the folder;COPYadds files;RUNruns build-time commands.ENVsets variables,EXPOSEdocuments ports andCMDsets 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
FROM php:8.5-apache
COPY . /var/www/html/
EXPOSE 80docker build -t rohan-site:1.0 .
docker run -d --name rohan-site -p 8085:80 rohan-site:1.0The 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?
RUN changes the image during the build; CMD runs when a container starts.
2. What does the final . in docker build -t ferry-api:1.0 . mean?
The build context is the set of files Docker can copy into the image.
3. Does EXPOSE 3000 make the app reachable from your browser?
Publishing a port is a run-time decision made with -p.
4. Why does the Dockerfile copy package.json before the rest of the code?
Separating dependency files from code lets the build cache skip npm ci when only code changes.
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
