# Docker Deployment

> Production-ready Docker configurations, multi-stage builds, and deployment best practices

- Skill: `pfangueiro/docker-deployment` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pfangueiro/docker-deployment`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pfangueiro/docker-deployment/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: pfangueiro (https://skillmd.com/u/pfangueiro)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/pfangueiro/docker-deployment

---


# 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)

```dockerfile
# 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:

```js
// next.config.js
module.exports = {
  output: 'standalone',
}
```

```dockerfile
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

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

```bash
# 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.

```yaml
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

```dockerfile
# 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

```dockerfile
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:

```yaml
# 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
```

```bash
# 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:

```bash
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

```yaml
# .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

```typescript
// 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

```yaml
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

```typescript
// 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 `.dockerignore` to 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

## References

- [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/)
- [Node.js Docker Guide](https://nodejs.org/en/docs/guides/nodejs-docker-webapp/)
- [Docker Security](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html)

