LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Docker Best Practices for Local Development

dockercontainersdevopsdevelopment

Docker has become essential for local development, but there’s a difference between “it works” and “it works well.” Here are practices I’ve learned that make Docker development smoother.

Use Multi-Stage Builds

Multi-stage builds keep your final images small by separating build dependencies from runtime.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

The build stage has all your dev dependencies; the final image only has what’s needed to run.

Volume Mounts: Get Them Right

For local development, mount your source code as a volume so changes reflect immediately:

1
2
3
4
5
6
services:
  app:
    build: .
    volumes:
      - .:/app
      - /app/node_modules  # Anonymous volume to preserve container's node_modules

That second volume is crucial. Without it, your host’s node_modules (or lack thereof) overwrites the container’s.

Use .dockerignore

Just like .gitignore, a .dockerignore file prevents unnecessary files from bloating your build context:

node_modules
.git
.env
*.log
dist
coverage
.DS_Store

This speeds up builds significantly on large projects.

Don’t Run as Root

Create a non-root user in your Dockerfile:

1
2
3
4
5
6
FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
CMD ["node", "index.js"]

This limits the damage if your container is compromised.

Pin Your Base Image Versions

Avoid:

1
FROM node:latest

Prefer:

1
FROM node:20.11-alpine3.19

latest will eventually break your build when a new version introduces incompatibilities.

Use Health Checks

Health checks let Docker know if your container is actually working:

1
2
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

This is especially useful with orchestration tools and load balancers.

Docker Compose for Local Dev

Keep your docker-compose.yml focused on development concerns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app
    environment:
      - NODE_ENV=development
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: localdev
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

Common Pitfalls

Building in the wrong directory: Always check your build context. A misplaced docker build . can send gigabytes to the daemon.

Ignoring layer caching: Put instructions that change frequently (like COPY . .) near the end. Dependencies (COPY package*.json) should come first.

Not cleaning up: Run docker system prune periodically. Old images, containers, and volumes accumulate fast.

Hardcoding configuration: Use environment variables for anything that might change between environments.

Conclusion

These practices have saved me countless hours of debugging and frustration. Start with the basics—.dockerignore, proper volume mounts, and pinned versions—and add complexity as needed.

Comments