A Dockerfile is just a plain text file, usually named exactly Dockerfile with no extension, sitting in the root of your project. Each line is an instruction that adds a layer to the image being built. Let us build one for a small Node.js app, since it shows every important instruction without much noise.
The project it packages
// server.js
const http = require("http");
http.createServer((req, res) => {
res.end("Hello from inside a container!");
}).listen(3000);The Dockerfile, instruction by instruction
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]What each line actually does
- FROM node:20-alpine — starts from an existing image that already has Node.js 20 installed, built on the tiny
alpineLinux distribution. You almost never build an image completely from scratch; you build on top of a trusted base image. - WORKDIR /app — sets the working directory inside the image. Every instruction after this runs relative to
/app, and it is created automatically if it does not exist. - COPY package*.json ./ — copies only the package files first, before the rest of the source code.
- RUN npm install --production — installs dependencies inside the image at build time.
- COPY . . — copies the rest of the project source into the image.
- EXPOSE 3000 — documents which port the app listens on. It does not actually publish the port by itself; that happens with
-pwhen you run the container. - CMD ["node", "server.js"] — the command that runs when a container starts from this image. A Dockerfile can only have one
CMD; if you list several, only the last one takes effect.
Why COPY package*.json happens before COPY . .
This ordering is not an accident, and it is one of the most important habits in writing Dockerfiles. Docker caches each layer, and reuses a cached layer if the instruction and its inputs have not changed. Dependencies change far less often than application source code, so copying the package files and running npm install first means Docker can reuse that entire cached layer on almost every rebuild, only re-running the slow install step when dependencies actually change. Copy everything at once instead, and Docker invalidates the dependency-install layer on every single source code change — turning a two-second rebuild into a two-minute one.
Building and running it
docker build -t hello-node .
docker run -p 3000:3000 hello-nodeVisiting http://localhost:3000 now hits the server running inside the container. The -t hello-node flag tags the image with a friendly name so you do not have to reference it by its long generated hash.
One more essential file: .dockerignore
node_modules
.git
*.logJust like .gitignore, a .dockerignore file stops unwanted files from being copied into the image during a COPY . . step — keeping images smaller and builds faster, and avoiding accidentally shipping your local node_modules or .git history into a production image.