In 2018, hackers found Tesla's container dashboard sitting open on the internet. No password. No authentication. Just open.
They didn't use a zero-day exploit. They didn't write sophisticated malware. They just scanned the public internet for misconfigured dashboards, found Tesla's Kubernetes console sitting unprotected, walked in, and quietly installed cryptocurrency mining software across Tesla's AWS infrastructure.
For weeks, Tesla's powerful cloud servers were mining crypto for someone else. The attack was only discovered when a security firm stumbled across it during a routine scan.
The root cause wasn't a clever hack. It was a default configuration nobody had bothered to change.
That's the uncomfortable truth about container security. The most dangerous vulnerabilities aren't the exotic ones, they're the mundane defaults that ship with every Docker installation and that most developers never think to change.
The Uncomfortable Stat Before We Start
Before we get into fixes, one number worth sitting with:
76% of images on Docker Hub contain known security vulnerabilities. 67% carry high-severity ones.
That's not fringe images from unknown publishers. That includes popular, widely-used base images that developers pull millions of times per day. The node:latest image you pulled this morning might have 14 critical CVEs sitting in its libraries right now.
Most of those vulnerabilities aren't in your code. They're in the OS libraries bundled inside the image - OpenSSL, libcurl, zlib - packages that get patched on the open internet but not inside the frozen snapshot of a Docker image that hasn't been rebuilt in months.
the image you pulled last year is more dangerous than the image you pull today.
Layer 1 - Stop Running Everything as Root
When you run a Docker container without specifying a user, every process inside runs as root. UID 0. The same root that owns the host machine.
Inside the container it feels harmless. The namespace keeps it isolated, right?
But container isolation relies on Linux namespaces and kernel features, not hardware-level separation like a VM. When a vulnerability in your app or the container runtime is exploited, that isolation can break. And when it does, root inside the container means root on the host.
CVE-2024-21626, a vulnerability discovered in runc in January 2024, demonstrated this exactly. By manipulating how Docker handled the WORKDIR instruction, an attacker could expose host filesystem file descriptors to the container. A root process inside the container could then read or write any file on the host machine. The attack worked even if the containerized application itself was completely secure.
Running as a non-root user blocks this entire class of attack. The process simply doesn't have permission to do anything meaningful even if it escapes.
Root vs Non-Root Container
FROM node:22-alpine
CMD ["node", "server.js"]
UID 0 — root
RUN adduser --system appuser
USER appuser
UID 1001 — appuser
The fix is two lines in your Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
# Create a dedicated user and switch to it
RUN addgroup --system --gid 1001 appgroup && \
adduser --system --uid 1001 --ingroup appgroup appuser
# Give ownership of the app directory to the new user
COPY --chown=appuser:appgroup . .
# Never run as root in production
USER appuser
CMD ["node", "server.js"]
Or in your Compose file for third-party images:
services:
nginx:
image: nginx:alpine
user: "1001:1001"
Non-root users can't bind to ports below 1024. If your app needs port 80, run it on port 3000 internally and let nginx handle port 80 on the outside — or add CAP_NET_BIND_SERVICE capability specifically for that purpose.
Layer 2 - Linux Capabilities: Root Isn't One Thing
Most developers think of "root" as a binary state, you either have it or you don't. The reality is more nuanced, and understanding it gives you much more precise control.
Linux splits root's powers into approximately 40 separate capabilities. Each one is an independent permission that can be granted or revoked.
Docker gives containers a limited but still generous set of capabilities by default. The right approach is to drop everything and add back only exactly what your app needs:
services:
backend:
security_opt:
- no-new-privileges:true # processes can't gain new privileges
cap_drop:
- ALL # strip every capability
cap_add:
- NET_BIND_SERVICE # add back only what's needed
Linux Capabilities — Drop All, Add Back Only What You Need
The principle of least privilege
no-new-privileges is the important one most people miss. It prevents any process inside the container from gaining new privileges through setuid binaries, even if root was already dropped, this closes a remaining escalation path.
Never add SYS_ADMIN unless you have a very specific and understood reason. It grants such broad privileges that it essentially undoes everything else you've done. And never use --privileged in production — a privileged container has full access to the host. It completely destroys container isolation.
Think About It
This command lists every running container and shows whether it's running as root:
docker ps -q | xargs -I{} docker inspect \
--format '{{.Name}} → User: {{.Config.User}}' {}
Any container with a blank User field is running as root.
Run it on your production server. Count the blanks.
For most setups that have never thought about this, the number will be higher than expected. That's not a crisis, it's a starting point. Each blank is a container to harden on your next deploy.
Layer 3 - The Image Problem You Can't See
Your application code might be perfectly written. Your Dockerfile might be clean and minimal. But you're still inheriting whatever vulnerabilities exist in the base image you started with.
This is the supply chain problem in containers. When you write FROM node:22-alpine, you're trusting that image's contents. And that image was built weeks or months ago. Libraries have been patched on the open internet since then. The image hasn't been.
Practice 1 - Scan your images regularly.
Docker Scout is built directly into Docker for this:
docker scout cves myapp/backend:1.0.0
Output:
✗ CRITICAL CVE-2024-XXXXX libssl 3.0.1
✗ HIGH CVE-2023-XXXXX libcurl 7.88.0
✓ No vulnerabilities in application layer
The critical finding is almost never your code. It's almost always a library in the base OS. The fix is simple: rebuild with a newer base image.
Practice 2 - Rebuild base images regularly.
A Docker image built 6 months ago with FROM node:22-alpine is not the same as one built today with the same instruction. The tag is the same but Docker Hub has updated what that tag points to. Libraries are patched. CVEs are fixed.
Add a weekly scheduled rebuild to your CI/CD pipeline:
# .github/workflows/weekly-rebuild.yml
on:
schedule:
- cron: '0 2 * * 1' # every Monday at 2am
jobs:
rebuild:
steps:
- uses: docker/build-push-action@v5
with:
push: true
tags: myapp/backend:latest
no-cache: true # force fresh pull of base image
Your app code didn't change. But your image is now built on a base that has this week's security patches. Automated security with zero developer effort.
Layer 4 - Secrets: The Most Common Mistake
If this is in your compose file, that's a problem:
environment:
DB_PASSWORD: mypassword123
JWT_SECRET: supersecretkey
API_KEY: sk-1234567890abcdef
And if that compose file is in a git repository, even a private one, those credentials have now been committed to version history. Deleting them later doesn't remove them from git history. Anyone with access to the repository, present or future, can find them.
The correct pattern has two levels depending on your security requirements.
Two Levels of Secret Management
DB_PASSWORD: mypassword123
JWT_SECRET: supersecretkey
DB_PASSWORD: ${DB_PASSWORD}
# .env on server only — in .gitignore
DB_PASSWORD=actual-value
/run/secrets/db_password
# read in app code
Files.readString(Path.of("/run/secrets/..."))
Level 1 - Environment variables from a server-only .env file:
# docker-compose.yml (safe to commit)
environment:
DB_PASSWORD: ${DB_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
# .env - lives only on the server, never committed
DB_PASSWORD=your-actual-password
JWT_SECRET=your-actual-secret
Add .env to .gitignore. The compose file is safe to push. The secrets never leave the server.
Level 2 - Docker secrets (for higher security requirements):
services:
backend:
secrets:
- db_password
- jwt_secret
secrets:
db_password:
file: ./secrets/db_password.txt
jwt_secret:
file: ./secrets/jwt_secret.txt
Docker secrets mount as files at /run/secrets/ inside the container. They're stored in memory, never written to the container's filesystem, and never visible in docker inspect. Your app reads the secret by reading a file rather than an environment variable.
// reading a Docker secret in Java
String dbPassword = Files.readString(Path.of("/run/secrets/db_password")).strip();
The advantage over environment variables: environment variables can leak through crash reports, debug output, and third-party monitoring tools that dump process environment. Files at /run/secrets/ don't have that exposure surface.
Layer 5 - Lock the Filesystem
If your application doesn't need to write to its own filesystem and most web apps don't, they write to databases and object storage - lock it:
services:
frontend:
image: nginx:alpine
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
read_only: true makes the entire container filesystem immutable. An attacker who exploits your application and tries to install tools, drop scripts, or modify binaries can't. The filesystem rejects every write.
tmpfs mounts give specific directories write access in RAM only - no disk persistence. nginx needs to write cache and PID files; those go to RAM and disappear when the container stops.
Read-Only Filesystem with tmpfs Exceptions
Attacker exploits app → tries to install tools → filesystem rejects every write
For your backend containers that write to the database but not to local disk:
services:
backend:
read_only: true
tmpfs:
- /tmp # Java and Spring Boot need temp space
volumes:
- uploads:/app/uploads # only mount what genuinely needs writes
This pattern - read-only root with minimal tmpfs exceptions is one of the highest-impact security improvements you can make with almost no downside.
Layer 6 - seccomp
This one requires almost no work from you because Docker does it automatically but it's worth understanding because it's quietly protecting every container you run.
seccomp (secure computing mode) is a Linux kernel feature that lets you define which system calls a process is allowed to make. There are over 300 syscalls in Linux. Most apps use fewer than 50. The rest - reboot, mount, ptrace, init_module — have no business being called from inside a container.
Docker ships with a default seccomp profile that blocks about 44 dangerous syscalls automatically, on every container, without any configuration needed from you.
For most applications this default profile is more than sufficient. For highly sensitive workloads you can apply stricter custom profiles:
services:
backend:
security_opt:
- seccomp:/path/to/strict-profile.json
If someone tells you to run a container with --security-opt seccomp=unconfined, they're disabling this protection entirely. Don't do it unless you have a very specific and understood reason.
The Security Checklist - Before Every Production Deploy
Image security:
- Base image version is pinned (not
:latest) - Image has been scanned (
docker scout cves) - Multi-stage build used to minimize image contents
.dockerignoreexcludes.env,.git, credentials
Runtime security:
USERinstruction set to non-root in Dockerfileno-new-privileges: truein Composesecurity_optcap_drop: ALLwith only necessary caps added backread_only: truewhere the app doesn't write to filesystem
Secrets:
- No credentials hardcoded in Dockerfile or Compose file
.envfile in.gitignore- Secrets use
${VARIABLE}with no fallback defaults
Network:
- Databases bound to
127.0.0.1, not0.0.0.0 - Only ports that need public access are published
- Services use custom named networks, not default bridge
Ongoing:
- Weekly image rebuilds scheduled in CI/CD
- Automated scanning in CI/CD pipeline
The Mindset Shift That Makes This Stick
It's easy to skip because the consequences aren't immediate. Your container runs fine without any of these practices. Until it doesn't.
Tesla's cloud was mining cryptocurrency for someone else for weeks before anyone noticed. Because default configurations are designed for convenience, not security. And convenience is what ships.
The question isn't whether to secure your setup. It's whether you do it before or after something goes wrong.
Every item on that checklist above takes minutes to implement. Together they reduce your attack surface dramatically - not to zero, but to a place where an attacker has to work significantly harder to do real damage.
What's Next
The final article in this series ties everything together: CI/CD with Docker - how to build, scan, tag, and deploy images automatically on every code push, with zero manual steps.
If you've been doing any part of your Docker workflow manually, that article will show you how to automate the whole thing end-to-end.
What's the most surprising Docker security gap you've discovered in a project you were working on? Drop it in the comments.



