Docker for Backend Developers: Containerize Your API (2026)
A practical Docker tutorial for backend developers — Dockerfile, docker-compose with a database, multi-stage builds, and when to use Docker vs bare metal vs Kubernetes.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
The first time I containerized a Node.js API, I thought I understood Docker. I'd written a Dockerfile, built an image, and got it running. What I didn't realize until production was that my image was 1.4GB (because I included dev dependencies and TypeScript source files), my database credentials were baked into the image, and I had no idea how to handle database migrations in a container context.
This tutorial is what I wish I'd had then. We're going to build a production-appropriate Docker setup for a Node.js API with a PostgreSQL database — the right way.
Understanding What Docker Actually Does
Docker packages your application and its runtime dependencies into a container — an isolated process that runs the same way everywhere. The two things that matter most for backend developers:
- Images are the blueprint — a layered filesystem built from a Dockerfile
- Containers are running instances of images
The practical benefit: "it works on my machine" stops being a problem. Your development environment, CI/CD pipeline, and production server all run the same image.
Your First Dockerfile
Start with a Node.js API. Here's a naive Dockerfile that gets it working:
# Naive Dockerfile — works but has problems
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "src/index.js"]
This works but has three significant problems: it copies everything (including .git, node_modules, test files), installs dev dependencies, and runs as root. Let's fix it properly.
A Production-Ready Multi-Stage Dockerfile
# Multi-stage build for a TypeScript Node.js API
# Stage 1: Install all dependencies and build TypeScript
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first — Docker caches this layer
# Only re-runs npm install when package.json or lock file changes
COPY package*.json ./
RUN npm ci --include=dev
# Copy source and build
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Stage 2: Production image — only runtime dependencies
FROM node:20-alpine AS production
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeapp -u 1001
WORKDIR /app
# Copy only production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy compiled JavaScript from builder stage
COPY --from=builder /app/dist ./dist
# Set ownership
RUN chown -R nodeapp:nodejs /app
USER nodeapp
# Health check — Docker will mark container unhealthy if this fails
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
EXPOSE 3000
CMD ["node", "dist/index.js"]
The multi-stage build is critical. The builder stage needs TypeScript and all dev tools. The production stage needs only the compiled JavaScript and production dependencies. Result: an image that's typically 200–400MB instead of 1.2–1.5GB.
Add a .dockerignore file (this is like .gitignore but for Docker):
# .dockerignore
node_modules
dist
.git
.gitignore
.env
.env.*
*.md
coverage
.nyc_output
docker-compose*.yml
Dockerfile*
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
Adding a Health Check Endpoint
Your Dockerfile referenced /health — let's add that to the application:
// src/routes/health.ts
import { Router } from 'express';
import { pool } from '../db';
const router = Router();
router.get('/health', async (req, res) => {
try {
// Check database connectivity
await pool.query('SELECT 1');
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
} catch (err) {
res.status(503).json({
status: 'error',
message: 'Database unavailable',
});
}
});
export default router;
Health checks let Docker (and Kubernetes, load balancers, etc.) know whether your container is actually ready to serve traffic.
Docker Compose: Running Your Full Stack Locally
Docker Compose orchestrates multiple containers — your API, database, cache, and any other services:
# docker-compose.yml
version: '3.8'
services:
api:
build:
context: .
dockerfile: Dockerfile
target: production
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgresql://apiuser:devpassword@postgres:5432/apidb
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET} # Loaded from .env file
depends_on:
postgres:
condition: service_healthy # Wait for DB to be ready
redis:
condition: service_started
restart: unless-stopped
networks:
- api-network
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: apiuser
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: apidb
volumes:
- postgres-data:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U apiuser -d apidb"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432" # Only expose in development
networks:
- api-network
redis:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
networks:
- api-network
# Development-only: run migrations before starting
migrate:
build:
context: .
target: builder
command: npx prisma migrate deploy
environment:
DATABASE_URL: postgresql://apiuser:devpassword@postgres:5432/apidb
depends_on:
postgres:
condition: service_healthy
networks:
- api-network
profiles: ["migrate"] # Only runs when explicitly invoked
networks:
api-network:
driver: bridge
volumes:
postgres-data:
redis-data:
# Start everything
docker compose up -d
# Run migrations
docker compose --profile migrate run migrate
# View logs from all services
docker compose logs -f
# View logs from a specific service
docker compose logs -f api
# Stop everything (preserves volumes/data)
docker compose down
# Stop and delete volumes (start completely fresh)
docker compose down -v
A .env file for development (never commit this):
JWT_SECRET=dev-jwt-secret-change-in-production
POSTGRES_PASSWORD=devpassword
Development vs Production Compose
Keep a separate docker-compose.dev.yml for development:
# docker-compose.dev.yml — extends docker-compose.yml
version: '3.8'
services:
api:
build:
target: builder # Use builder stage for hot reloading
command: npx ts-node-dev --respawn src/index.ts
volumes:
- ./src:/app/src # Mount source for live reloading
environment:
NODE_ENV: development
LOG_LEVEL: debug
ports:
- "9229:9229" # Node.js debugger port
# Development with hot reloading
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
Handling Database Migrations in Production
This is where many Docker tutorials fall short. You can't run migrations inside your API container on startup — race conditions between instances, migration failures crashing the app, and rollback complexity all become problems.
The clean pattern: run migrations as a separate step in your CI/CD pipeline before deploying the new container:
# GitHub Actions example — .github/workflows/deploy.yml
- name: Run database migrations
run: |
docker run --rm \
--network host \
-e DATABASE_URL=${{ secrets.DATABASE_URL }} \
${{ env.IMAGE }}:${{ env.SHA }} \
npx prisma migrate deploy
- name: Deploy new container
run: docker compose up -d api # Only deploys after migrations succeed
For local development with Docker Compose, the migrate service with profiles: ["migrate"] pattern shown above lets you run migrations explicitly without them running on every up.
Comparison: Bare Metal vs Docker vs Kubernetes
| Aspect | Bare Metal | Docker + Compose | Kubernetes |
|---|---|---|---|
| Setup complexity | Low | Medium | High |
| Local dev experience | Best | Very Good | Complex |
| Deployment consistency | Poor | Excellent | Excellent |
| Scaling | Manual | Manual (single host) | Automatic |
| Resource overhead | None | Low (~50MB per container) | Medium (control plane) |
| Best for | Prototypes, tiny apps | Most production apps | 10+ services, high scale |
| Cost | Low | Low–Medium | Medium–High |
| Learning curve | Low | Medium | High |
For most backend developers reading this, Docker Compose is the right choice. Kubernetes adds real value when you have many services, need auto-scaling, or are operating in a large team with multiple environments. For a single API + database, Kubernetes is overhead you don't need.
Managed container services — Fly.io, Railway, Google Cloud Run, AWS Fargate — give you Docker's consistency with less operational overhead than self-managed Kubernetes. Worth considering if you don't want to manage infrastructure.
Useful Docker Commands for Day-to-Day Development
# Build and tag an image
docker build -t my-api:latest .
# Run a container from an image (without compose)
docker run -d \
--name my-api \
-p 3000:3000 \
-e DATABASE_URL="postgresql://..." \
my-api:latest
# Get a shell inside a running container
docker exec -it my-api sh
# Check resource usage
docker stats
# View all images and their sizes
docker images
# Remove unused images (free disk space)
docker image prune -a
# Remove stopped containers
docker container prune
# Copy a file from a container (useful for extracting logs)
docker cp my-api:/app/logs/error.log ./error.log
Connecting to Your PostgreSQL Container
During development, you'll often want to connect to your containerized PostgreSQL with a GUI tool like TablePlus or pgAdmin:
# Port 5432 is exposed in docker-compose.yml for dev
# Connect with:
# Host: localhost
# Port: 5432
# Database: apidb
# User: apiuser
# Password: devpassword
# Or use psql directly inside the container
docker exec -it <postgres-container-name> psql -U apiuser -d apidb
For your Node.js application, use postgres (the service name) as the hostname — Docker's internal networking resolves service names to container IPs automatically.
Wrapping Up
Docker has become a fundamental skill for backend developers, not a DevOps specialty. The ability to define your application's environment in code, ensure consistency between development and production, and spin up a full stack with a single command — these are productivity improvements you'll feel every day.
Start with the multi-stage Dockerfile to keep images small and secure. Use Docker Compose for local development with your full stack. Add health checks from the beginning. Handle migrations as a separate CI step. Don't over-engineer toward Kubernetes until you actually need it.
From here, the next steps are understanding how to ship your Docker containers — connecting Docker to your CI/CD pipeline, pushing images to a registry, and deploying to a cloud provider. Our web dev roadmap 2026 covers where Docker fits in the broader deployment picture.
If you're building the Node.js API to containerize, check our JWT authentication tutorial and Prisma ORM tutorial for the application code that goes inside these containers.
Frequently Asked Questions
Do I need Kubernetes if I'm using Docker?
Not necessarily. Docker Compose handles multi-container applications on a single server effectively. Kubernetes adds value when you need to run containers across multiple servers, auto-scale based on load, or manage dozens of services. Most small-to-medium applications are better served by Docker Compose or a managed container service like Cloud Run, Railway, or Fly.io.
What is a multi-stage Docker build and why does it matter?
Multi-stage builds use multiple FROM instructions in a Dockerfile to separate build-time tools from the runtime image. The final image only contains what's needed to run the application — not TypeScript, build tools, dev dependencies, or source files. This typically reduces image size by 60–80% and reduces the attack surface.
How do I handle environment variables securely in Docker?
Never bake secrets into Docker images. In development, use .env files with docker-compose (and add .env to .gitignore). In production, use Docker secrets, Kubernetes secrets, or a secrets manager like AWS Secrets Manager, HashiCorp Vault, or your cloud provider's native secrets service. Pass them at runtime, not build time.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How to Use Docker Compose for Local Dev (Node.js + PostgreSQL)
Set up a full local dev environment with Docker Compose, Node.js, PostgreSQL, and pgAdmin. Includes .env config, named volumes, healthchecks, and common error fixes.
5 GraphQL Resolver Best Practices (DataLoader, Error Handling)
Write efficient GraphQL resolvers that don't hammer your database. DataLoader N+1 fix, error handling patterns, auth in context, and resolver performance comparison.
7 Logging Strategies for Microservices (ELK, Loki, Fluentd)
Centralized logging for microservices: compare ELK, Loki, Fluentd, and Datadog with real configs, cost breakdown, and 7 battle-tested strategies.
10 SQL Query Optimization Techniques (Indexes, EXPLAIN, Joins)
Speed up slow database queries with 10 proven SQL optimization techniques. Covers EXPLAIN ANALYZE, index types, N+1 in SQL, slow query log setup, and real before/after examples.