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:
-slimDebian variants or Alpine. Alpine uses musl libc, which occasionally causes surprises with prebuilt binaries and Python wheels;-slimis 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 updatein oneRUNandapt-get installin another — the cached update goes stale.CMDin 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 logsworks.
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.