A Dockerfile is a recipe for a container image: start from a base image, add files, install dependencies, and declare how the app starts. A good Dockerfile builds fast (thanks to caching), produces a small image, and runs the app as a non-root user. Here’s how to write one.

You’ll need Docker installed — see installing Docker on Ubuntu.

A first Dockerfile

For a small Node.js app with package.json and server.js:

FROM node:22-slim

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]

Build and run it:

docker build -t myapp:1.0 .
docker run -d --name myapp -p 127.0.0.1:3000:3000 myapp:1.0

The instructions you need

Instruction What it does
FROM Base image. Every Dockerfile starts here.
WORKDIR Sets (and creates) the working directory for later instructions.
COPY Copies files from the build context into the image.
RUN Runs a command at build time and saves the result as a layer.
ENV Sets an environment variable in the image.
ARG A build-time variable (docker build --build-arg). Not present at runtime.
EXPOSE Documents the port the app listens on. It doesn’t publish it.
USER User for subsequent instructions and for the running container.
CMD Default command when the container starts.
ENTRYPOINT Fixed executable; CMD then supplies default arguments.
HEALTHCHECK Command Docker runs to decide whether the container is healthy.

Use the exec form (CMD ["node", "server.js"]) rather than the shell form (CMD node server.js). With the exec form, your app is PID 1 and receives the SIGTERM sent by docker stop, so it can shut down cleanly.

Layer caching: order matters

Each instruction creates a layer, and Docker reuses a layer if its inputs haven’t changed. Everything after a changed layer is rebuilt.

That’s why the example copies package.json first and runs npm ci before copying the source code. Editing server.js invalidates only the final COPY . . layer; the slow dependency install comes from cache. The same pattern applies to any language:

  • Python: copy requirements.txt → pip install → copy source.
  • Go: copy go.mod/go.sum → go mod download → copy source.
  • PHP: copy composer.json/composer.lock → composer install → copy source.

Add a .dockerignore

The build context is everything in the directory you pass to docker build. Without a .dockerignore, you’ll send node_modules, .git and possibly secrets into the build:

.git
node_modules
*.log
.env
Dockerfile
compose.yaml

Smaller context → faster builds, and no accidental .env baked into an image.

Multi-stage builds

Compilers and build tools don’t belong in the runtime image. A multi-stage build compiles in one stage and copies only the result into a slim final stage:

# Build stage
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

# Runtime stage
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]

The Go toolchain image is hundreds of megabytes; the final image is a few megabytes plus your binary. For front-end projects, build with Node in the first stage and copy dist/ into an nginx stage. More examples in Docker multi-stage builds.

Keep images small

  • Pick a slim base: -slim Debian variants or Alpine. Alpine uses musl libc, which occasionally causes surprises with prebuilt binaries and Python wheels; -slim is the safer default.
  • Clean up in the same RUN: files deleted in a later layer still take space in the earlier one.
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*
  • Don’t install what you don’t need — --no-install-recommends, npm ci --omit=dev, pip install --no-cache-dir.

Check the result with docker images and docker history myapp:1.0.

Run as a non-root user

Containers run as root unless told otherwise. If an attacker exploits your app, root inside the container is a much better starting point for an escape. Official images often provide a user (node in Node images, www-data in PHP images); otherwise create one:

RUN useradd --system --uid 10001 --no-create-home appuser
USER appuser

Make sure the files the app writes to are owned by that user (COPY --chown=appuser:appuser).

Add a health check

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD curl -fsS http://localhost:3000/health || exit 1

docker ps then shows healthy/unhealthy, and Compose can wait for it with depends_on: condition: service_healthy. The command must exist in the image — distroless images have no curl, so use a small built-in health binary instead.

Pin your versions

FROM node:latest today and FROM node:latest next year are different images. Pin at least the major version (node:22-slim), and for strict reproducibility pin the digest (node:22-slim@sha256:…). Rebuild regularly anyway, to pick up security fixes in the base image.

Never bake secrets into images

Anything in a layer can be extracted, even if a later layer deletes it. Pass secrets at runtime (environment variables, mounted files, Docker secrets). For secrets needed only during the build — for example a private package registry token — use BuildKit secret mounts:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

Common mistakes

  • COPY . . before installing dependencies — every code change reinstalls everything.
  • apt-get update in one RUN and apt-get install in another — the cached update goes stale.
  • CMD in shell form — the app doesn’t receive SIGTERM and gets killed after 10 seconds.
  • Missing .dockerignore — huge contexts and leaked files.
  • Writing logs to files inside the container — log to stdout/stderr so docker logs works.

Next steps

Run your image alongside a database with the Docker Compose tutorial, and keep the Docker commands cheat sheet nearby. Any Docker VPS plan can build and run images like these.

  • Docker
  • Dockerfile
  • Containers