agentleFS
pfangueiropfangueiro/claude-code-agents/.claude/skills/docker-deployment/SKILL.md

docker-deployment

Skill7 starsChanged 20 days agoMITView on GitHub

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

  • Reads credentials
  • Deletes or force-pushes
  • Installs packages

4 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 1 is distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Verify rather than assume — a hardening flag that silently failed to apply is the same as not setting it:

    line 542

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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 sourceTypeBound by
./secrets/postgres_password.txtfilesecrets.postgres_password → /run/secrets/postgres_password
./api/.envfileapi env_file
./init-db.sqlfilepostgres → /docker-entrypoint-initdb.d/init.sql
./nginx.conffilenginx → /etc/nginx/nginx.conf
./api/uploadsdirectoryapi → /app/uploads
./ssldirectorynginx → /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 .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

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.