Dockerfile Tutorial: Build Your First Docker Image Step by Step

· 3 min read · Docker & Rancher Desktop Tutorials

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.

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.

package.json
{
  "name": "hello-docker",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": { "start": "node server.js" },
  "dependencies": {}
}
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
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:

$ 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.

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 #

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 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
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:

.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 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:

    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.

  • Separate build and runtime with multi-stage builds.

  • Lint your Dockerfiles with Hadolint or docker build --check.

Next steps #

Make the image smaller and safer with multi-stage builds.

#Docker #Dockerfile