rb ashish
Oct 3, 2026

Docker Best Practices for Developers: Building Secure, Efficient, and Reliable Containers
Docker has become a standard part of modern software development. Teams use it for local development, testing, CI/CD, microservices, and production deployments.
But writing a Dockerfile that works is easy. Writing one that is secure, reproducible, efficient, and maintainable requires more thought.
For developers, good Docker practices improve build times and developer experience. For technology managers, they reduce operational risk, infrastructure waste, and security exposure.
Here are the practices that matter most:
Start With a Small, Explicitly Versioned Base Image
Avoid generic or floating images such as:
FROM node:latest
Instead, use a supported version appropriate for your application:
FROM node:22-alpine
or:
FROM python:3.13-slim
Smaller images generally mean faster downloads, lower storage requirements, and a smaller attack surface.
However, don't choose an image purely because it is small. Compatibility and operational stability matter more than saving a few megabytes.
Also avoid relying on latest for production builds. Explicit versions make builds more predictable and easier to reproduce.
Use Multi-Stage Builds
Build environments often need compilers, development dependencies, and other tools that aren't required to run the application.
Multi-stage builds let you separate those concerns.
For example:
FROM golang:1.24 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o application .
FROM alpine:3.22
WORKDIR /app
COPY --from=builder /app/application .
USER nobody
CMD ["./application"]
The final image contains the application and its runtime requirements, rather than the entire build environment.
This approach is especially useful for compiled applications and frontend builds.
Build with everything you need; run with only what you need.
Make Builds Reproducible
A production container shouldn't depend on whatever versions happen to be available on the day it is built.
Use dependency lock files and deterministic installation commands.
For example, in Node.js:
COPY package.json package-lock.json ./
RUN npm ci
rather than:
COPY . .
RUN npm install
The same principle applies across ecosystems:
go.modandgo.sumfor Go- Lock files for Python package managers
- Maven/Gradle dependency controls for Java
- Package lock files for .NET where appropriate
A reproducible build combines:
Application Code
+
Locked Dependencies
+
Controlled Base Image
=
Predictable Artifact
This becomes particularly important when troubleshooting production incidents.
Optimize Docker Build Layers
Docker caches image layers, so the order of instructions matters.
Instead of:
COPY . .
RUN npm install
prefer:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Dependency files usually change less frequently than application source code. Docker can therefore reuse the dependency layer when developers modify source files.
Also use a .dockerignore file to keep unnecessary files out of the build context:
node_modules
.git
.env
.env.*
coverage
*.log
This reduces build overhead and helps prevent accidental inclusion of sensitive or irrelevant files.
Never Put Secrets Into Images
Avoid embedding credentials in Dockerfiles:
ENV API_KEY="secret-value"
or copying environment files:
COPY .env .
Images can have multiple layers, and information that appears to have been deleted may still exist in the image history.
Instead, inject secrets at runtime using your deployment platform, CI/CD system, or a dedicated secret-management service.
The same image should be able to move from development to staging to production while receiving different configuration at runtime.
Run Containers With Least Privilege
Applications generally don't need root privileges.
Where the base image supports it, run the application as a non-root user:
USER node
You should also consider other runtime security controls where appropriate, such as:
- Read-only filesystems
- Dropping unnecessary Linux capabilities
- Restricted network access
- Resource limits
- Security profiles
The principle is straightforward:
Give the application only the privileges it actually needs.
Keep Runtime Images Focused
A production container shouldn't contain everything used during development.
For example, you typically don't need:
Compilers
Test frameworks
Debugging tools
Source-control metadata
Development dependencies
Build caches
The runtime image should contain what the application needs to operate.
For Node.js, this may mean installing production dependencies only:
RUN npm ci --omit=dev
Combined with multi-stage builds, this can significantly reduce image size and attack surface.
Treat Containers as Disposable
Containers should generally be replaceable.
Don't rely on the container filesystem for permanent application data.
Instead, keep state in appropriate external systems:
- Managed databases
- Object storage
- Redis
- Persistent volumes
- Other purpose-built storage
Think of the architecture as:
Container
|
+-- Application
+-- Temporary Files
External Systems
|
+-- Database
+-- Persistent Data
+-- Secrets
If a container disappears and the application cannot recover, the architecture probably has too much state tied to the container itself.
Design for Observability and Graceful Shutdown
A container being "running" doesn't necessarily mean the application is healthy.
Where appropriate, provide health and readiness endpoints:
/health
/ready
The distinction matters:
- Liveness: Is the application process alive?
- Readiness: Can it currently serve traffic?
Avoid making a liveness check depend on every external service. A temporary database outage shouldn't necessarily cause the application process to be restarted repeatedly.
Applications should also handle termination signals properly so they can:
- Stop accepting new work
- Complete active requests where possible
- Close connections
- Exit cleanly
For logging, prefer standard output and standard error so your container platform can collect and centralize logs.
Keep Containers Focused on One Main Responsibility
A container should generally represent one main application process or service.
For example:
Frontend
|
Backend API
|
Worker
|
Database
rather than putting unrelated services into a single container.
This makes independent scaling, deployment, monitoring, and failure handling easier.
There are legitimate exceptions, but combining multiple unrelated services should be a deliberate architectural decision.
Build, Test, Scan, and Deploy Through CI/CD
Production images shouldn't depend on someone's laptop.
A mature pipeline looks something like:
Git Commit
↓
Unit Tests
↓
Build Image
↓
Integration Tests
↓
Security Scan
↓
Push Image
↓
Deploy
The important point is that you test the actual container image that will be deployed.
This catches problems that application-only tests may miss, including:
- Missing runtime dependencies
- Incorrect permissions
- Wrong startup commands
- Missing files
- Configuration problems
- Vulnerable packages
Scan and Govern Your Container Supply Chain
A container contains more than application code.
Its supply chain may look like:
Base Image
↓
OS Packages
↓
Runtime
↓
Application Dependencies
↓
Application Code
Each layer can introduce vulnerabilities.
Include image scanning and dependency scanning in your development process. For mature environments, consider additional controls such as:
- SBOM generation
- Trusted base images
- Image signing
- Registry access controls
- Image retention policies
- Regular dependency updates
Security should be part of the build process rather than a final manual check before production.
Use Immutable, Traceable Images
Once an image has been tested, treat it as an immutable artifact.
Avoid manually changing production containers.
Instead:
Source Code
↓
Build Image
↓
Test Image
↓
Deploy Exact Image
Give images meaningful identifiers, such as:
my-app:1.8.3
my-app:git-8f42a1c
For environments that require strong immutability, image digests can identify the exact image content being deployed.
This also makes troubleshooting easier because the team can answer a simple question:
Which exact application artifact is running in production?
Keep Dockerfiles Simple and Maintainable
Dockerfiles are infrastructure code, so they should receive the same engineering discipline as application code.
Keep them:
- Readable
- Version-controlled
- Reviewed through pull requests
- Automated through CI/CD
- Documented where necessary
Avoid clever, complicated shell commands unless they provide a real benefit.
A developer who joins the team six months later should be able to understand how the container is built without needing the original author.
A Practical Dockerfile Example
Putting several of these practices together, a Node.js application could use:
FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER node
EXPOSE 8080
CMD ["node", "src/server.js"]
And a corresponding .dockerignore:
node_modules
.git
.env
.env.*
coverage
*.log
This example demonstrates several important principles:
- Explicit base image
- Dependency lock file
- Layer caching
- Production-only dependencies
- Multi-stage build
- Non-root execution
- Minimal build context
- Clear startup command
Docker Best Practices Checklist
Before shipping a container to production, ask:
Image
- Is the base image appropriate and version-controlled?
- Is the image smaller than necessary?
- Are build tools excluded from the runtime image?
Security
- Does the application avoid unnecessary root privileges?
- Are secrets kept outside the image?
- Are dependencies and images scanned?
- Are unnecessary capabilities and access removed?
Build
- Is
.dockerignoreconfigured? - Are dependencies locked?
- Are Docker layers structured for effective caching?
- Is the image built in CI/CD?
Runtime
- Is persistent data stored outside the container?
- Are health and readiness checks appropriate?
- Does the application handle graceful shutdown?
- Are CPU and memory requirements understood?
Operations
- Is the image traceable to a source-code revision?
- Is the deployed artifact immutable?
- Can the team reproduce the build?
- Are logs and operational metrics available?
Final Thoughts
Docker best practices aren't about making a Dockerfile as clever or as small as possible.
They're about creating a reliable path from source code to production.
A well-designed container should be:
Small enough to deploy efficiently.
Secure enough to minimize unnecessary exposure.
Reproducible enough to build consistently.
Observable enough to troubleshoot.
Disposable enough to replace safely.
Simple enough for another engineer to maintain.
For developers, that means treating Dockerfiles as production code.
For technology managers, it means establishing sensible standards and automating the checks so teams don't have to remember every rule manually.
The real measure of good Docker practices isn't whether a container starts successfully on a developer's laptop.
It's whether the same application can be built, tested, secured, deployed, monitored, and replaced reliably in production.