# Dockerfile Tutorial: Build Your First Docker Image Step by Step

> Learn to write a Dockerfile: FROM, COPY, RUN, CMD and ENTRYPOINT, the build cache, .dockerignore and best practices, building a real Node.js image.

- Source: https://www.itwonderlab.com/dockerfile-tutorial/
- Published: 2026-10-06
- Updated: 2026-10-06
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## What is a Dockerfile

A **Dockerfile** is a text file with the instructions to build an image. Each instruction creates a layer. `docker build` reads the file, executes the instructions in order and produces an image you can run anywhere or push to a [registry](https://www.itwonderlab.com/docker-containers-images-explained/).

In this tutorial we containerize a small Node.js web server. The ideas are the same for Python, Java, Go or any other language.

## The application

Create a directory with three files.

```json title="package.json"
{
  "name": "hello-docker",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": { "start": "node server.js" },
  "dependencies": {}
}
```

```javascript title="server.js"
const http = require("http");
const port = process.env.PORT || 3000;

http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end(`Hello from ${require("os").hostname()}\n`);
}).listen(port, () => console.log(`listening on ${port}`));
```

## The Dockerfile

```dockerfile title="Dockerfile"
FROM node:22-slim

WORKDIR /app

# 1. Dependencies first: this layer changes rarely
COPY package*.json ./
RUN npm install --omit=dev

# 2. Your code last: this layer changes all the time
COPY . .

ENV PORT=3000
EXPOSE 3000

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

Build and run it:

```shell
$ docker build -t hello-docker:1.0 .
$ docker run -d --name hello -p 3000:3000 hello-docker:1.0
$ curl http://localhost:3000
Hello from 3f2a9c1d8b7e
$ docker rm -f hello
```

`-t` names and tags the image and the final `.` is the **build context**: the directory sent to the builder.

## The instructions you need

| Instruction | What it does |
|---|---|
| `FROM` | Base image. Every Dockerfile starts with it. `FROM scratch` is an empty image. |
| `WORKDIR` | Sets the working directory for the next instructions and for the container. Use absolute paths. |
| `COPY` | Copies files from the build context into the image. Prefer it to `ADD`. |
| `RUN` | Executes a command at build time and saves the result as a layer. |
| `ENV` | Sets an environment variable that exists at build time and at run time. |
| `ARG` | A build-time variable, set with `--build-arg`. It is not available at run time. |
| `EXPOSE` | Documents the port the application listens on. It does not publish it: use `-p`. |
| `USER` | The user for the next instructions and for the container. Use a non-root user. |
| `CMD` | The default command. It is easy to override: `docker run image other-command`. |
| `ENTRYPOINT` | The executable of the container. `CMD` becomes its default arguments. |
| `HEALTHCHECK` | A command Docker runs to know whether the container is healthy. |

### CMD and ENTRYPOINT

Always use the **exec form** (a JSON array) so that your process receives signals such as `SIGTERM` and stops cleanly. The shell form (`CMD node server.js`) wraps it in `/bin/sh -c`, which does not forward signals.

```dockerfile
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]
```

With this, `docker run image` runs `python app.py --port 8000`, and `docker run image --port 9000` replaces only the arguments.

### Health checks

```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"
```

`docker ps` then shows `(healthy)` or `(unhealthy)`, and [Docker Compose](https://www.itwonderlab.com/docker-compose-tutorial/) can wait for it.

## The build cache

Docker caches each layer. If an instruction and everything before it are unchanged, Docker reuses the layer. As soon as one layer changes, **all the following layers are rebuilt**. That is why the Dockerfile above copies `package.json` and installs the dependencies before copying the code: when you edit `server.js`, only the last `COPY` runs again.

![Dockerfile layers and the build cache: instructions before COPY of the source stay cached, and changing the source rebuilds only the layers after it](https://www.itwonderlab.com/media/tutorials/Diagrams/ITWL-Dockerfile-Layers.svg "Order the instructions from the least to the most frequently changed")

Try it: build twice, then change `server.js` and build again. The output marks each step as `CACHED` or executes it.

## .dockerignore

Everything in the build context is sent to the builder, and `COPY . .` copies all of it. A `.dockerignore` file keeps out what must not be in the image:

```text title=".dockerignore"
.git
node_modules
npm-debug.log
Dockerfile
.dockerignore
.env
*.md
```

This makes builds faster, keeps images small and, importantly, keeps secrets such as `.env` and SSH keys out of the image.

## Best practices

- **Use small, official base images** and pin their version: `node:22-slim` or `alpine:3.21`, never `latest`. See [image security](https://www.itwonderlab.com/docker-image-security-scanning/) for pinning by digest.
- **One process per container**, logging to `stdout` and `stderr` so `docker logs` works.
- **Combine `apt-get update` and `install` in the same `RUN`** and delete the package lists, or you cache stale packages:

  ```dockerfile
  RUN apt-get update && apt-get install -y --no-install-recommends curl \
      && rm -rf /var/lib/apt/lists/*
  ```

- **Run as a non-root user** with `USER`.
- **Do not put secrets in the image**, not in `ENV`, `ARG` or `COPY`. They remain in the layers. Use [BuildKit secrets](https://www.itwonderlab.com/docker-buildkit-buildx/).
- **Separate build and runtime** with [multi-stage builds](https://www.itwonderlab.com/docker-multi-stage-builds/).
- **Lint your Dockerfiles** with [Hadolint](https://github.com/hadolint/hadolint) or `docker build --check`.

## Next steps

Make the image smaller and safer with [multi-stage builds](https://www.itwonderlab.com/docker-multi-stage-builds/).
