Skip to content
Back to skills

Docker Deployment

CSecurity

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

  • 7 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added October 14, 2026
devopstypescriptpythonshellbashsqlnextjsnodenodejsexpressfastapi

Works with

  • vscode
  • cli
  • api

Security analysis

C72/100
  • mediumUses curl or wget to download content
  • criticalAccesses sensitive system or user directories
  • highPerforms destructive filesystem operations
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 14, 2026

npx -y skills add pfangueiro/claude-code-agents --skill docker-deployment --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Docker Deployment?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Docker Deployment
[![Security: C — Skills Directory](https://www.skillsdirectory.com/api/skills/pfangueiro-docker-deployment/badge)](https://www.skillsdirectory.com/skills/pfangueiro-docker-deployment)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: docker-deployment
description: Production-ready Docker configurations, multi-stage builds, and deployment best practices
---

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

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.