Docker makes it easy to containerize applications, but the default settings are far from secure. Containers running as root, bloated images with unnecessary packages, and missing resource limits pose serious risks. In this hands-on tutorial, we walk through every step to make your Docker containers production-ready and secure.
Minimal Base Images and Multi-Stage Builds
The first rule of container security: minimize the attack surface. Use Alpine Linux (5 MB) or distroless images instead of full Ubuntu or Debian images. Every extra package in your image is a potential vulnerability. With multi-stage builds, you compile your application in a builder stage with all development tools, and copy only the final artifact to a minimal runtime image.
Scan your images regularly with tools like Trivy, Snyk, or Docker Scout. Integrate this into your CI/CD pipeline so vulnerable images never reach production. Pin your base image versions with a specific digest hash instead of mutable tags like :latest to prevent supply chain risks.
# Optimal Dockerfile with multi-stage build
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Stage 2: Runtime (minimal image)
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# Run as non-root user (uid 1000)
USER 1000
EXPOSE 3000
CMD ["dist/server.js"]
Non-Root Users, Read-Only Filesystems, and Resource Limits
Containers run as root by default — a huge security risk. If an attacker breaks out of the container, they have root access on the host. Always use the USER directive in your Dockerfile to run as a non-root user. Create a dedicated user or use the built-in node, www-data, or nobody user.
Run containers with a read-only root filesystem via --read-only. Only mount specific directories as writable where your application needs to write temporary files. Also always set resource limits with --memory and --cpus to prevent a compromised container from exhausting the entire system. Drop all Linux capabilities with --cap-drop ALL and only add back the strictly necessary ones.
- Use distroless or Alpine as base image — avoid bloated images
- Always run as non-root with the USER directive
- Enable read-only root filesystem with --read-only flag
- Set memory and CPU limits for every container
- Drop all capabilities with --cap-drop ALL and only add necessary ones
- Scan images with Trivy or Snyk in your CI/CD pipeline
Runtime Security and Secrets in Docker
Build-time security is only half the story. Use Docker's seccomp profile to restrict syscalls and AppArmor or SELinux for mandatory access control. Also enable --no-new-privileges to prevent processes in the container from escalating their privileges after starting.
Never store secrets in Docker images or environment variables. Use Docker Secrets (in Swarm) or external secret managers like HashiCorp Vault. For development, you can use Docker Compose with an .env file that's in .gitignore. In production, mount secrets as files in a tmpfs volume with restrictive permissions (0400). Don't forget to secure your Docker daemon itself: enable TLS, restrict API access, and keep Docker up to date.
Pro Tip
Run docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image <your-image> on your production images now. You'll likely find critical vulnerabilities in your base images. Then switch to a distroless or Alpine base image, add USER 1000 to every Dockerfile, and you'll have eliminated 80% of the most common container vulnerabilities in less than an hour.
Share this article