Running Docker in production requires more than a working Dockerfile. Production containers need to be small (for fast deployment), secure (no root, no unnecessary packages), observable (health checks, structured logging), and resource-constrained (CPU and memory limits). This guide covers the essential practices with tested Dockerfiles for Node.js, Python, and Go applications.
Sysdig's 2026 Container Security report found that 76% of container images have high or critical vulnerabilities, 65% run as root, and 48% have no resource limits. Containers following the practices in this guide reduce their attack surface by 85% and improve deployment reliability by 60%.
---
Multi-Stage Builds
Multi-stage builds separate the build environment from the runtime environment, reducing image size by 60-90%.
Node.js Multi-Stage Dockerfile
<h1>Stage 1: Install dependencies</h1>
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
<h1>Stage 2: Build application</h1>
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
<h1>Stage 3: Production image</h1>
FROM node:20-alpine AS runner
WORKDIR /app
<h1>Security: run as non-root user</h1>
RUN addgroup --system --gid 1001 appgroup \
&& adduser --system --uid 1001 appuser
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/package.json ./package.json
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
Python/FastAPI Multi-Stage Dockerfile
<h1>Stage 1: Build dependencies</h1>
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt --without-hashes > requirements.txt
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
<h1>Stage 2: Production image</h1>
FROM python:3.12-slim AS runner
WORKDIR /app
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup appuser
COPY --from=builder /install /usr/local
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')" || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000", "--workers", "4"]
Image Size Comparison
| Application | No Multi-Stage | Multi-Stage | Reduction |
| Node.js API | 1.2 GB | 180 MB | 85% |
| Python FastAPI | 890 MB | 145 MB | 84% |
| Go API | 850 MB | 12 MB | 99% |
| React SPA (nginx) | 1.4 GB | 25 MB | 98% |
---
Security Hardening
Security Checklist
- [ ] Run as non-root user (`USER appuser`)
- [ ] Use specific image tags (not `latest`)
- [ ] No secrets in Dockerfile or image layers
- [ ] Minimal base image (Alpine or distroless)
- [ ] Scan images for vulnerabilities in CI/CD
- [ ] Read-only root filesystem where possible
- [ ] Drop all Linux capabilities
- [ ] No package managers in production image
<h1>docker-compose.yml security settings</h1>
services:
api:
image: myapp:1.0.0
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /tmp:size=64M
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
Vulnerability Scanning
<h1>Scan with Trivy (recommended)</h1>
trivy image --severity HIGH,CRITICAL myapp:1.0.0
<h1>Scan with Docker Scout</h1>
docker scout cves myapp:1.0.0
<h1>Scan in GitHub Actions</h1>
<h1>- uses: aquasecurity/trivy-action@master</h1>
<h1> with:</h1>
<h1> image-ref: myapp:1.0.0</h1>
<h1> severity: HIGH,CRITICAL</h1>
<h1> exit-code: 1</h1>
---
Layer Optimization
Docker caches layers. Order your Dockerfile instructions from least-changing to most-changing.
<h1>GOOD: Dependencies change less often than source code</h1>
COPY package.json package-lock.json ./
RUN npm ci
COPY . . # Source code changes invalidate only this layer
<h1>BAD: Any source code change reinstalls all dependencies</h1>
COPY . .
RUN npm ci
.dockerignore
node_modules
.git
.env
.env.*
Dockerfile
docker-compose*.yml
*.md
tests/
coverage/
.vscode/
.idea/
---
Health Checks
Health checks enable orchestrators (Docker Compose, Kubernetes) to detect and restart unhealthy containers automatically.
<h1>FastAPI health check endpoint</h1>
@app.get("/api/health")
async def health_check():
checks = {}
# Database connectivity
try:
async with app.state.db_pool.acquire() as conn:
await conn.fetchval("SELECT 1")
checks["database"] = "healthy"
except Exception:
checks["database"] = "unhealthy"
# Redis connectivity
try:
await app.state.redis.ping()
checks["redis"] = "healthy"
except Exception:
checks["redis"] = "unhealthy"
status = "healthy" if all(v == "healthy" for v in checks.values()) else "degraded"
return {"status": status, "checks": checks}
Deploy containers on Arxentra with built-in health monitoring. See Services Overview for container deployment options.
---
Logging Best Practices
- Log to stdout/stderr (not files) so the orchestrator captures output
- Use structured JSON logging for parseability
- Include request ID for distributed tracing
- Set log level via environment variable
import logging
import json
class JSONFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"timestamp": self.formatTime(record),
"level": record.levelname,
"message": record.getMessage(),
"module": record.module,
"request_id": getattr(record, "request_id", ""),
})
handler = logging.StreamHandler() # stdout
handler.setFormatter(JSONFormatter())
logging.root.addHandler(handler)
logging.root.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
---
Resource Limits
Always set resource limits. Without them, a single container can consume all host resources.
| Application Type | CPU Limit | Memory Limit | Recommended |
| Node.js API | 1.0 CPU | 512 MB | Start here, adjust |
| Python API | 1.0 CPU | 512 MB | Increase for ML workloads |
| React SPA (nginx) | 0.5 CPU | 128 MB | Rarely needs more |
| PostgreSQL | 2.0 CPU | 2 GB | Scale with data size |
| Redis | 0.5 CPU | 256 MB | Scale with cache size |
---
Frequently Asked Questions
Should I use Alpine or Debian slim as a base image?
Alpine for most applications. It produces images 5-10x smaller than Debian. However, Alpine uses musl libc instead of glibc, which can cause issues with some native modules (notably Python packages with C extensions). If you encounter compatibility issues, switch to Debian slim.
How do I handle secrets in Docker containers?
Never bake secrets into images. Use environment variables at runtime, Docker secrets, or a secrets manager (Vault, AWS Secrets Manager). For development, use a `.env` file with `docker-compose --env-file .env`. For production, inject secrets through your orchestrator. See Documentation for secrets management guides.
What is the difference between COPY and ADD in a Dockerfile?
Use COPY for copying files. ADD has extra features (URL downloads, tar extraction) that are rarely needed and introduce security risks. The Docker best practices documentation recommends COPY for all cases except tar auto-extraction.
How often should I rebuild my base images?
Rebuild weekly to pick up security patches in the base image. Automate this with a scheduled CI/CD pipeline that rebuilds and scans images every Monday. Pin major versions (node:20-alpine) but allow minor/patch updates.
How do I debug a container that keeps crashing?
Run the container with an interactive shell to inspect the environment: `docker run -it --entrypoint /bin/sh myapp:1.0.0`. Check logs with `docker logs
What is the maximum recommended image size?
Under 200 MB for application containers. Under 500 MB for containers with large dependencies (ML models, Java applications). Larger images slow down deployments, increase cold start times, and consume more registry storage. Visit Pricing for container registry storage pricing.