CoursesDocker for beginnersWriting a Dockerfile

Writing a Dockerfile

Turn an app into an image.

Beginner12 min · lesson 9 of 16
In plain terms
A Dockerfile is a recipe card the kitchen follows step by step — start with this base, add these ingredients, then this is how you serve it. Follow the card and you get the same dish (image) every time.

A recipe card does two useful things. It lists the steps in order, and it means the same dish comes out of anybody's kitchen. A Dockerfile is that card. It is a plain text file, read top to bottom, that tells Docker how to assemble your app into a bundle you can hand to anyone. For the last few lessons you've been running bundles other people wrote cards for. Now you write your own. Two words to pin down first, because people swap them constantly. An image is a frozen snapshot of your app and everything it needs to run: your code, the libraries it leans on, the language runtime underneath, all sealed together. A container is a running copy of that image. Card, sealed dish, plate in front of you.

First, something worth packaging

You can't package nothing, so here is a web server small enough to read in one breath. It uses Express, a small helper library that handles the fiddly parts of answering web requests. Express runs on Node.js, which is the program that runs JavaScript on a server instead of inside a browser. Every request gets the same one-line reply. Make an empty folder called hello-docker and drop these two files into it.

server.js
const express = require('express');
const app = express();
app.get('/', (req, res) => res.send('Hello from inside a container\n'));
app.listen(3000, () => console.log('listening on port 3000'));
package.json
{
"name": "hello-docker",
"version": "1.0.0",
"dependencies": {
"express": "^4.21.2"
}
}

Now write the card

Create a file named exactly Dockerfile, with no extension, sitting next to those two. One instruction per line, worked through from the top down. FROM picks the base image, a ready-made image you build on top of instead of starting from bare Linux. Ours is an official Node.js 22 image built on Alpine, a stripped-back version of Linux that keeps the finished bundle small. WORKDIR sets the folder inside the image where every step after it runs. COPY carries files from your folder on disk into the image. RUN executes a command while the image is being built and bakes whatever came out of it in. EXPOSE writes down which port the app listens on. CMD is what Docker runs the moment someone starts a container from this image.

Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

That order is not an accident. package.json goes in on its own, npm install runs, and only then does the rest of your source arrive. Do it this way and Docker can hand back the already-installed libraries on your next build, as long as your dependency list hasn't moved. Every instruction that touches files adds a layer, meaning one saved step of the build. Stack the saved steps in order and you have the image, like sheets of clear film laid one over another until you can see the whole picture. The next lesson pulls that apart properly.

Build it

docker build feeds your Dockerfile to Docker and gets an image back. The -t flag tags it, which means giving it a name a human can remember plus a version: hello-docker:1.0. Everything after the colon is the tag. Leave the tag off and Docker writes latest for you, which is almost never what you actually meant. The dot on the end is the build context, the folder you hand over, and the only place on your whole disk that COPY is allowed to read from. Docker ships that folder to its daemon, the background program that does the real work, then walks your instructions one at a time.

terminal
docker build -t hello-docker:1.0 .
build output
[+] Building 15.7s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 214B 0.0s
=> [internal] load metadata for docker.io/library/node:22-alpine 1.2s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 61B 0.0s
=> [1/5] FROM docker.io/library/node:22-alpine@sha256:6f3d... 3.9s
=> [internal] load build context 0.1s
=> => transferring context: 2.6kB 0.0s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY package.json ./ 0.0s
=> [4/5] RUN npm install 8.4s
=> [5/5] COPY . . 0.1s
=> exporting to image 0.2s
=> => exporting layers 0.2s
=> => writing image sha256:b9a1...c4 0.0s
=> => naming to docker.io/library/hello-docker:1.0 0.0s

Read that output top to bottom and you are watching your own file play out. Base image pulled, working directory set, package.json copied, npm install run, then the rest of your code. The [1/5] through [5/5] labels are your five build steps, each with the time it cost. Run the build a second time and every step that hasn't changed comes straight back from the cache, so it finishes before you have let go of the Enter key. The last line stamps the finished image with its name, ready to start.

Run it

Your own image runs exactly like anyone else's. The -d flag sends the container to the background (detached is Docker's word for it) so your prompt comes straight back to you. The -p 3000:3000 flag wires port 3000 on your laptop through to port 3000 inside the container, which is the bit that lets curl or a browser on your machine actually reach the app.

terminal
docker run -d -p 3000:3000 hello-docker:1.0
curl localhost:3000
output
7d4a9f0e2b1c8a5f3e6d0c9b7a4e2f1d8c6b3a0e9f7d2c4b1a8e5f0d3c6b9a2e
Hello from inside a container

That long string of letters and numbers is the new container's ID, its full-length name in Docker's records. The line underneath is your app talking back from inside the container. You wrote code, sealed it into an image, started the image as a container, and got an answer out of it. That is the whole Docker loop, and this time it went around an app of your own.

When your container vanishes

Here is the snag nearly everyone hits in week one. You start the image, Docker prints an ID, and docker ps shows an empty table. The container started and died inside the same second, almost always because the app inside it crashed on startup. docker ps lists only what is currently running, so add -a to see the dead ones too, then read their logs. Say your CMD points at node app.js while the file on disk is called server.js.

