Zero to Docker: Containerizing a Full-Stack App
Shipping a full-stack app consistently across environments is one of the biggest pain points in development. Docker solves this by packaging your app and all its dependencies into portable containers. In this post I'll containerize a NestJS API + Next.js frontend with Docker Compose, environment configs, and a reverse proxy with Nginx.
Why Docker?
Without Docker:
- "It works on my machine" is a real problem
- Setting up a new dev environment takes hours
- Production servers need manual dependency management
- Scaling is hard
With Docker:
- Every environment runs the same image
- New devs:
docker compose upand they're running - Production deployments are repeatable
- Horizontal scaling is trivial
Project Structure
my-app/
โโโ api/ # NestJS backend
โ โโโ src/
โ โโโ Dockerfile
โ โโโ package.json
โโโ web/ # Next.js frontend
โ โโโ src/
โ โโโ Dockerfile
โ โโโ package.json
โโโ nginx/
โ โโโ default.conf # Reverse proxy config
โโโ docker-compose.yml
โโโ .env
Dockerfile: NestJS API
# api/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production image
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/main"]
๐ก Tip: The multi-stage build pattern keeps your final image tiny. The builder stage with full node_modules never ships to production.
Dockerfile: Next.js Frontend
# web/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Required for Next.js standalone output
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production image
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Enable standalone output in next.config.ts:
const config: NextConfig = {
output: 'standalone',
};
Nginx Reverse Proxy
Nginx sits in front of both services, routing traffic:
# nginx/default.conf
upstream api {
server api:3001;
}
upstream web {
server web:3000;
}
server {
listen 80;
# Route /api/* to NestJS
location /api/ {
proxy_pass http://api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Route everything else to Next.js
location / {
proxy_pass http://web;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Docker Compose
# docker-compose.yml
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
api:
build:
context: ./api
target: production
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${DB_USER}:${DB_PASS}@postgres:5432/${DB_NAME}
JWT_SECRET: ${JWT_SECRET}
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
web:
build:
context: ./web
target: production
restart: unless-stopped
environment:
NEXT_PUBLIC_API_URL: /api
depends_on:
- api
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
- web
volumes:
postgres_data:
Environment Variables
# .env (never commit this!)
DB_USER=myuser
DB_PASS=supersecret
DB_NAME=myapp
JWT_SECRET=your-256-bit-secret-here
Add .env to .gitignore. Use a secrets manager (e.g. AWS Secrets Manager, Vault) in production.
Running It
# Build and start all services in detached mode
docker compose up --build -d
# Check service status
docker compose ps
# View logs
docker compose logs -f api
# Stop everything
docker compose down
# Stop and remove volumes (wipes DB data!)
docker compose down -v
Production Checklist
- Add HTTPS with Let's Encrypt (Certbot + Nginx)
- Set resource limits (
deploy.resources.limits) on each service - Configure log rotation
- Add a
.dockerignoreto each service (excludenode_modules,.next,.git) - Use Docker secrets instead of environment variables for sensitive values
- Set up a container registry (Docker Hub, GHCR, ECR) for CI/CD
Key Takeaways
- Multi-stage builds dramatically reduce image size โ don't ship build tools to production
- Health checks on your database prevent race conditions at startup
- Nginx as a reverse proxy unifies routing and enables SSL termination in one place
depends_onwithcondition: service_healthyensures services start in the right order- Never commit
.envfiles โ use environment-specific secrets management
โ ๏ธ Warning: Docker containers are not VMs. If your container crashes, all in-memory state is lost. Design your services to be stateless, and persist data in named volumes or external databases.