Writing a Dockerfile
Turn an app into an image.
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.
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'));
{"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.
FROM node:22-alpineWORKDIR /appCOPY package.json ./RUN npm installCOPY . .EXPOSE 3000CMD ["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.
docker build -t hello-docker:1.0 .
[+] 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.
docker run -d -p 3000:3000 hello-docker:1.0curl localhost:3000
7d4a9f0e2b1c8a5f3e6d0c9b7a4e2f1d8c6b3a0e9f7d2c4b1a8e5f0d3c6b9a2eHello 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.
docker run -d hello-docker:1.0docker psdocker ps -adocker logs hopeful_swartz
c1f8a3d7e9b0a4d2f6c8e1b3a7d9f0c2e4b6a8d1f3c5e7b9a0d2f4c6e8b1a3d5CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMESCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMESc1f8a3d7e9b0 hello-docker:1.0 "node app.js" 6 seconds ago Exited (1) 5 seconds ago hopeful_swartznode:internal/modules/cjs/loader:1247throw 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.
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.
mkdir -p dfdemo && printf 'FROM nginx:1.27-alpine\nCOPY index.html /usr/share/nginx/html/index.html\n' > dfdemo/Dockerfileecho '<h1>hello from my image</h1>' > dfdemo/index.htmldocker build -t dfdemo:1 dfdemodocker run --rm -d --name dfdemo -p 8088:80 dfdemo:1curl -s http://127.0.0.1:8088/ | head -n 1docker rm -f dfdemo
[+] 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.