Docker Best Practices in Practice: Small Images, Safer Containers, and Faster Builds (Hands-On)

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*.json before copying source lets Docker cache dependency installation unless manifests change.
  • npm ci makes installs deterministic (uses package-lock.json).
  • --omit=dev avoids 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 ls and docker ps to confirm what’s running
  • docker logs <container> for app output
  • docker exec -it <container> sh to inspect filesystem/env
  • docker 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 .dockerignore to 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 HEALTHCHECK for 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

Your email address will not be published. Required fields are marked *