Docker Deployment Skill
Provides production-ready Docker configurations, multi-stage builds, and deployment best practices.
Purpose
This skill provides:
- Optimized Dockerfile patterns for different tech stacks
- Multi-stage build strategies
- Docker Compose configurations
- Container security best practices
- Docker registry integration
- Health checks and monitoring
When to Use
- "Create a Dockerfile for Node.js app"
- "Optimize Docker image size"
- "Set up Docker Compose for microservices"
- "Implement Docker health checks"
- "Deploy with Docker Swarm/Kubernetes"
Node.js Dockerfile (Multi-Stage Build)
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (including dev dependencies for build)
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
# Production stage
FROM node:20-alpine AS production
# Add non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
# Copy built artifacts and production dependencies
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Use non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start application
CMD ["node", "dist/index.js"]
Next.js Dockerfile
Prerequisite — output: 'standalone'. Next.js only emits .next/standalone when the app
opts into standalone output. On a default Next app that directory does not exist and the
COPY /app/.next/standalone below fails the build with
"/app/.next/standalone": not found. Set this in next.config.js before building:
// next.config.js
module.exports = {
output: 'standalone',
}
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
# Requires `output: 'standalone'` in next.config.js — see prerequisite above
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
Python FastAPI Dockerfile
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc && \
rm -rf /var/lib/apt/lists/*
# Install into a virtualenv, NOT `pip install --user`.
# `--user` installs to /root/.local, and /root is mode 0700 — after the runtime
# stage switches to a non-root USER the interpreter cannot even traverse it, and
# the container dies on startup with "Permission denied" on the entrypoint.
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
# Copy requirements
COPY requirements.txt .
# Install Python dependencies into the virtualenv
RUN pip install --no-cache-dir -r requirements.txt
# Production stage
FROM python:3.11-slim
WORKDIR /app
# Create the non-root user first, so everything below is copied in already owned by it
RUN useradd -m -u 1001 appuser
# Copy the virtualenv from builder, owned by the user that will actually run it
COPY --from=builder --chown=appuser:appuser /opt/venv /opt/venv
# Copy application code
COPY --chown=appuser:appuser . .
# Put the virtualenv first on PATH so `python` / `uvicorn` resolve to it
ENV PATH=/opt/venv/bin:$PATH
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Docker Compose - Full Stack Application
Two things this template does deliberately, because the checklist below demands them:
credentials are mounted as secret files under /run/secrets/ rather than passed as
environment variables, and every service declares CPU/memory limits
(deploy.resources.limits is honoured by docker compose up in Compose v2, not just Swarm).
Create every host path this file binds — with the right TYPE — before docker compose up.
Compose resolves env_file eagerly and aborts the whole stack if the path is missing, so an
absent api/.env fails before a single container starts. The remaining bind sources fail later
and worse: Docker auto-creates a missing bind source as a directory, so every source that is
meant to be a file gets a directory instead and the container dies with
not a directory: Are you trying to mount a directory onto a file (or vice-versa)?. That failure
is sticky — the bogus directory outlives the failed run, so even writing the correct file
afterwards fails with is a directory and every retry reproduces the same error until it is
removed. Measured on Compose v5.1.2 / Engine 29.4.0, docker compose config exits 0 with all
four of these missing, so it does not work as the pre-flight check.
This file binds six host paths — four files and two directories. Miss any one and the stack does not come up:
| Host source | Type | Bound by |
|---|---|---|
./secrets/postgres_password.txt |
file | secrets.postgres_password → /run/secrets/postgres_password |
./api/.env |
file | api env_file |
./init-db.sql |
file | postgres → /docker-entrypoint-initdb.d/init.sql |
./nginx.conf |
file | nginx → /etc/nginx/nginx.conf |
./api/uploads |
directory | api → /app/uploads |
./ssl |
directory | nginx → /etc/nginx/ssl |
depends_on gates on start, not on readiness — unless you ask for health. A bare
depends_on: [api] means condition: service_started: the dependent is released the moment
the container exists, not when it can serve. Only condition: service_healthy waits, and it
is only as truthful as the dependency's own healthcheck — a probe that exits 0 on a 503
reports healthy and releases dependents against a dead service. Measured on Docker Compose
v5.1.2 / Engine 29.4.0: service_healthy against a dependency that declares no healthcheck
is not silently downgraded — Compose fails closed with
dependency failed to start: container ... has no healthcheck configured. So the hazard here
is never a missing healthcheck; it is a lying one. That is why the api probe below uses
curl -f.
# 1. Secret — chmod 0600, gitignored, never baked into an image.
# Generate ONLY if absent. This block doubles as the repair procedure for a
# half-created stack, so it gets re-run — and an unguarded `>` would mint a new
# password while the existing `postgres-data` volume still holds the old one.
# Postgres only reads POSTGRES_PASSWORD when it initialises an EMPTY data
# directory, so the app then fails auth while `pg_isready` still reports healthy.
mkdir -p secrets
if [ ! -s secrets/postgres_password.txt ]; then
openssl rand -base64 32 > secrets/postgres_password.txt
fi
chmod 0600 secrets/postgres_password.txt
# 2. The directory-type bind sources (this also creates ./api for the env file below).
mkdir -p api/uploads ssl
# 3. The file-type bind sources. `rmdir` undoes a directory left behind by an earlier
# failed run and — unlike `rm -rf` — refuses to delete anything that is not empty.
for f in api/.env init-db.sql; do
if [ -d "$f" ]; then rmdir "$f"; fi
[ -e "$f" ] || : > "$f"
done
# 4. nginx.conf must be a VALID config, not merely present: nginx exits with
# `[emerg] no "events" section in configuration` on an empty file, so `touch` here
# only trades one startup failure for another. This placeholder starts clean —
# replace it with your real proxy rules. Do not add an `upstream` block until those
# services are actually up: nginx resolves upstream hostnames at startup and exits
# with `host not found in upstream "frontend:3000"` when they are not.
if [ -d nginx.conf ]; then rmdir nginx.conf; fi
if [ ! -s nginx.conf ]; then
cat > nginx.conf <<'NGINX'
events {}
http {
server {
listen 80;
location / { return 200 "nginx up - replace ./nginx.conf with your real config\n"; }
}
}
NGINX
fi
# 5. Assert what actually breaks. `docker compose config` does not check any of this.
rc=0
for p in secrets/postgres_password.txt api/.env init-db.sql nginx.conf; do
[ -f "$p" ] || { echo "NOT A FILE: $p"; rc=1; }
done
for p in api/uploads ssl; do
[ -d "$p" ] || { echo "NOT A DIR: $p"; rc=1; }
done
[ "$rc" -eq 0 ] && echo "all bind sources present with the right type"
Recovering a stack that already failed this way takes more than deleting the directories.
The nginx failure is the kind one — it is loud, and re-running the block above repairs it. The
postgres one is silent: its entrypoint runs /docker-entrypoint-initdb.d/* only against an
empty PGDATA, so the bad run initialised the database, errored with
psql: ... could not read from input file: Is a directory, and restart: unless-stopped brought
it straight back up reporting healthy — with the schema never applied. Measured: after
replacing the directory with real SQL and up -d --force-recreate, the seeded table was still
absent; only docker compose down -v (which drops the postgres-data volume) let it apply. A
green docker compose ps is not evidence that your init script ran.
services:
# Frontend (Next.js)
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://api:4000
depends_on:
api:
condition: service_healthy
networks:
- app-network
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 256M
# Backend API (Node.js)
api:
build:
context: ./api
dockerfile: Dockerfile
ports:
- "4000:4000"
# Non-secret config only. Keep this file gitignored and .dockerignore'd.
env_file:
- ./api/.env
environment:
- REDIS_URL=redis://redis:6379
- NODE_ENV=production
# No credentials in the DSN. The app reads the password from the mounted
# secret file and assembles the connection string at startup.
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_NAME=myapp
- DATABASE_USER=postgres
- DATABASE_PASSWORD_FILE=/run/secrets/postgres_password
secrets:
- postgres_password
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# Only exit code 0 is healthy. `-f` makes curl exit 22 on any >= 400 response,
# `-S` keeps the reason on stderr for `docker inspect`, and `|| exit 1`
# normalises every failure to one documented value. A probe WITHOUT `-f`
# (plain `curl -s`, or an http.get with no status check) exits 0 on a 503 —
# Compose then marks this container healthy and releases `frontend` against a
# dead API. See the `depends_on` note above.
#
# curl must exist INSIDE the api image: node:*-alpine ships busybox wget only
# and python:*-slim ships neither. Either add `RUN apk add --no-cache curl` to
# the api Dockerfile, or probe with a runtime already in the image, e.g.
# ["CMD","node","-e","require('http').get('http://localhost:4000/health',r=>process.exit(r.statusCode===200?0:1))"]
# `CMD-SHELL` also needs a shell, so distroless images must use the exec form.
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 30s
networks:
- app-network
restart: unless-stopped
volumes:
- ./api/uploads:/app/uploads
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
memory: 512M
# PostgreSQL Database
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_DB=myapp
# The official image reads the password from this file instead of POSTGRES_PASSWORD
- POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
secrets:
- postgres_password
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
networks:
- app-network
restart: unless-stopped
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
reservations:
memory: 1G
# Redis Cache
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- app-network
restart: unless-stopped
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
reservations:
memory: 256M
# Nginx Reverse Proxy
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
# Bare list = `condition: service_started`: nginx starts as soon as these
# containers exist, NOT when they can serve. Neither declares a healthcheck,
# so `service_healthy` cannot be used here until one is added — Compose fails
# closed on a healthcheck-less dependency (see the depends_on note above).
- frontend
- api
networks:
- app-network
restart: unless-stopped
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
reservations:
memory: 128M
networks:
app-network:
driver: bridge
volumes:
postgres-data:
redis-data:
secrets:
# Local/dev: a gitignored file, mounted read-only at /run/secrets/postgres_password.
# Swarm or a managed platform: swap for `external: true` and a real secret store
# (Vault, AWS Secrets Manager, SOPS) — never commit the value.
postgres_password:
file: ./secrets/postgres_password.txt
Docker Security Best Practices
Minimal Base Image
# Use distroless for minimal attack surface
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/index.js"]
Multi-Layer Security
FROM node:20-alpine
# Security: Run as non-root
RUN addgroup -g 1001 app && \
adduser -D -u 1001 -G app app
WORKDIR /app
# Security: Use specific versions
COPY package*.json ./
RUN npm ci --only=production && \
npm cache clean --force
# Security: Set file permissions
COPY --chown=app:app . .
# The writable data dir must exist AND be owned by `app` before USER, because a
# named volume inherits the ownership of the image path it is mounted over. Skip
# this and the volume lands root-owned and the non-root process cannot write to it.
RUN mkdir -p /app/data && chown app:app /app/data
# Security: Run as the non-root user created above.
# This does NOT drop Linux capabilities. Measured: with only `USER app` the
# bounding set is unchanged (CapBnd 00000000a80425fb); running as non-root merely
# empties the *effective* set, and the container can regain those caps via a
# setuid or file-capability binary. Dropping them is a runtime control — see below.
USER app
# Declares the writable data dir. VOLUME does NOT make the filesystem read-only.
# Measured on this image with no runtime flags, the app user can still write /tmp,
# /var/tmp and /dev/shm — and because `COPY --chown=app:app` handed it ownership of
# its own code it can overwrite /app/index.js and persist across a restart.
# A read-only rootfs is a runtime control — see below.
VOLUME ["/app/data"]
EXPOSE 3000
CMD ["node", "index.js"]
Capability dropping and a read-only rootfs cannot be set in a Dockerfile. They are runtime controls, so a Dockerfile comment claiming them delivers nothing. Apply them where the container is actually started, or they are not in effect:
# docker compose — verified to yield CapBnd 0000000000000000, NoNewPrivs 1,
# a read-only rootfs, and a writable /app/data + /tmp
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
read_only: true
tmpfs: ["/tmp"]
volumes:
- appdata:/app/data
# docker run equivalent
docker run --cap-drop=ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp -v appdata:/app/data myapp:latest
Verify rather than assume — a hardening flag that silently failed to apply is the same as not setting it:
docker run --rm --cap-drop=ALL --security-opt no-new-privileges:true --read-only myapp:latest \
sh -c 'grep -E "^(CapBnd|NoNewPrivs)" /proc/self/status'
# Expect: CapBnd 0000000000000000 and NoNewPrivs 1
.dockerignore
# Dependencies
node_modules
npm-debug.log
# Testing
coverage
.jest
*.test.js
# Environment & secrets
.env
.env.local
.env.*.local
secrets/
# Git
.git
.gitignore
# CI/CD
.github
.gitlab-ci.yml
# Documentation
README.md
docs/
# Build artifacts
dist
build
*.log
# IDE
.vscode
.idea
*.swp
Docker Registry & CI/CD
GitHub Container Registry
# .github/workflows/docker-publish.yml
name: Docker Build & Push
on:
push:
branches: [ main ]
tags: [ 'v*' ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Health Checks
Node.js Health Endpoint
// health.ts
export function setupHealthCheck(app: Express) {
app.get('/health', async (req, res) => {
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
database: await checkDatabase(),
redis: await checkRedis(),
memory: process.memoryUsage(),
}
const isHealthy = checks.database && checks.redis
res.status(isHealthy ? 200 : 503).json(checks)
})
}
async function checkDatabase(): Promise<boolean> {
try {
await db.raw('SELECT 1')
return true
} catch {
return false
}
}
async function checkRedis(): Promise<boolean> {
try {
await redis.ping()
return true
} catch {
return false
}
}
Monitoring & Logging
Docker Compose with Logging
services:
app:
image: myapp:latest
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
labels:
- "prometheus.scrape=true"
- "prometheus.port=9090"
Prometheus Metrics Endpoint
// metrics.ts
import promClient from 'prom-client'
const register = new promClient.Registry()
promClient.collectDefaultMetrics({ register })
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_ms',
help: 'Duration of HTTP requests in ms',
labelNames: ['method', 'route', 'status_code'],
buckets: [10, 50, 100, 500, 1000, 5000],
})
register.registerMetric(httpRequestDuration)
export function setupMetrics(app: Express) {
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType)
res.end(await register.metrics())
})
}
Best Practices Checklist
- ✅ Use multi-stage builds to minimize image size
- ✅ Run containers as non-root user
- ✅ Use specific base image versions (not
latest) - ✅ Implement health checks
- ✅ Set resource limits (CPU/memory)
- ✅ Use
.dockerignoreto exclude unnecessary files - ✅ Scan images for vulnerabilities (Snyk, Trivy)
- ✅ Use secrets management (not env vars for sensitive data)
- ✅ Implement proper logging
- ✅ Add monitoring and metrics
Integration with Agents
Works best with:
- devops-automation agent - Generates Docker configs
- security-auditor agent - Scans for container vulnerabilities
- performance-optimizer agent - Optimizes image size and startup time
Tools & Resources
- Docker: Official container platform
- Docker Compose: Multi-container orchestration
- Trivy: Vulnerability scanner
- Dive: Image layer analyzer
- Hadolint: Dockerfile linter