Here's a Dockerfile most of us have written at some point:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "index.js"]
It works. It builds. It runs. And it's a security and performance problem that most people don't notice until something breaks:
-
Image size: 1.1 GB (the full
node:20image includes compilers, man pages, and 400+ system packages you'll never use) - Running as root: if your container is compromised, the attacker has root access
- No layer caching: every code change reinstalls all dependencies from scratch
- No health check: your orchestrator can't tell if the app is actually healthy
- Dev dependencies shipped: test frameworks, linters, and type-checkers sitting in production
By the end of this post, you'll have a Dockerfile that produces images 9x smaller, builds 4x faster, and blocks the most common attack vectors.
The 10 Rules of Production Dockerfiles
Rule 1: Use Multi-Stage Builds
Multi-stage builds give you the biggest size reduction for the least effort. The idea is simple: build in one image, run in another.
# ❌ Single stage - everything ships to production
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
# Result: 1.1 GB image with source code, devDependencies, and build tools
# ✅ Multi-stage - only production artifacts ship
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]
# Result: 120 MB image with only compiled code and production dependencies
Why it matters: Your build tools (TypeScript compiler, Webpack, Babel, testing frameworks) stay in Stage 1. Only the compiled output and production node_modules make it to the final image.
Rule 2: Choose the Right Base Image
Your base image controls your image size, security exposure, and compatibility:
| Base Image | Size | CVEs (typical) | C Library | Best For |
|---|---|---|---|---|
node:20 |
~1.1 GB | 200+ | glibc | Never in production |
node:20-slim |
~200 MB | 30–50 | glibc | Apps with native C modules |
node:20-alpine |
~130 MB | 5–15 | musl | Most web apps ⭐ |
python:3.12 |
~1.0 GB | 200+ | glibc | Never in production |
python:3.12-slim |
~150 MB | 20–40 | glibc | Django/FastAPI with C deps ⭐ |
python:3.12-alpine |
~60 MB | 5–10 | musl | Pure Python apps |
gcr.io/distroless/nodejs |
~120 MB | 0–5 | glibc | Maximum security |
gcr.io/distroless/python3 |
~50 MB | 0–5 | glibc | Maximum security |
Our recommendation:
-
Node.js: Start with
node:20-alpine. Switch tonode:20-slimif you encountermuslcompatibility issues with native modules (e.g.,sharp,bcrypt). -
Python: Start with
python:3.12-slim. Use Alpine only for pure Python apps without C extensions.
⚠️ Always pin your version. Use
node:20.12-alpine3.19, notnode:latestornode:20-alpine. Untagged/floating tags break reproducibility.
Rule 3: Optimize Layer Caching
Docker caches each layer. If a layer hasn't changed, Docker reuses it. The rule is straightforward: put things that change less often at the top, things that change often at the bottom.
# ❌ Bad: COPY all files first → cache busts on ANY file change
COPY . .
RUN npm ci
# ✅ Good: Copy dependency files first → cache busts only when deps change
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Impact: When only your source code changes (the common case), Docker skips the npm ci step entirely. For a typical Node.js project, this turns a 90-second build into a 5-second build.
The optimal cache order:
# 1. Base image + system deps (changes: rarely)
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# 2. Dependency manifest (changes: weekly)
COPY package.json package-lock.json ./
RUN npm ci
# 3. Source code (changes: every deploy)
COPY . .
RUN npm run build
Rule 4: Run as Non-Root
By default, Docker containers run as root. If your app is compromised, the attacker has root access inside the container and potentially to the host system.
# ✅ Node.js (Alpine)
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
USER appuser
# ✅ Python (Debian)
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -m appuser
USER appuser
Place the USER instruction after installing system packages and before the CMD. Some setup steps require root (like apk add or apt-get install).
Alpine shortcut: Node.js Alpine images ship with a built-in
nodeuser. You can simply useUSER nodeinstead of creating a new user.
Rule 5: Add a Health Check
Without a health check, your orchestrator (ECS, Kubernetes, Docker Compose) has no way to know if your app is actually responding to requests.
# ✅ Node.js (using wget - available in Alpine)
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# ✅ Python (using python module - no extra installs needed)
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
Parameters explained:
| Parameter | Value | Meaning |
|---|---|---|
--interval |
30s | Check every 30 seconds |
--timeout |
5s | Fail if the check takes longer than 5 seconds |
--start-period |
30s | Wait 30 seconds before first check (app startup time) |
--retries |
3 | Mark unhealthy after 3 consecutive failures |
Your /health endpoint should return 200 OK if the service is ready:
// Express.js
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', uptime: process.uptime() });
});
# FastAPI
@app.get("/health")
async def health():
return {"status": "ok"}
Rule 6: Write a Proper .dockerignore
The .dockerignore file stops unnecessary files from getting into the Docker build context. Without it, your .git history, test files, and local node_modules all get sent along.
# Version control
.git
.gitignore
# Dependencies (we install fresh in the container)
node_modules
__pycache__
*.pyc
.venv
venv
# Environment files (NEVER ship secrets)
.env
.env.local
.env.production
# IDE and OS files
.vscode
.idea
*.swp
.DS_Store
Thumbs.db
# Build output (we build fresh in the container)
dist
build
.next
# Documentation and tests
README.md
LICENSE
docs
tests
test
*.test.js
*.test.ts
*.spec.js
*.spec.ts
__tests__
pytest.ini
jest.config.*
# Docker files (avoid recursive builds)
Dockerfile*
docker-compose*
.dockerignore
# CI/CD configs
.github
.gitlab-ci.yml
.circleci
Impact: A typical Node.js project sends 500 MB+ to the build context without .dockerignore. With it: around 5 MB.
Rule 7: Handle Signals for Graceful Shutdown
When ECS/Kubernetes stops your container, it sends SIGTERM. Your app needs to catch this signal and shut down gracefully (finish in-flight requests, close database connections).
The critical rule: Use CMD ["node", "server.js"] (exec form), NOT CMD node server.js (shell form).
# ❌ Shell form - node runs as a child of /bin/sh, never receives SIGTERM
CMD node server.js
# ✅ Exec form - node is PID 1, receives SIGTERM directly
CMD ["node", "server.js"]
In your application code:
// Node.js graceful shutdown
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received. Shutting down gracefully...');
server.close(() => {
console.log('Server closed. Exiting.');
process.exit(0);
});
// Force exit after 10 seconds if connections don't close
setTimeout(() => {
console.error('Forcing shutdown after timeout.');
process.exit(1);
}, 10000);
});
# Python / Gunicorn - graceful shutdown is built in
# Gunicorn handles SIGTERM natively.
# Just configure the timeout:
CMD ["gunicorn", "--timeout", "30", "--graceful-timeout", "10", \
"--bind", "0.0.0.0:8000", "app:application"]
Rule 8: Never Bake Secrets into the Image
This is the one that can actually get you fired:
# ❌ NEVER DO THIS - secrets are baked into the image layers forever
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-1234567890abcdef
# ❌ ALSO NEVER DO THIS - the ARG is still visible in build history
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
Even if you delete the ENV in a later layer, it's still visible with docker history.
The right way: Inject secrets at runtime.
# ✅ Declare that your app needs these vars, but don't set values
ENV NODE_ENV=production
# DATABASE_URL and API_KEY are injected at runtime via:
# - ECS task definition (environment or secrets)
# - docker run -e DATABASE_URL=xxx
# - docker-compose.yml environment section
For ECS, use AWS Secrets Manager:
{
"containerDefinitions": [{
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456:secret:myapp/database-url"
}
]
}]
}
Rule 9: Pin Dependencies and Base Images
Reproducible builds require pinned versions everywhere:
# ❌ Floating tags - your build is non-deterministic
FROM node:20-alpine
RUN npm install # Uses whatever latest packages resolve to
# ✅ Pinned everything - identical builds every time
FROM node:20.12.2-alpine3.19
COPY package.json package-lock.json ./
RUN npm ci # Uses exact versions from lockfile
| Practice | ❌ Bad | ✅ Good |
|---|---|---|
| Base image | node:latest |
node:20.12.2-alpine3.19 |
| Node deps | npm install |
npm ci |
| Python deps | pip install -r requirements.txt |
pip install --no-cache-dir -r requirements.txt |
| System packages | apk add curl |
apk add --no-cache curl=8.5.0-r0 |
npm civsnpm install:npm cideletes existingnode_modules, installs frompackage-lock.jsonexactly, and fails if the lockfile is out of sync. It's designed for CI/CD and Docker builds.
Rule 10: Scan for Vulnerabilities
Build a scan step into your CI/CD pipeline:
# Install Trivy (one-time)
brew install trivy # macOS
# or: apt-get install trivy # Debian/Ubuntu
# Scan your image
trivy image my-app:latest
# Fail CI on HIGH/CRITICAL vulnerabilities
trivy image --exit-code 1 --severity HIGH,CRITICAL my-app:latest
In GitHub Actions:
- name: Scan Docker image
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app:latest
format: 'table'
exit-code: '1'
severity: 'CRITICAL,HIGH'
Complete Dockerfiles (Copy-Paste Ready)
1. Express.js (TypeScript) - Production
# =============================================================================
# Stage 1: Dependencies
# =============================================================================
FROM node:20.12-alpine3.19 AS deps
WORKDIR /app
# Install production dependencies only
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# =============================================================================
# Stage 2: Builder
# =============================================================================
FROM node:20.12-alpine3.19 AS builder
WORKDIR /app
# Install ALL dependencies (including devDeps for building)
COPY package.json package-lock.json ./
RUN npm ci
# Copy source code and build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# =============================================================================
# Stage 3: Runner (Production)
# =============================================================================
FROM node:20.12-alpine3.19 AS runner
WORKDIR /app
# Set production environment
ENV NODE_ENV=production
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
# Copy production dependencies from deps stage
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
# Copy compiled output from builder stage
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/package.json ./
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Start application (exec form for signal handling)
CMD ["node", "dist/server.js"]
Why 3 stages? Stage 1 installs only production node_modules (no TypeScript, no test tools). Stage 2 installs everything including devDependencies to compile TypeScript. Stage 3 takes node_modules from Stage 1 (small) and compiled code from Stage 2 (small) = smallest possible image.
2. Next.js (Standalone) - Production
# =============================================================================
# Stage 1: Dependencies
# =============================================================================
FROM node:20.12-alpine3.19 AS deps
WORKDIR /app
# Install dependencies (including sharp for image optimization)
COPY package.json package-lock.json ./
RUN npm ci
# =============================================================================
# Stage 2: Builder
# =============================================================================
FROM node:20.12-alpine3.19 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# IMPORTANT: Ensure next.config.js has: output: 'standalone'
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# =============================================================================
# Stage 3: Runner (Production)
# =============================================================================
FROM node:20.12-alpine3.19 AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy public assets
COPY --from=builder /app/public ./public
# Copy standalone output (auto-traced minimal dependencies)
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
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
CMD ["node", "server.js"]
Prerequisite: Add
output: 'standalone'to yournext.config.js. This enables Next.js to auto-trace only the files needed at runtime, producing a minimal output bundle.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;
3. Django (Gunicorn) - Production
# =============================================================================
# Stage 1: Builder
# =============================================================================
FROM python:3.12-slim-bookworm AS builder
# Prevent Python from writing .pyc files and buffering stdout
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Install build dependencies for C extensions (psycopg2, etc.)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies into /install directory
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# =============================================================================
# Stage 2: Runner (Production)
# =============================================================================
FROM python:3.12-slim-bookworm AS runner
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DJANGO_SETTINGS_MODULE=myproject.settings.production
# Install runtime-only system dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libpq5 \
wget \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -m -s /bin/bash appuser
# Copy installed Python packages from builder
COPY --from=builder /install /usr/local
WORKDIR /app
# Copy application code
COPY --chown=appuser:appgroup . .
# Collect static files (runs as root, then we switch)
RUN python manage.py collectstatic --noinput 2>/dev/null || true
# Fix ownership
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8000/health/ || exit 1
# Gunicorn with 2 workers (adjust based on vCPU - typically 2*CPU+1)
CMD ["gunicorn", "myproject.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "2", \
"--timeout", "30", \
"--graceful-timeout", "10", \
"--access-logfile", "-", \
"--error-logfile", "-"]
Why --prefix=/install? This installs all Python packages into a separate /install directory in the builder stage, which we cleanly copy into the final image. No pip, no setuptools, no build tools in production.
Gunicorn worker count: The rule of thumb is 2 × CPU cores + 1. For a Fargate task with 0.5 vCPU, use 2 workers. For 1 vCPU, use 3.
4. FastAPI (Uvicorn + Gunicorn) - Production
# =============================================================================
# Stage 1: Builder
# =============================================================================
FROM python:3.12-slim-bookworm AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# =============================================================================
# Stage 2: Runner (Production)
# =============================================================================
FROM python:3.12-slim-bookworm AS runner
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Create non-root user
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -m appuser
# Copy installed packages from builder
COPY --from=builder /install /usr/local
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Gunicorn with Uvicorn worker class for async FastAPI
CMD ["gunicorn", "main:app", \
"-k", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", \
"--workers", "2", \
"--timeout", "30", \
"--graceful-timeout", "10", \
"--access-logfile", "-", \
"--error-logfile", "-"]
Why Gunicorn + Uvicorn? Uvicorn is a great ASGI server, but it runs a single process. Gunicorn acts as the process manager, spawning multiple Uvicorn workers for concurrent request handling. The
-k uvicorn.workers.UvicornWorkerflag tells Gunicorn to use Uvicorn's event loop inside each worker.
5. Using Poetry (Python) - Production
If your Python project uses Poetry instead of pip:
# =============================================================================
# Stage 1: Builder (with Poetry)
# =============================================================================
FROM python:3.12-slim-bookworm AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
POETRY_VERSION=1.8.5 \
POETRY_HOME="/opt/poetry" \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false
# Install Poetry
RUN apt-get update && \
apt-get install -y --no-install-recommends curl build-essential libpq-dev && \
curl -sSL https://install.python-poetry.org | python3 - && \
rm -rf /var/lib/apt/lists/*
ENV PATH="$POETRY_HOME/bin:$PATH"
WORKDIR /app
# Copy only dependency files first (layer caching)
COPY pyproject.toml poetry.lock ./
# Install production dependencies only (no dev deps)
RUN poetry install --only main --no-root
# =============================================================================
# Stage 2: Runner (Production)
# =============================================================================
FROM python:3.12-slim-bookworm AS runner
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Runtime system deps only
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 && \
rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -m appuser
# Copy installed Python packages from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["gunicorn", "main:app", \
"-k", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", \
"--workers", "2"]
Key detail: Poetry itself is NOT in the final image. We only copy the installed site-packages. No poetry binary, no pyproject.toml parser, just the actual libraries your app needs.
Base Image Size Comparison (Real Numbers)
Here's what the same Express.js app looks like across different base images:
| Base Image | Final Image Size | Known CVEs | Build Time |
|---|---|---|---|
node:20 (Debian full) |
1,100 MB | 200+ | 45s |
node:20-slim (Debian slim) |
240 MB | 30–50 | 38s |
node:20-alpine (Alpine) |
130 MB | 5–15 | 30s |
node:20-alpine + multi-stage |
85 MB | 5–15 | 32s |
distroless/nodejs20 |
120 MB | 0–5 | 35s |
And for a Django app with Gunicorn + Postgres:
| Base Image | Final Image Size | Known CVEs | Build Time |
|---|---|---|---|
python:3.12 (Debian full) |
1,020 MB | 200+ | 60s |
python:3.12-slim (Debian slim) |
180 MB | 20–40 | 50s |
python:3.12-slim + multi-stage |
140 MB | 20–40 | 55s |
python:3.12-alpine + multi-stage |
95 MB | 5–10 | 70s* |
*Alpine Python builds are slower because C extensions (psycopg2) must compile against musl. Use psycopg2-binary or python:3.12-slim instead.
Docker Compose for Local Development
Your production Dockerfile shouldn't be what you use in development. Use Docker Compose to create a dev environment with hot-reloading:
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
target: deps # Stop at the deps stage, don't build production
volumes:
- .:/app # Mount source code for hot-reloading
- /app/node_modules # Don't overwrite container's node_modules
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
command: npm run dev # Override CMD with dev server
depends_on:
- db
- redis
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:
The key trick: target: deps stops the build at the dependencies stage, and the volumes mount lets you edit code locally with hot-reloading. In production, the full multi-stage build runs.
Common Mistakes (and Fixes)
Mistake 1: Running npm install instead of npm ci
# ❌ npm install may update lockfile, non-deterministic
RUN npm install
# ✅ npm ci installs exact lockfile versions, fails if out of sync
RUN npm ci --omit=dev
Mistake 2: Forgetting --omit=dev or --only=production
# ❌ Installs jest, eslint, typescript, prettier in production
RUN npm ci
# ✅ Production dependencies only
RUN npm ci --omit=dev
Mistake 3: Using COPY . . Before Installing Dependencies
# ❌ Any source change invalidates the npm ci cache
COPY . .
RUN npm ci
# ✅ Dependency files first, then source
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Mistake 4: Ignoring the apt/apk Cache
# ❌ Leaves package manager cache in the image (50-100 MB)
RUN apt-get update && apt-get install -y curl
# ✅ Clean up in the same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
Mistake 5: Not Using .dockerignore
Without .dockerignore, your 500 MB .git directory, your local node_modules, and your .env file all get sent to Docker. This slows down builds and creates security risks.
Mistake 6: Using Shell Form for CMD
# ❌ Shell form - node is a child of sh, doesn't receive SIGTERM
CMD npm start
# ✅ Exec form - node is PID 1, receives signals properly
CMD ["node", "dist/server.js"]
How TurboDeploy Handles Dockerfiles
When you deploy through TurboDeploy, we auto-detect your framework (Express, Next.js, Django, FastAPI), generate an optimized Dockerfile with all the practices from this guide, build with multi-stage, push to ECR in your account, and scan with Trivy before deploying.
You can always bring your own Dockerfile instead. TurboDeploy deploys any valid Docker image.
The Production Checklist
Before you deploy, verify:
- [ ] Multi-stage build : build tools don't ship to production
- [ ] Minimal base image : Alpine or Slim, not full Debian/Ubuntu
- [ ] Pinned version tags :
node:20.12-alpine3.19, never:latest - [ ] .dockerignore : excludes .git, node_modules, .env, tests
- [ ] Layer caching optimized : dependency files copied before source
- [ ] Non-root user :
USER appuserbefore CMD - [ ] Health check :
HEALTHCHECKinstruction with proper intervals - [ ] Graceful shutdown : exec form CMD + SIGTERM handling in code
- [ ] No secrets in image : environment variables injected at runtime
- [ ] Security scan :
trivy image --severity HIGH,CRITICALpasses
TL;DR
| What | Bad | Good |
|---|---|---|
| Base image |
node:20 (1.1 GB) |
node:20-alpine (130 MB) |
| Build | Single stage | Multi-stage (build → run) |
| User | root (default) | Non-root (appuser) |
| Dependencies | npm install |
npm ci --omit=dev |
| Cache |
COPY . . first |
COPY package*.json first |
| Secrets | ENV API_KEY=xxx |
Injected at runtime |
| Health | No health check |
HEALTHCHECK instruction |
| Shutdown | CMD npm start |
CMD ["node", "server.js"] |
| Scanning | None |
trivy image in CI |
| Tags | :latest |
:20.12-alpine3.19 |
Don't want to write Dockerfiles? TurboDeploy generates production Dockerfiles for your Node.js and Python apps. Multi-stage builds, non-root users, health checks, Trivy scanning. Connect your repo, deploy.



Top comments (0)