terminal
docker run -d hello-docker:1.0
docker ps
docker ps -a
docker logs hopeful_swartz
output
c1f8a3d7e9b0a4d2f6c8e1b3a7d9f0c2e4b6a8d1f3c5e7b9a0d2f4c6e8b1a3d5
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c1f8a3d7e9b0 hello-docker:1.0 "node app.js" 6 seconds ago Exited (1) 5 seconds ago hopeful_swartz
node:internal/modules/cjs/loader:1247
throw err;
^
Error: Cannot find module '/app/app.js'

Exited (1) means the program stopped with an error rather than finishing cleanly. The log names the error outright: Node went looking for /app/app.js and found nothing there. Fix the filename in CMD, rebuild, run again. Any time a container refuses to stay up, reach for that same pair before anything else. docker ps -a to find it, docker logs to hear what it said on the way out.

Write a .dockerignore before your first build
COPY . . takes everything in the build context and puts it in your image. With no .dockerignore file to stop it, that can mean your entire .git history, a bloated node_modules folder, and a .env file holding your database password, all baked into layers that anyone who pulls the image can crack open and read. Deleting the file in a later instruction does not help, because the earlier layer still has it. Put a .dockerignore next to your Dockerfile listing node_modules, .git and .env, and put it there before you build. Faster builds come free with it, since Docker then ships less data to the daemon.
From recipe to running app
1Dockerfile
your recipe
2docker build
run each instruction
3image
frozen, reusable
4docker run
start a copy
5container
your app, live
Build once, run many. The same image can start any number of identical containers.

Writing the setup down in a file, rather than typing the same commands by hand on every machine, is the entire point. A colleague on a Mac, a build server in a data centre, and you at two in the morning can all run docker build on the same folder and get the same kind of image out. Nobody has to remember which library version you installed by hand last spring.

Start smaller than feels useful. One HTML file served by a tiny web server, or the three-line Node app above, teaches you the loop: write the Dockerfile, docker build -t name ., docker run. Get that working before you add dependency installs and real application code. People who open a tutorial on multi-stage production Dockerfiles first usually end up with something that works and no idea which line made it work.

COPY and RUN get mixed up more than any other pair here. COPY moves files from the build context, which is normally your project folder, into the image. RUN executes a command inside the half-built image while the build is happening. If your container starts and the app is nowhere to be found, a missing COPY is the first thing to check. And if you RUN curl to fetch something without naming a version, two builds a month apart can quietly produce two different images.

Set a CMD or an ENTRYPOINT every time, so docker run with no extra arguments does something sensible instead of dropping you at a shell prompt or exiting on the spot. On anything real, name a specific base tag: alpine:3.20 pins you to a version you have actually tested, while alpine:latest changes underneath you without warning. And keep passwords and keys out of the Dockerfile text and out of the build context altogether. Once a secret has been written into a layer, treat it as readable by anyone holding the image.

Try this

Build a one-file static site image and run it end to end, so the edit, build, run loop becomes something your hands know rather than something you read about.

terminal
mkdir -p dfdemo && printf 'FROM nginx:1.27-alpine\nCOPY index.html /usr/share/nginx/html/index.html\n' > dfdemo/Dockerfile
echo '<h1>hello from my image</h1>' > dfdemo/index.html
docker build -t dfdemo:1 dfdemo
docker run --rm -d --name dfdemo -p 8088:80 dfdemo:1
curl -s http://127.0.0.1:8088/ | head -n 1
docker rm -f dfdemo
output
[+] Building ... FINISHED
=> naming to docker.io/library/dfdemo:1
<container-id>
<h1>hello from my image</h1>

Takeaway

Keep the hello-docker folder where it is. Seven lines, four of which are FROM, COPY, RUN and CMD, were enough to turn a real app into an image you can rebuild whenever you like. The next lesson takes that same seven-line file apart to explain why COPY package.json has to sit above COPY . . rather than below it.

Quick check
01In that Dockerfile, package.json is copied and npm install runs before the rest of your source arrives. Why split it up instead of copying everything in one go?
Correct — Dependencies live in a layer of their own, so editing server.js never triggers a reinstall.
Incorrect — It isn't. COPY can appear as often as you like, and the very next line proves it.
Incorrect — No. npm install reads package.json and nothing else. Your source being there makes no difference to it.
Incorrect — It will. COPY . . grabs the entire folder, one line further down.
02In docker build -t hello-docker:1.0 ., what is that dot on the end doing?
Incorrect — Docker finds the Dockerfile by its name. The dot points at a folder, not a file.
Incorrect — The dot has nothing to do with the cache. It names the context folder.
Incorrect — Tags come from -t. The dot plays no part in naming the image.
Correct — Yes. COPY can only pull files from inside the context folder you hand to the build.
03Your project folder holds a .env file full of database passwords, a .git folder and node_modules. Your Dockerfile ends with COPY . . and you never wrote a .dockerignore. You build the image and push it. What did you ship?
Correct — COPY . . takes the whole context, and only a .dockerignore would have held those back.
Incorrect — Docker skips nothing on its own. A .dockerignore is the only thing that keeps files out.
Incorrect — COPY copies dotfiles quite happily, so no error appears.
Incorrect — There is no default exclusion list. All three land in the image.

Related