Yasas Banuka
Securing Your Docker Setup (Most People Skip This)
Back to BlogDevOps

Securing Your Docker Setup (Most People Skip This)

July 14, 202611 min read·2,062 words
Share

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

Running as Root (Default)

FROM node:22-alpine

CMD ["node", "server.js"]

UID 0 — root

Full host access if container escapes
CVE-2024-21626 class of attacks possible
Can write to any host path
vs
Running as Non-Root

RUN adduser --system appuser

USER appuser

UID 1001 — appuser

Escape blocked — no permissions on host
Cannot write outside app directory
Attack surface drastically reduced

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"
Tip

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

cap_drop: ALLRemove every capability
CAP_NET_BIND_SERVICE
Bind ports < 1024OK
CAP_CHOWN
Change file ownership
CAP_KILL
Signal any process
CAP_NET_RAW
Raw network packets
CAP_SYS_PTRACE
Attach debuggers
CAP_SYS_ADMIN
Basically full rootAVOID
cap_add: [NET_BIND_SERVICE]Add back only this

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.

Warning

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

✗ Never Do This
# hardcoded in compose — committed to git
DB_PASSWORD: mypassword123
JWT_SECRET: supersecretkey
Level 1 — .env fileGood for most projects
# compose (safe to commit)
DB_PASSWORD: ${DB_PASSWORD}

# .env on server only — in .gitignore
DB_PASSWORD=actual-value
Level 2 — Docker secretsHigh-security workloads
# mounted as file in memory
/run/secrets/db_password

# read in app code
Files.readString(Path.of("/run/secrets/..."))
Never visible in docker inspect or crash dumps

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

nginx:alpine containerread_only: true
🔒/ (root filesystem)
immutable
🔒/usr/share/nginx/html
immutable
💾/tmp (tmpfs)
RAM only — no disk
💾/var/cache/nginx (tmpfs)
RAM only — no disk
💾/var/run (tmpfs)
RAM only — no disk

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
Warning

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
  • .dockerignore excludes .env, .git, credentials

Runtime security:

  • USER instruction set to non-root in Dockerfile
  • no-new-privileges: true in Compose security_opt
  • cap_drop: ALL with only necessary caps added back
  • read_only: true where the app doesn't write to filesystem

Secrets:

  • No credentials hardcoded in Dockerfile or Compose file
  • .env file in .gitignore
  • Secrets use ${VARIABLE} with no fallback defaults

Network:

  • Databases bound to 127.0.0.1, not 0.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.

#docker#security#devops#containers#linux
Share
← All Articles
Yasas Banuka Malavige

Written by

Yasas Banuka Malavige

DevOps Engineer · Building resilient infrastructure, automating pipelines, and documenting the quiet foundations that keep production systems alive.