Back to Blogs
December 10, 20254 min read

Docker for Application Developers: A Practical Guide

Docker is now a baseline skill for most developers, but many teams use it in ways that slow them down. Here's how to use it well.

Why Docker Matters More Than People Think

Docker is often introduced as "run your app anywhere." That's true, but it undersells the real value: reproducible environments. The "it works on my machine" problem is a real productivity drain, and Docker eliminates it. Your development environment, your CI environment, and your production environment run the same image.

This post is for application developers — not DevOps — who want to use Docker effectively without becoming container infrastructure experts.

A Dockerfile That's Actually Good

Most Dockerfile examples online produce images that are 1GB+ and have poor layer caching. Here's a better pattern for a Node.js application:

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app

# Copy package files first — layer cache reuses this unless dependencies change
COPY package*.json ./
RUN npm ci --frozen-lockfile

COPY . .
RUN npm run build

# Production stage — starts fresh, copies only what's needed
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

# Create a non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000
CMD ["node", "server.js"]

Key decisions:

  • Alpine base — minimal OS, ~5MB vs ~900MB for the full Debian image
  • Multi-stage build — the final image contains only runtime artifacts, not build tools or source code
  • Layer caching — dependencies are installed in a layer that only rebuilds when package.json changes
  • Non-root user — security best practice, reduces blast radius if the container is compromised

Docker Compose for Local Development

A docker-compose.yml for local development should:

  • Mount your source code as a volume so you get live reloading
  • Use environment variable files
  • Wire up dependencies (databases, caches) with health checks
services:
  app:
    build:
      context: .
      target: builder  # Use the builder stage for development
    volumes:
      - .:/app
      - /app/node_modules  # Don't override node_modules with local
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
    env_file:
      - .env.local
    depends_on:
      postgres:
        condition: service_healthy
    command: npm run dev

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

The condition: service_healthy in depends_on is important: the app won't start until Postgres is ready to accept connections. Without this, your app often tries to connect before Postgres has initialized.

Understanding Layer Caching

Docker builds images layer by layer. Each RUN, COPY, and ADD instruction creates a layer. If a layer's contents haven't changed, Docker reuses the cached layer instead of rebuilding it.

This means instruction order matters:

# Bad — code copy invalidates the cache before npm install
COPY . .
RUN npm install

# Good — package.json change is rare; code change is frequent
COPY package*.json ./
RUN npm install
COPY . .

In the "bad" version, every code change causes npm install to re-run. In the "good" version, npm install only re-runs when package.json or package-lock.json changes — which is far less frequent.

.dockerignore

The .dockerignore file works like .gitignore but for the Docker build context. Always include it:

node_modules
.next
.env
.env.local
.env*.local
*.log
.git
.gitignore
README.md
docker-compose*.yml

Without .dockerignore, Docker sends your entire project directory to the build daemon — including node_modules, which can be hundreds of megabytes. This slows down every build.

Healthchecks in Production

A container marked as "running" by Docker isn't necessarily ready to serve traffic. Healthchecks tell Docker (and orchestrators like Kubernetes or ECS) when a container is actually healthy:

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

Your /api/health endpoint should check that the application is ready: database connected, caches warm, migrations run. A 200 response means healthy; anything else means unhealthy.

Docker will restart an unhealthy container automatically. Orchestrators use healthchecks to decide whether to route traffic to a container.

The Commands That Matter Day to Day

# Build the image
docker compose build

# Start all services in the background
docker compose up -d

# Stream logs from all services
docker compose logs -f

# Stream logs from one service
docker compose logs -f app

# Run a one-off command in a running container
docker compose exec app sh

# Stop all services
docker compose down

# Stop and remove volumes (destructive — wipes the database)
docker compose down -v

# Rebuild without cache
docker compose build --no-cache

The flag to remember: docker compose up -d --build rebuilds images before starting. Use this after changing the Dockerfile or when you want to ensure the running container reflects the current code.

When to Use Docker and When Not To

Docker is worth the overhead for:

  • Applications with complex dependencies (multiple services, specific OS packages)
  • Teams where environment parity matters
  • Anything that will run in a container in production

Docker adds friction for:

  • Simple scripts and utilities that run fine on any machine
  • Development workflows where native tooling is significantly faster (some Go and Rust builds)
  • Learning a new language where simplicity matters more than environment parity

For most web application development, Docker is worth it. Set it up once, get consistent environments forever.

Written by

Zikri Akmal Santoso

Software Engineer

More Articles