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.jsonchanges - 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.