Docker Best Practices for Web Apps: Small Images, Fast Builds, Safer Containers

Docker Best Practices for Web Apps: Small Images, Fast Builds, Safer Containers

Docker is “easy” until your builds take 8 minutes, your images are 1.5GB, and production containers run as root. This hands-on guide shows practical Docker best practices for typical web apps (Node, Python, etc.) that you can apply today: faster builds, smaller images, better security, and easier debugging.

1) Start with a good .dockerignore (seriously)

Before touching your Dockerfile, add a .dockerignore. Shipping your local node_modules, virtualenvs, test artifacts, and Git history into the Docker build context slows everything down and can leak secrets.

# .dockerignore .git .gitignore node_modules dist build coverage *.log .env .env.* __pycache__ *.pyc .venv .DS_Store 

Rule of thumb: if it’s generated locally or sensitive, ignore it.

2) Use multi-stage builds to keep runtime images small

Multi-stage builds let you compile/build in one stage (with build tools) and copy only the final output into a lean runtime stage. This usually cuts image size dramatically.

Example: Node app that builds to dist/ and runs with node dist/server.js.

# Dockerfile (Node + multi-stage) # syntax=docker/dockerfile:1 FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:20-alpine AS runtime WORKDIR /app # Create a non-root user RUN addgroup -S app && adduser -S app -G app # Copy only what you need at runtime COPY --from=build /app/dist ./dist COPY --from=build /app/package*.json ./ # If you need runtime dependencies: RUN npm ci --omit=dev && npm cache clean --force USER app EXPOSE 3000 CMD ["node", "dist/server.js"] 

What you get:

  • Smaller runtime image (no compilers, no dev deps).
  • Cleaner attack surface (fewer tools available inside the container).
  • More predictable builds (deps resolved via lockfile).

3) Order your layers for maximum cache reuse

Docker caches layers. If you copy your entire source tree before installing dependencies, any code change invalidates the dependency layer and you reinstall everything every time.

Prefer:

  • Copy lockfiles first (package-lock.json, poetry.lock, etc.).
  • Install deps.
  • Then copy app code.

Node pattern (already shown): COPY package*.jsonnpm ciCOPY . ..

Python pattern example (FastAPI/Flask style):

# Dockerfile (Python + wheels + non-root) # syntax=docker/dockerfile:1 FROM python:3.12-slim AS build WORKDIR /app ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_NO_CACHE_DIR=1 # System deps only in build stage (if needed) RUN apt-get update && apt-get install -y --no-install-recommends build-essential \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip wheel --wheel-dir /wheels -r requirements.txt FROM python:3.12-slim AS runtime WORKDIR /app RUN addgroup --system app && adduser --system --ingroup app app COPY --from=build /wheels /wheels COPY requirements.txt . RUN pip install --no-cache-dir /wheels/* COPY . . USER app EXPOSE 8000 CMD ["python", "-m", "your_app_module"] 

Even if you’re junior, you’ll feel the difference the moment you rebuild after changing one file.

4) Pin versions and make builds reproducible

“Works on my machine” often becomes “works yesterday.” Reproducible builds come from pinned base images and pinned dependencies.

  • Use a specific major/minor tag like node:20-alpine or python:3.12-slim.
  • Use lockfiles: package-lock.json, pnpm-lock.yaml, poetry.lock, etc.
  • Prefer npm ci over npm install in CI and Docker builds.

If you want extra stability, pin by digest (more advanced): node:20-alpine@sha256:....

5) Run as a non-root user

By default many images run as root. If an attacker escapes your app, running as root makes things much worse. The fix is usually a few lines.

RUN addgroup -S app && adduser -S app -G app USER app 

Common gotcha: ports below 1024 require root. Use higher ports inside the container (e.g., 3000, 8000) and map externally if needed.

6) Add a health check (and make it meaningful)

A health check lets Docker (and orchestrators) know if your container is actually healthy, not just “running.” Keep it cheap: hit a /health endpoint that checks dependencies lightly (or just returns OK if your app is up).

Example app route:

// Node/Express example app.get("/health", (req, res) => res.status(200).send("ok")); 

Dockerfile healthcheck:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:3000/health || exit 1 

If your base image lacks wget/curl, either add a tiny tool (trade-off) or implement a small healthcheck script in your language runtime.

7) Use Docker Compose for local dev (with bind mounts)

Compose is perfect for juniors: one command to run app + DB + cache. For local development, use bind mounts so code changes show up without rebuilding.

# docker-compose.yml services: web: build: context: . target: runtime ports: - "3000:3000" environment: - NODE_ENV=development - DATABASE_URL=postgres://postgres:postgres@db:5432/app volumes: - .:/app # keep container node_modules separate from your host - /app/node_modules depends_on: - db db: image: postgres:16-alpine environment: - POSTGRES_PASSWORD=postgres - POSTGRES_DB=app ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata: 

Tip: keep dev and prod concerns separate. Bind mounts are great for dev, but avoid them in production.

8) Don’t bake secrets into images

Never COPY .env into an image. Images get pushed around, cached, and shared.

  • Use runtime env vars: docker run -e KEY=value ...
  • Use env_file in Compose for local only.
  • In CI/CD, use the platform’s secret manager (GitHub Actions secrets, GitLab CI variables, etc.).

If you suspect you accidentally baked a secret, rotate it. Deleting the repo isn’t enough—images and caches may still contain it.

9) Practical “golden” Dockerfile checklist

  • Small base: -alpine or -slim when appropriate.
  • Multi-stage: build tools only in build stage.
  • Cache-friendly layers: copy lockfiles first, install deps, then copy code.
  • Non-root user: USER app.
  • Healthcheck: cheap endpoint or script.
  • No secrets: env vars / secret manager, not image layers.
  • Clean up: remove package manager caches when reasonable.

10) Quick verification commands

After implementing these practices, validate the result with a few commands:

# Build docker build -t myapp:dev . # Run locally docker run --rm -p 3000:3000 myapp:dev # Check image size docker images myapp:dev # Inspect history (see what got baked into layers) docker history myapp:dev 

If the image is still huge, look for common culprits: missing .dockerignore, copying node_modules, or bundling build artifacts and tooling into runtime.

Wrap-up

The best Docker setup is the one your team can maintain. If you adopt only three things from this article, make them: .dockerignore, multi-stage builds, and running as non-root. You’ll get faster iteration locally, smaller deploy artifacts, and safer production containers—with minimal complexity.


Leave a Reply

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