Docker Best Practices in Practice: Small Images, Safer Containers, and Faster Builds (Hands-On)
Docker is easy to start with and just as easy to accidentally misuse—giant images, slow builds, flaky environments, and containers running as root. This hands-on guide focuses on practical Docker best practices you can apply today: multi-stage builds, smarter caching, security hardening, health checks, and a clean docker-compose setup for local development.
The examples use a simple Node.js API (Express), but the techniques translate to most stacks.
1) Start with a “boring” but solid project
Project layout:
my-app/ src/ server.js package.json package-lock.json Dockerfile .dockerignore docker-compose.yml
src/server.js:
import express from "express"; const app = express(); app.use(express.json()); app.get("/health", (req, res) => res.json({ ok: true })); app.get("/", (req, res) => res.json({ message: "Hello from Docker!" })); const port = process.env.PORT || 3000; app.listen(port, () => console.log(`Listening on ${port}`));
package.json (minimal):
{ "name": "my-app", "type": "module", "private": true, "scripts": { "start": "node src/server.js" }, "dependencies": { "express": "^4.19.2" } }
2) Use a .dockerignore (it’s the cheapest speed win)
Without .dockerignore, Docker may send your entire repo (including node_modules) to the build context—slowing builds and bloating layers.
# .dockerignore node_modules npm-debug.log Dockerfile docker-compose.yml .git .gitignore .vscode .env dist coverage
Tip: include build outputs (dist) only if your Dockerfile needs them.
3) Prefer multi-stage builds for smaller runtime images
Even for Node, multi-stage builds help you keep dev tooling out of production images. Here’s a production-oriented Dockerfile using a “builder” stage and a slim runtime stage.
# Dockerfile # syntax=docker/dockerfile:1 FROM node:20-bookworm-slim AS base WORKDIR /app ENV NODE_ENV=production FROM base AS deps # Copy only the dependency manifests first (maximizes cache hits) COPY package*.json ./ # Install production deps only RUN npm ci --omit=dev FROM base AS runtime # Create a non-root user RUN useradd -m -u 10001 appuser # Copy deps and app source COPY --from=deps /app/node_modules ./node_modules COPY src ./src COPY package.json ./package.json USER appuser EXPOSE 3000 # Healthcheck calls an internal endpoint HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["npm", "start"]
COPY package*.jsonbefore copying source lets Docker cache dependency installation unless manifests change.npm cimakes installs deterministic (usespackage-lock.json).--omit=devavoids dev dependencies in the runtime image.- Running as a non-root user reduces the blast radius if something goes wrong.
4) Make builds fast with cache-friendly layering
Docker caches each layer. If you copy your entire source tree before installing dependencies, every code change will invalidate the dependency layer and force a reinstall. The pattern you want is:
- Copy only dependency manifests
- Install dependencies
- Copy the rest of the source
That’s why the Dockerfile copies package*.json first.
5) Pin what matters (but don’t overdo it)
Common “pinning” strategies:
- Pin major versions for base images (e.g.,
node:20-...) to avoid surprise runtime changes. - Use lockfiles (
package-lock.json,poetry.lock,composer.lock) for deterministic dependency resolution. - In CI, build with a clean cache periodically to catch drift (e.g., weekly).
Avoid pinning every OS package unless you have a strong reason; it can increase maintenance pain.
6) Don’t bake secrets into images
Never do this:
# BAD: secrets end up in layers and image history ENV DATABASE_PASSWORD=supersecret
Instead, inject secrets at runtime via environment variables or secret managers. For local dev, use a .env file and keep it out of git.
7) Add a practical docker-compose for local development
Compose is great for junior/mid dev workflows: one command spins up your app + database + dependencies.
# docker-compose.yml services: api: build: context: . ports: - "3000:3000" environment: - PORT=3000 # For dev, you might mount source; for prod, avoid bind mounts volumes: - ./src:/app/src:ro depends_on: - db db: image: postgres:16-bookworm environment: - POSTGRES_PASSWORD=postgres - POSTGRES_USER=postgres - POSTGRES_DB=appdb ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:
Run it:
docker compose up --build
Then open:
http://localhost:3000/http://localhost:3000/health
Note: Mounting ./src read-only (:ro) prevents accidental container writes to your host source.
8) Use a predictable entrypoint and signal handling
One subtle production issue: PID 1 signal handling. If your app doesn’t shut down gracefully on SIGTERM, deployments can become messy.
For Node apps, consider using an init process like tini (or Docker’s --init flag) to handle zombie reaping and signal forwarding.
Example (Dockerfile):
FROM node:20-bookworm-slim AS runtime RUN apt-get update && apt-get install -y --no-install-recommends tini \ && rm -rf /var/lib/apt/lists/* ENTRYPOINT ["tini", "--"] CMD ["npm", "start"]
If you’re running in Kubernetes, you’ll also want your app to listen for SIGTERM and close servers cleanly.
9) Security basics you can apply immediately
- Run as non-root: already done via
USER appuser. - Reduce attack surface: use slim images; avoid installing extra packages in runtime images.
- Read-only filesystem: where possible, run containers with a read-only root filesystem and mount only needed writable dirs.
- Drop capabilities: in orchestrators, drop Linux capabilities if you don’t need them.
Example Compose hardening (optional):
services: api: build: . read_only: true tmpfs: - /tmp
This forces you to be explicit about writable paths—great for catching “it works on my machine” assumptions.
10) Build once, run anywhere (avoid environment-specific images)
A common anti-pattern is building different images per environment (“dev image”, “staging image”, “prod image”) with different behavior. Prefer:
- One image artifact
- Environment-specific configuration injected at runtime (env vars, config files, secrets)
This reduces “works in staging, fails in prod” issues because you’re literally running the same bits.
11) A quick “debug checklist” for Docker issues
docker image lsanddocker psto confirm what’s runningdocker logs <container>for app outputdocker exec -it <container> shto inspect filesystem/envdocker inspect <container>to check mounts, ports, health status- Rebuild cleanly if needed:
docker compose build --no-cache
12) Putting it all together: commands you’ll actually use
Build and run the production-style image:
docker build -t my-app:prod . docker run --rm -p 3000:3000 my-app:prod
Run locally with Postgres:
docker compose up --build
Stop everything:
docker compose down
Remove volumes too (careful: deletes DB data):
docker compose down -v
Key takeaways
- Use
.dockerignoreto keep contexts small and builds fast. - Structure Dockerfiles to maximize caching: copy manifests → install deps → copy source.
- Prefer multi-stage builds and slim runtime images.
- Run as non-root and add a
HEALTHCHECKfor safer operations. - Keep secrets out of images; inject configuration at runtime.
- Use Compose to standardize local dev across your team.
If you want, I can adapt the same best-practice template to your stack (Python, PHP/Laravel, Java, Go) and include an optimized Dockerfile + Compose setup tailored to it.
Leave a Reply