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.