Docker Security for Developers: The Practical Guide (2026)
Docker security best practices for application developers — not DevOps. Running containers as non-root, managing secrets, picking safe base images, and more.
Rod
Founder & Developer
Docker security is one of those topics that gets handed to DevOps and forgotten. But if you're writing the Dockerfile — and most application developers are — you're making security decisions whether you know it or not. This guide covers what you need to know without assuming you have a Kubernetes cluster or a dedicated security team.
The five biggest Docker security mistakes we see in developer codebases, and how to fix each one.
1. Running as Root
The default Docker behavior is to run your container process as root. This is convenient and wrong.
# BAD: Root by default — no USER instruction
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]If an attacker finds a code execution vulnerability in your app, they get root inside the container. On misconfigured hosts, or systems where container isolation isn't properly configured, root in the container can become root on the host. Even on well-configured hosts, root access inside a container significantly expands what an attacker can do.
# GOOD: Create a non-root user and run as that user
FROM node:20-alpine
WORKDIR /app
# Create app user and group
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
# Switch to non-root user before CMD
USER appuser
CMD ["node", "server.js"]The --chown flag on COPY ensures the files are owned by the app user, not root. The USER instruction switches the running user for all subsequent commands and the final process.
2. Secrets Baked Into the Image
This is the most common Dockerfile security mistake. A developer adds a secret during build — an API key, a database URL, a service credential — and it gets permanently embedded in the image layer history.
# BAD: ARG leaks into image history
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
# BAD: ENV hardcodes the secret in every layer
ENV STRIPE_SECRET_KEY=sk_live_abc123Anyone who pulls your image can run docker history your-image:tag and see every layer, including the value of every ENV and ARG.
For build-time secrets — use BuildKit's --secret flag:
# GOOD: Build-time secret that never appears in image layers
# syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=secret,id=npm_token \
npm config set //registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)
RUN npm install# Pass the secret at build time — never appears in the image
docker buildx build --secret id=npm_token,src=.npmrc .For runtime secrets — inject as environment variables at run time, not build time:
# GOOD: Secret provided at runtime, not baked into the image
docker run -e DATABASE_URL="$DATABASE_URL" your-image:tagIn production, use your orchestrator's native secret injection: Railway environment variables, Fly.io secrets, Kubernetes secrets, or AWS Secrets Manager.
3. Using Fat Base Images
The node:20 base image is 1.1GB and includes gcc, g++, make, python, and dozens of tools your running application never needs. Every package in that image is a potential vulnerability.
# BAD: Full Debian-based Node image — 1.1GB, hundreds of packages
FROM node:20# BETTER: Alpine-based image — ~180MB, far fewer packages
FROM node:20-alpine# BEST: Multi-stage build with distroless runtime image
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Final stage: no shell, no package manager, no tools
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]Distroless images contain only your application and its runtime dependencies. No shell means an attacker who gains execution inside the container can't run bash, sh, curl, or any other tool to escalate their access. This is the principle of least privilege applied at the image level.
Multi-stage builds are valuable even without distroless — they ensure dev dependencies, test files, and build artifacts never land in the production image.
4. Not Pinning Base Image Versions
FROM node:20-alpine pulls whatever the current node:20-alpine tag points to. That changes when the Node or Alpine maintainers publish updates. This sounds like a good thing — automatic updates. It's also how a supply chain attack would work.
# BAD: Mutable tag — changes silently under you
FROM node:20-alpine
# BETTER: Pin to a specific version — predictable and auditable
FROM node:20.11.0-alpine3.19
# BEST: Pin to digest — cryptographically verified, truly immutable
FROM node:20-alpine@sha256:a6eb4a04f1c...(full digest)Pinning to a specific version means you know exactly what's in your base image. When a security update releases, you update the pin explicitly — you decide when to take the update, and you can review the changelog.
Get the digest for any image:
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine5. COPY . . Without a .dockerignore
If you use COPY . . in your Dockerfile without a .dockerignore file, you copy everything — including your .env files, .git directory, test fixtures, and local development artifacts.
# This copies .env, .git/, node_modules/, and everything else
COPY . .A .dockerignore file works exactly like .gitignore:
# .dockerignore
.env
.env.*
.git
.gitignore
node_modules
npm-debug.log
*.test.ts
*.spec.ts
coverage/
.nyc_output/
Dockerfile
.dockerignore
README.mdBeyond security, this also makes builds faster — Docker's layer cache invalidates when files change, so copying fewer files means more cache hits.
Bonus: Scan Your Images for Known Vulnerabilities
Writing a secure Dockerfile is necessary. It's not sufficient. Your base image and your npm packages may contain vulnerabilities that no Dockerfile instruction can fix — they need to be patched.
Trivy is the standard free tool for this:
# Install Trivy (macOS)
brew install aquasecurity/trivy/trivy
# Scan your local image
trivy image your-app:latest
# Scan in CI (fail on critical/high vulnerabilities)
trivy image --exit-code 1 --severity CRITICAL,HIGH your-app:latestRunning Trivy in your CI pipeline catches new vulnerabilities in packages you're already using — including base image packages that aren't in your package.json.
For scanning your application code alongside Docker security, Data Hogo checks for security misconfigurations and vulnerable dependencies in your repo.
Scan your repo for security issues free →
Dockerfile Security Checklist
# Complete example incorporating all best practices
# syntax=docker/dockerfile:1
# Stage 1: Build
FROM node:20.11.0-alpine3.19 AS builder
WORKDIR /app
# Copy only package files first (better layer caching)
COPY package*.json ./
RUN npm ci --only=production
# Copy source files
COPY . .
RUN npm run build
# Stage 2: Production image
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
# Create non-root user (distroless has nonroot user built in)
USER nonroot
# Copy only the built output from builder stage
COPY --from=builder --chown=nonroot:nonroot /app/dist ./dist
COPY --from=builder --chown=nonroot:nonroot /app/node_modules ./node_modules
EXPOSE 3000
CMD ["dist/server.js"]Verify before you ship:
- No secrets in
ENVorARGinstructions USERset to a non-root user.dockerignoreexists and covers.envfiles- Base image pinned to a specific version
- Multi-stage build used to exclude dev dependencies
HEALTHCHECKadded for container orchestrationCOPYused instead ofADD(unless you needADD's tar extraction)
Frequently Asked Questions
Why is running Docker containers as root dangerous?
When a container runs as root and an attacker gains code execution inside it, they have root-level access. On misconfigured hosts or systems without proper namespace isolation, this can lead to container escape — where the attacker gets root access to the host machine. Running as a non-root user limits the blast radius of any container compromise.
How do I pass secrets to a Docker container securely?
Use environment variables injected at runtime (not baked into the image), Docker secrets for Swarm deployments, or BuildKit's --secret flag for build-time secrets. Never put secrets in the Dockerfile with ENV or ARG — they're visible in the image layer history. In production, use a secrets manager like AWS Secrets Manager, HashiCorp Vault, or your orchestrator's native secret management.
What is the most secure Docker base image?
Distroless images (from Google's distroless project) contain only your application and its runtime — no shell, no package manager, no tools an attacker could use. For Node.js apps, gcr.io/distroless/nodejs20-debian12 is the most hardened option. Alpine Linux is a popular middle ground — much smaller than full Debian/Ubuntu, but still includes a shell. Smaller images have fewer vulnerabilities.
What is a multi-stage Docker build and why does it improve security?
A multi-stage build uses multiple FROM statements in one Dockerfile. The first stage installs dev dependencies and builds the app. The final stage copies only the built output — no dev tools, no build dependencies, no source files. This produces a smaller image with a smaller attack surface. Many critical vulnerabilities live in dev dependencies that would never be needed at runtime.
How do I scan a Docker image for vulnerabilities?
Use Trivy (free, open source) to scan images for OS and package vulnerabilities: trivy image your-image:tag. For automated scanning in CI/CD, integrate Trivy or Grype into your GitHub Actions workflow. Docker Desktop also has a built-in vulnerability scan powered by Snyk.
Docker security isn't a DevOps problem — it's a developer problem the moment you write FROM. The five fixes above take less than an hour to apply to an existing Dockerfile. The non-root user alone eliminates the most severe class of container escape vulnerabilities. Start there.
Related Posts
Stripe Webhook Security: The Checklist Your App Needs
Stripe webhook security checklist for Next.js developers. Signature verification, replay attack prevention, idempotency, and error handling with code examples.
Prisma + Supabase Security: The Risks Most Guides Skip
Raw queries, RLS bypass risks, connection string exposure, and migration safety in Prisma + Supabase apps. A practical security guide with code examples.
Auth.js Security Misconfigurations That Break Your App
Auth.js (NextAuth) has common security misconfigurations that expose sessions, tokens, and user data. Here's what they are and how to fix each one.