You can build an image by running commands inside a container and committing the result, but nobody does that. The standard approach is a Dockerfile — a text file that describes how to assemble an image, line by line.
A Minimal Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Build and run it:
docker build -t myapp:1.0 .
docker run -p 3000:3000 myapp:1.0
The Core Instructions
| Instruction | Purpose |
|---|---|
FROM image:tag | Base image to start from. Required, must be the first non-comment line. |
WORKDIR /path | Set the working directory; created if missing. |
COPY src dst | Copy files from the build context into the image. |
ADD src dst | Like COPY but also extracts tarballs and supports URLs. Prefer COPY. |
RUN cmd | Execute a command at build time and bake the result into a layer. |
ENV KEY=VALUE | Set an environment variable in the image (and at runtime). |
ARG NAME[=DEFAULT] | Build-time variable, available only during build. Pass with --build-arg. |
EXPOSE port | Documents the port the container listens on (does not publish it). |
USER name | Switch to a non-root user for subsequent instructions and runtime. |
CMD ["cmd", "arg"] | Default command if none is given to docker run. |
ENTRYPOINT ["cmd"] | Sets the executable. CMD becomes its default arguments. |
HEALTHCHECK | How Docker probes whether the container is healthy. |
The Build Cache
Each instruction produces a layer. When you rebuild, Docker checks each instruction in order: if the inputs haven't changed since last build, it reuses the cached layer. The first changed instruction and everything below it rebuilds.
This is why you order Dockerfiles like the example above: copy the dependency manifest first, install dependencies, then copy your source code. Editing application code does not bust the slow npm ci layer.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # changes rarely
RUN npm ci --omit=dev # slow — cached as long as package.json is unchanged
COPY . . # changes on every commit
CMD ["node", "server.js"]
CMD vs ENTRYPOINT
This trips up almost everyone. The two interact:
- CMD only: The arguments to
docker runreplace CMD entirely.docker run myapp lsrunslsinstead of the default. - ENTRYPOINT only: Arguments to
docker runare appended to ENTRYPOINT. Use this when you want the image to behave like a single command. - Both: ENTRYPOINT is the executable, CMD is the default args, easily overridden.
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]
# docker run myapp → python app.py --port 8000
# docker run myapp --port 9000 → python app.py --port 9000
Always use the exec form (JSON array) — not the shell form. Shell form wraps the command in /bin/sh -c, which breaks signal handling (SIGTERM is swallowed by the shell, not delivered to your app).
.dockerignore
The build context (the directory you point docker build at) is sent to the daemon. Without filtering, a 2 GB node_modules or .git folder gets uploaded on every build. Use .dockerignore just like .gitignore:
node_modules
.git
.env
*.log
dist
.next/cache
Best Practices
- Pin base images:
node:20.11-alpine, notnode:latest. - Use minimal bases: Alpine, distroless, or
-slimvariants. A 50 MB image starts faster, ships faster, and has a smaller attack surface than a 1 GB one. - Combine related RUN commands with
&&and clean up in the same layer (so the cleanup is also in the same layer):
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
- Run as non-root:
RUN useradd -m app USER app - Use
HEALTHCHECKso orchestrators know when your container is ready. - Tag deliberately: include a version (
1.2.3) and consider addinglatestonly after release verification. - Scan images:
docker scout cves, Trivy, Snyk, or your registry's built-in scanner — a missing patch in a base image becomes your problem.
The next lesson covers multi-stage builds, which let you compile your app in one image and ship a tiny runtime image — often shrinking final images by 90% or more.