Smaller, safer images: multi-stage builds, non-root users and health checks
Cut image size with multi-stage builds and .dockerignore, run as a non-root user, pin versions and add a HEALTHCHECK.
The first Dockerfile you write works. The one a reviewer approves is small, does not run as root, and tells the orchestrator when the app is healthy. These four improvements are what separate a tutorial image from a production one, and they come up in nearly every DevOps interview.
Multi-stage builds
Build tools are needed to compile, not to run. A multi-stage Dockerfile uses one stage to build and copies only the result into a clean final stage.
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
The final image contains nginx and static files, not Node, npm or node_modules. The same pattern works for Go (build with the golang image, copy the binary into a scratch or distroless image) and Java (build with a JDK image, run on a JRE image).
.dockerignore and base image choice
A .dockerignore file keeps junk out of the build context so COPY . . stays fast and does not leak secrets:
.git
node_modules
*.log
.env
dist
Choose slim or alpine variants when the app does not need a full OS, and pin a version tag such as python:3.12-slim rather than latest, so builds are repeatable and a base image update cannot break production without you noticing.
Run as a non-root user and add a health check
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
&& useradd --create-home appuser
COPY . .
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)"
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]
If the container is ever broken into, a non-root user limits the damage. The health check runs inside the container; it uses Python here because slim images often lack curl. Docker marks the container unhealthy after three failures, and Compose or an orchestrator can restart or stop routing traffic to it. Never bake secrets with ENV or COPY .env; pass them at runtime with --env-file or a secrets manager.
Common mistakes and a practice habit
- Copying the whole source before installing dependencies, which invalidates the cache on every edit.
- Installing packages without cleaning caches in the same
RUNstep, which leaves them in the layer. - Switching to
USERbefore files are copied, then hitting permission errors on write paths. - A health check endpoint that returns 200 even when the database is unreachable.
Practice habit. Take an image you have built and run docker image ls to note its size. Apply the four changes above one at a time and record the size after each. Then run docker exec api whoami and docker inspect --format '{{.State.Health.Status}}' api to confirm the user and health status.
More in Cloud and DevOps