Docker for JavaScript Developers: A Practical Introduction
“It works on my machine.”
I said this so many times that my team lead finally snapped: “Then we’ll ship your machine.”
Spoiler: We didn’t ship my machine. We learned Docker.
Here’s why Docker matters, even if you’re “just a JavaScript developer.”
The Problem: Environment Hell
I had a Node.js app that worked perfectly locally:
- Node.js v18.12.0
- PostgreSQL 14
- Redis for caching
- Specific environment variables
Then I deployed to staging.
Nothing worked.
Why:
- Staging server had Node.js v16.14.0
- PostgreSQL was version 13
- Redis wasn’t installed
- Environment variables were different
I spent 6 hours debugging. The bug? A Node.js version difference broke a dependency. Six hours.
Docker solves this: Your app runs in the same environment everywhere. No surprises.
What is Docker? (Without the Jargon)
Docker is a way to package your app with everything it needs:
- Your code
- Node.js runtime
- Dependencies (npm packages)
- System libraries
- Environment configuration
This package (called a “container”) runs the same on your laptop, your coworker’s laptop, staging, and production.
Think of it like this:
- Without Docker: “Here’s my code. Install Node.js v18, PostgreSQL 14, Redis, set these env vars, then run
npm start. Good luck.” - With Docker: “Run this container. Everything is already configured.”
My First Dockerfile (And What I Learned)
I’ll show you the wrong way first (because that’s how I learned).
Version 1: The Naive Approach
FROM node:18
# Copy everything
COPY . /app
# Set working directory
WORKDIR /app
# Install dependencies
RUN npm install
# Run the app
CMD ["npm", "start"]
Run it:
docker build -t my-app .
docker run -p 3000:3000 my-app
This worked! But it was slow. Every time I changed one line of code, Docker reinstalled ALL dependencies. Build time: 3 minutes.
Version 2: Layer Caching (The Fix)
Docker builds in layers. If a layer hasn’t changed, Docker reuses the cached version.
FROM node:18
WORKDIR /app
# Copy package files FIRST
COPY package*.json ./
# Install dependencies (cached if package.json unchanged)
RUN npm install
# Copy the rest of the code
COPY . .
# Run the app
CMD ["npm", "start"]
Now if I only change code (not package.json), Docker reuses the npm install layer.
Build time: 5 seconds (instead of 3 minutes).
Lesson: Order matters in Dockerfiles. Copy the things that change least first.
Version 3: Production Optimizations
By this point I’d learned a few things from production deployments. Here’s what a real production Dockerfile looks like:
FROM node:18-alpine # Smaller image (alpine is minimal Linux)
WORKDIR /app
# Install only production dependencies
COPY package*.json ./
RUN npm ci --only=production # 'npm ci' is faster and more reliable than 'npm install'
# Copy application code
COPY . .
# Don't run as root (security)
USER node
# Expose the port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if(r.statusCode !== 200) throw new Error()})"
CMD ["node", "server.js"]
What changed:
node:18-alpine- 200MB smaller thannode:18npm ci --only=production- Faster, skips dev dependenciesUSER node- Security best practice (don’t run as root)- Health check - Docker can detect if the app crashes
Image size: 500MB → 150MB
Worth it.
Docker Compose: Multi-Container Apps
My app needed Node.js, PostgreSQL, and Redis. Running them separately was annoying.
Docker Compose lets you define all containers in one file:
# docker-compose.yml
version: '3.8'
services:
# Node.js app
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
- REDIS_URL=redis://redis:6379
depends_on:
- db
- redis
# PostgreSQL
db:
image: postgres:14
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp
volumes:
- postgres_data:/var/lib/postgresql/data
# Redis
redis:
image: redis:7-alpine
volumes:
postgres_data:
Start everything:
docker-compose up
One command. App, database, cache all running.
Stop everything:
docker-compose down
No more “did you start Redis?” or “what’s the database password?”
Real Use Cases from My Projects
Use Case 1: Onboarding New Developers
Before Docker:
1. Install Node.js v18.12.0
2. Install PostgreSQL 14
3. Install Redis
4. Clone the repo
5. Copy .env.example to .env
6. Edit .env with local database credentials
7. Run migrations
8. Install dependencies
9. Pray it works
New developers took half a day to get set up. Sometimes a full day if they hit issues.
With Docker:
git clone [repo]
docker-compose up
Setup time: 2 minutes. Works on Mac, Windows, Linux.
Use Case 2: Testing Different Node.js Versions
We needed to support Node.js 16, 18, and 20. Testing locally meant switching Node versions constantly (using nvm or similar).
With Docker:
# Test on Node 16
docker run -v $(pwd):/app -w /app node:16 npm test
# Test on Node 18
docker run -v $(pwd):/app -w /app node:18 npm test
# Test on Node 20
docker run -v $(pwd):/app -w /app node:20 npm test
No switching. Run tests on all versions in parallel in CI/CD.
Use Case 3: Consistent Deployments
We deployed to three environments: staging, production, and a client demo server.
Each had different versions of system libraries, which caused weird bugs.
With Docker: The SAME container image runs in all environments. If it works in staging, it works in production.
# Build once
docker build -t myapp:1.2.3 .
# Push to registry
docker push myregistry.com/myapp:1.2.3
# Deploy to staging
ssh staging "docker pull myregistry.com/myapp:1.2.3 && docker run -d myapp:1.2.3"
# Deploy to production (same image)
ssh production "docker pull myregistry.com/myapp:1.2.3 && docker run -d myapp:1.2.3"
Common Gotchas I Ran Into
1. Volumes for Hot Reload
During development, I wanted hot reload (code changes reload the app automatically).
Problem: Code is copied INTO the container at build time. Changing code locally doesn’t affect the container.
Solution: Mount your code as a volume:
services:
app:
build: .
volumes:
- .:/app # Mount current directory into /app
- /app/node_modules # Don't overwrite node_modules
command: npm run dev # Run in dev mode with hot reload
Now changes to local files instantly update in the container.
2. Database Persistence
I ran docker-compose down and lost all my database data.
All of it. Three days of test data, gone.
Problem: Container data is ephemeral (deleted when container stops).
Solution: Use volumes for persistent data:
services:
db:
image: postgres:14
volumes:
- postgres_data:/var/lib/postgresql/data # Persists data
volumes:
postgres_data: # Named volume (managed by Docker)
Now data survives container restarts.
3. .dockerignore File
My Docker build copied node_modules (30,000 files) into the container, then npm install reinstalled them.
Solution: Add .dockerignore:
node_modules
npm-debug.log
.git
.env
dist
coverage
Like .gitignore, but for Docker. Build time dropped from 2 minutes to 10 seconds.
4. Port Conflicts
I tried to run two projects simultaneously. Both used port 3000. Docker said “port already in use.”
Solution: Map to different host ports:
# Project 1
services:
app:
ports:
- "3000:3000"
# Project 2
services:
app:
ports:
- "3001:3000" # Host port 3001 -> container port 3000
Project 1 at localhost:3000, Project 2 at localhost:3001.
Docker Commands I Use Daily
Building and Running
# Build an image
docker build -t my-app .
# Run a container
docker run -p 3000:3000 my-app
# Run in detached mode (background)
docker run -d -p 3000:3000 my-app
# Run with environment variables
docker run -e NODE_ENV=production -p 3000:3000 my-app
Managing Containers
# List running containers
docker ps
# List all containers (including stopped)
docker ps -a
# Stop a container
docker stop <container_id>
# Remove a container
docker rm <container_id>
# View logs
docker logs <container_id>
# Follow logs in real-time
docker logs -f <container_id>
Docker Compose
# Start all services
docker-compose up
# Start in background
docker-compose up -d
# Stop all services
docker-compose down
# View logs
docker-compose logs
# Rebuild images
docker-compose up --build
Debugging
# Execute command in running container
docker exec -it <container_id> bash
# Inspect container details
docker inspect <container_id>
# View resource usage
docker stats
What I Learned (And What Changed)
Before Docker:
- “Works on my machine” was a meme AND a real problem
- New developers took hours to set up
- Deployments were stressful (will it work in production?)
- Different environments had different bugs
After Docker:
- Onboarding: 2 minutes
- Deployments: predictable (same container everywhere)
- Development environment matches production
- Testing on multiple Node versions: trivial
Metrics:
- Developer onboarding time: 4 hours → 2 minutes
- Deployment bugs from environment differences: ~40% reduction
- Time spent debugging “works on my machine”: Near zero
My Docker Workflow Today
Development
# Start the app with hot reload
docker-compose up
# Run tests in a container
docker-compose run app npm test
# Access database directly
docker-compose exec db psql -U postgres -d myapp
CI/CD (GitHub Actions)
name: CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build Docker image
run: docker build -t my-app .
- name: Run tests
run: docker run my-app npm test
- name: Push to registry
run: |
echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
docker tag my-app myregistry.com/my-app:${{ github.sha }}
docker push myregistry.com/my-app:${{ github.sha }}
Same Docker image built in CI, tested, then deployed.
Production Deployment
# Pull the tested image
docker pull myregistry.com/my-app:abc123
# Run it
docker run -d \
-p 3000:3000 \
-e NODE_ENV=production \
-e DATABASE_URL=$DATABASE_URL \
--name my-app \
myregistry.com/my-app:abc123
Or use Docker Swarm / Kubernetes for orchestration (but that’s another article).
Common Mistakes I Made
1. Running Everything in Docker Locally
I tried to run my code editor, Git, everything in Docker. It was slow and painful.
Don’t be like me.
Lesson: Docker is for running your app and its dependencies, not your entire dev environment. Keep your editor and Git on your host machine.
2. Not Using Multi-Stage Builds
My production image included dev dependencies, source TypeScript files, etc. Image size: 800MB.
Fix: Multi-stage builds
# Stage 1: Build
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --only=production
CMD ["node", "dist/server.js"]
Image size: 800MB → 180MB.
3. Hardcoding Secrets in Dockerfiles
I did this:
ENV DATABASE_PASSWORD=supersecret123
NEVER DO THIS. Secrets in Docker images can be extracted by anyone with access to the image.
Fix: Pass secrets at runtime:
docker run -e DATABASE_PASSWORD=$DATABASE_PASSWORD my-app
When NOT to Use Docker
Docker isn’t always the answer.
Simple static sites: Just deploy the HTML/CSS/JS. No need for containers.
Serverless functions: AWS Lambda, Netlify Functions already handle runtime environments.
Extremely resource-constrained environments: Docker has overhead.
But for most Node.js apps? Docker is worth it.
If I Could Start Over
I’d learn Docker in my first month of web development, not after two years of “works on my machine” pain.
Seriously, if someone told me “spend a week learning Docker now, save 100+ hours of debugging later,” I’d have done it immediately.
Week 1 curriculum:
- Run a simple Node app in Docker
- Add PostgreSQL with Docker Compose
- Use volumes for hot reload
- Write a production-ready Dockerfile
- Set up CI/CD with Docker
These aren’t advanced topics. They’re fundamentals for modern web development.
And you can learn them in a week.
Your Turn: Dockerize Your App
Take your current Node.js project and try this:
Day 1: Basic Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "start"]
Build it:
docker build -t my-app .
docker run -p 3000:3000 my-app
Day 2: Add docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
Run it:
docker-compose up
Day 3: Add Your Database
services:
app:
# ... existing config
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/mydb
depends_on:
- db
db:
image: postgres:14
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
That’s it. You’re using Docker.
Resources That Helped Me
But honestly? Just start using it. The learning curve is manageable, and the payoff is significant.
Docker has become an essential part of my development workflow. The consistency it provides, from local development to production, eliminates an entire category of bugs and frustrations. It’s one of those investments that keeps paying dividends on every project.