Why Default Dockerfiles Are Insecure
Most Dockerfiles written to "just work" share three common security problems:
1. Running as root
When a container process runs as root, a container breakout gives an attacker immediate host-level access.
2. Bloated images
Build tools and dev dependencies mixed into production images expand the attack surface. More packages = more CVEs = more noise in vulnerability scans.
3. Secrets leaking into images
COPY . . copies your .env and API keys into the image. ARG SECRET_KEY leaks into build logs. Both are common and dangerous.
Claude Code with security rules in CLAUDE.md solves all three — Dockerfile generation, docker-compose security settings, and CI scanning become consistently secure outputs.
Writing Docker Security Rules in CLAUDE.md
Add the following to your project's CLAUDE.md:
## Docker Security Rules
### Mandatory Requirements
- **Non-root user**: All containers MUST run as a non-root user
- `RUN useradd -r -u 1001 appuser && chown -R appuser:appuser /app`
- `USER appuser`
- **Multi-stage build**: Only copy build artifacts to the production image
- `FROM node:20-alpine AS builder` → `FROM gcr.io/distroless/nodejs20-debian12`
- **.dockerignore required**: Exclude `.env`, `*.key`, `*.pem`, `node_modules`, `.git`
- **Distroless images preferred**: `gcr.io/distroless/nodejs20-debian12` (no shell)
- **Pinned versions**: `FROM node:20.12.0-alpine3.19` (no `latest` tags)
- **No secrets in build args**: Never pass API keys via `ARG` / `ENV`. Use runtime mounts.
### Security Checklist (verify on every Dockerfile generation)
- [ ] No root execution
- [ ] No build dependencies in production image
- [ ] .dockerignore exists
- [ ] Base image version is pinned
- [ ] HEALTHCHECK is defined
Once this is in CLAUDE.md, every Dockerfile Claude generates will follow these rules automatically.
Prompt: Generate a Secure Dockerfile
Generate a production Dockerfile for Node.js 20.
Requirements:
- Multi-stage build (npm ci --production in builder, distroless or alpine for production)
- Non-root user (uid 1001)
- No secrets in build args
- Include HEALTHCHECK
- Also generate .dockerignore (.env, node_modules, .git, *.key, *.pem excluded)
App: Express.js API on port 3000
Generated Dockerfile:
# ---- Build Stage ----
FROM node:20.12.0-alpine3.19 AS builder
WORKDIR /app
# Copy dependencies first for layer cache optimization
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
# ---- Production Stage ----
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
# Run as non-root
USER nonroot:nonroot
# Copy only build artifacts from builder
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=builder --chown=nonroot:nonroot /app/src ./src
COPY --from=builder --chown=nonroot:nonroot /app/package.json .
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/nodejs/bin/node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
CMD ["src/index.js"]
.dockerignore:
.env
.env.*
node_modules
.git
*.key
*.pem
*.log
.DS_Store
coverage/
.nyc_output/
Distroless images have no shell, so docker exec intrusion is impossible. That's the ideal security profile for production.
Prompt: Harden docker-compose.yaml
Add security hardening to docker-compose.yaml.
Services: app (Node.js API), db (PostgreSQL), redis
Requirements:
- read_only: true to make filesystem read-only
- no-new-privileges to prevent setuid binary abuse
- Memory and CPU limits (app: 512MB/0.5CPU, db: 1GB/1CPU)
- Drop all capabilities (cap_drop: ALL)
- Network isolation (separate frontend and backend networks)
- Secrets via docker secrets or env file references
Generated security configuration:
services:
app:
build: .
read_only: true
tmpfs:
- /tmp
- /app/logs # Only writable directories via tmpfs
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Only add required capabilities
deploy:
resources:
limits:
memory: 512M
cpus: "0.5"
env_file:
- .env.production # gitignored env file
networks:
- frontend
- backend
db:
image: postgres:16.2-alpine3.19
read_only: true
tmpfs:
- /tmp
- /var/run/postgresql
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
deploy:
resources:
limits:
memory: 1G
cpus: "1.0"
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db_data:/var/lib/postgresql/data
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # No external access
secrets:
db_password:
file: ./secrets/db_password.txt # gitignored
volumes:
db_data:
read_only: true with tmpfs prevents runtime filesystem tampering. The internal: true network completely isolates DB and Redis from the internet.
Prompt: CI Vulnerability Scanning
Set up Docker image vulnerability scanning in GitHub Actions.
Requirements:
- Scan with both trivy and grype
- Fail CI if HIGH/CRITICAL vulnerabilities found
- Upload results to GitHub Security Alerts as SARIF
- Post summary to PR comment
- Target: built Docker image
Generated .github/workflows/container-scan.yml:
name: Container Security Scan
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
security-events: write
pull-requests: write
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t app:${{ github.sha }} .
# Trivy scan — SARIF upload to GitHub Security Alerts
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: app:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: "1" # Fail on HIGH/CRITICAL
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
# Grype scan — table summary as PR comment
- name: Run Grype scanner
uses: anchore/scan-action@v3
id: grype
with:
image: app:${{ github.sha }}
severity-cutoff: high
output-format: table
- name: Post scan summary to PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Container Security Scan Results\n\`\`\`\n${{ steps.grype.outputs.table }}\n\`\`\``
})
Trivy integrates directly with GitHub Security Alerts via SARIF. Grype provides a clean table summary in PR comments. Running both minimizes missed vulnerabilities.
Summary
With Docker security rules in CLAUDE.md, you get:
- Dockerfile generation: Multi-stage + distroless + non-root become the default
-
docker-compose hardening:
read_only/no-new-privileges/ network isolation as standard - CI vulnerability scanning: Trivy + Grype catch HIGH/CRITICAL issues on every PR
Write CLAUDE.md once, and every team member generates secure configurations automatically. Security knowledge lives in the repo, not in individual developers' heads.
I've packaged a Security Pack (3 skills: /security-check, /secret-scanner, /deps-check) for automated Dockerfile and configuration security checks.
Available at prompt-works.jp — Security Pack ¥1,480
Top comments (0)