Writing Dockerfiles and Image Best Practices
Package an app into a reproducible image with a single Dockerfile, then use layering, caching, and multi-stage builds to keep it small and safe.
A Dockerfile is a plain-text "build script" that docker build reads line by line to produce an image. Learn a handful of instructions and a few best practices, and you can reliably package and run apps on your own server or VPS. This guide targets Ubuntu/Debian.
The instructions you'll use most
- FROM — the base image; must be the first instruction, e.g. FROM debian:12-slim.
- WORKDIR — sets the working directory for later instructions; it's created if missing.
- COPY — copies files from the build context into the image.
- RUN — runs a command at build time (installing dependencies, for example); each RUN creates a new layer.
- ENV — sets an environment variable that also applies at runtime.
- EXPOSE — documents the port the container listens on; it's mainly informational.
- CMD — the default command run when the container starts; docker run can override it.
- ENTRYPOINT — a fixed entry program, often paired with CMD, which then supplies default arguments.
Build and tag an image:
docker build -t myapp:1.0 .
The trailing . is the build context (the current directory); -t names and tags the image.
Best practices
Pick a small base image
Prefer -slim or alpine variants. Smaller bases pull faster and have a smaller attack surface. Note that Alpine uses musl libc, so some programs need Debian slim instead.
Merge RUN commands to cut layers
Every RUN is a layer, and more layers mean a heavier image. Chain related commands with && and clean caches in the same layer:
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Order instructions for the build cache
Docker caches each layer, and changing one layer invalidates every layer after it. Put the steps that change least near the top: copy your dependency manifest and install first, then copy source code. That way editing code doesn't force a reinstall.
Use a .dockerignore
Add a .dockerignore at the project root to exclude nodemodules, .git, logs, and the like. This shrinks the context, speeds up builds, and keeps sensitive files out of the image:
.git
node_modules
*.log
Shrink images with multi-stage builds
Compile in one stage, then copy only the artifacts into a clean runtime image, so compilers and source never ship:
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM debian:12-slim
COPY --from=build /app /app
Run as a non-root user
Running as root by default is a security risk. Create an unprivileged user and switch to it:
RUN useradd -m appuser
USER appuser
A complete example
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
RUN useradd -m appuser
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
Dependencies install before the source is copied, so the cache does its job; the multi-stage build keeps only what runtime needs; and the app starts as the non-root appuser.
Summary
A good Dockerfile comes down to a few core instructions plus three ideas: small, few, and ordered. Choose a small base image, merge RUN steps to reduce layers, and order instructions by how often they change so the cache hits. Add a .dockerignore, multi-stage builds, and a non-root user, and you'll ship images that are small, fast to build, and safer on your server or VPS. Start from a Dockerfile that runs, then layer these practices in.