
Docker Container Security Mistakes Web Developers Keep Making
Common Docker container security mistakes web developers make, and the practical fixes that actually close the gaps before attackers find them.
Docker made shipping web apps faster, but it also introduced a whole new attack surface that a lot of developers never properly lock down. A container that runs fine in production isn't the same thing as a container that's secure in production. Here's where things actually go wrong, and what to do about it.
Running Containers as Root
This is the single most common mistake. By default, if you don't specify otherwise, your container process runs as root inside the container, and if an attacker breaks out of your app (through a dependency vulnerability, a bad deserialization bug, whatever), they inherit root privileges on the container itself.
The Fix
- Add a dedicated user in your Dockerfile:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup - Switch to it before the app starts:
USER appuser - For Node.js images specifically, the official images already ship a
nodeuser, use it instead of creating your own - Verify with
docker inspectto confirm it's not blank or "root"| grep User
Shipping Secrets Inside the Image
It's shockingly common to find API keys, database passwords, and .env files baked directly into image layers. Even if you delete the file in a later layer, it's still recoverable because Docker images are layered, not overwritten.
What To Do Instead
- Never
COPY .envinto an image. Add it to.dockerignore, not just.gitignore - Use build-time secrets with BuildKit:
docker build --secret id=mysecret,src=secret.txt - Pass runtime secrets as environment variables injected at deploy time, or better, use a secrets manager (AWS Secrets Manager, Doppler, HashiCorp Vault)
- Scan existing images for leaked secrets with a tool like
trufflehogordocker scout
Using "latest" or Bloated Base Images
Pulling node:latest or python:latest means you have no idea what you're actually running, and it changes under you every time you rebuild. These full images also carry hundreds of unnecessary packages, each one a potential CVE.
- Pin specific versions:
node:20.11.1-alpineinstead ofnode:latest - Prefer Alpine or distroless variants where your app allows it, distroless images have no shell, no package manager, nothing for an attacker to use post-compromise
- Use multi-stage builds so build tools and dev dependencies never make it into the final image
Example Multi-Stage Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/index.js"]
Ignoring Image Vulnerability Scanning
A huge number of teams build and deploy images without ever scanning them for known CVEs in the base image or dependencies. This is table stakes now, not optional.
- Run
docker scout cvesortrivy imageas part of your CI pipeline, not as an afterthought - Fail the build on critical/high severity findings, don't just log them and move on
- Re-scan running images periodically, new CVEs get disclosed against old images constantly
Exposing the Docker Socket
Mounting /var/run/docker.sock into a container (common with CI runners, monitoring tools, or "Docker-in-Docker" setups) effectively gives that container root access to the host. If that container is compromised, the whole host is compromised.
- Avoid mounting the socket unless absolutely necessary
- If you must, use a proxy like
docker-socket-proxythat limits which API calls are allowed - Never do this on a container that also handles untrusted input, like a web-facing app
Missing Resource Limits
Without CPU and memory limits, a single compromised or buggy container can exhaust host resources and take down every other service on the same machine. This is a denial-of-service vector a lot of developers don't think about until it happens.
- Set limits in Compose:
mem_limit: 512m,cpus: 0.5 - In Kubernetes, always define
resources.limitsandresources.requests - Monitor actual usage with
docker statsbefore guessing at numbers
Forgetting Network Isolation
Default bridge networks let every container on the host talk to every other container. A compromised frontend container shouldn't have direct network access to your database container.
- Create purpose-specific networks in Compose: separate your app tier, database tier, and cache tier
- Only expose ports that genuinely need to be public
- Use internal networks for service-to-service traffic and keep the database off any network with internet-facing exposure
Read-Only Filesystems and Dropped Capabilities
Most web apps don't need to write to their own filesystem at runtime, and almost none need the full set of Linux capabilities Docker grants by default.
- Run containers with
--read-onlyand mount specific writable volumes only where needed (like a tmp directory) - Drop unnecessary capabilities:
--cap-drop=ALL --cap-add=NET_BIND_SERVICEif you only need to bind to a port - Add
--security-opt=no-new-privilegesto prevent privilege escalation inside the container
Container Security Is Only Half the Picture
Locking down your containers matters, but it doesn't help if the app serving traffic from that container is missing basic web security controls, weak TLS configuration, missing security headers, permissive CORS policies, or cookies without the right flags set. These are exactly the kinds of issues that sit outside the container boundary but still get exploited every day.
This is where a tool like WebSentry is useful as a complement to container hardening: it checks your live site's SSL setup, HTTP security headers, CSP, cookie configuration, DNS records, and CORS policy, then gives you an A-F grade so you can see what's actually exposed from the outside, regardless of how well the container itself is locked down.
If you've just gone through hardening your Dockerfiles and compose files, it's worth running a free scan at websentry.dev to check the parts of your security posture that container-level fixes don't touch, the headers, certificates, and policies your app actually sends to the browser.
Check your own site
Run a free security scan and see if your site has the issues covered in this article. Results in under 30 seconds.