Docker makes self-hosting easy. It also makes it easy to accidentally give an attacker root access to your entire server. Most self-hosting guides skip security entirely — here’s what they don’t tell you.

1. Never Run Containers as Root (When Possible)

By default, processes inside Docker containers run as root. If an attacker escapes the container, they’re root on the host.

Fix: Use the user directive:

services:
  myapp:
    image: myapp:latest
    user: "1000:1000"

Or in the Dockerfile:

RUN adduser --disabled-password --gecos "" appuser
USER appuser

Check your running containers:

docker ps -q | xargs -I {} docker exec {} whoami

If everything says root, you have work to do.

2. Don’t Expose the Docker Socket

The Docker socket (/var/run/docker.sock) gives full control over the Docker daemon — which means full control over the host. Mounting it into a container is like giving that container root SSH access.

Common offenders:

# ⚠️ Dangerous — gives container full host control
volumes:
  - /var/run/docker.sock:/var/run/docker.sock

Services that need the socket: Portainer, Traefik, Watchtower, Dozzle.

Safer alternatives:

  1. Use a socket proxy like tecnativa/docker-socket-proxy:
services:
  socket-proxy:
    image: tecnativa/docker-socket-proxy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      CONTAINERS: 1
      SERVICES: 0
      TASKS: 0
      NETWORKS: 0
      VOLUMES: 0
    networks:
      - proxy

  traefik:
    image: traefik:v3
    depends_on:
      - socket-proxy
    environment:
      DOCKER_HOST: tcp://socket-proxy:2375
    networks:
      - proxy

This exposes only the container listing API — not the ability to create, delete, or execute inside containers.

  1. Use read-only mounts when a proxy isn’t an option:
volumes:
  - /var/run/docker.sock:/var/run/docker.sock:ro

Not as secure as a proxy, but prevents writes.

3. Limit Container Resources

A runaway container can consume all CPU and RAM, taking down your entire server. Set limits:

services:
  myapp:
    image: myapp:latest
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 512M
        reservations:
          cpus: '0.5'
          memory: 128M

Or with the older syntax (still works):

mem_limit: 512m
cpus: 2.0

4. Use Read-Only Filesystems

Prevent containers from writing to the filesystem. This blocks many attack vectors:

services:
  myapp:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /run
    volumes:
      - app-data:/data  # Only this directory is writable

If the app needs to write temporary files, use tmpfs mounts.

5. Drop Unnecessary Linux Capabilities

Docker containers get a set of Linux capabilities by default (like NET_RAW, SYS_CHROOT). Most apps don’t need them:

services:
  myapp:
    image: myapp:latest
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # Only if it needs to bind to ports < 1024

Rule of thumb: Drop everything, then add back only what’s needed.

6. Don’t Use --privileged

The --privileged flag gives a container almost full access to the host kernel. It’s the nuclear option:

# ⚠️ Never do this unless absolutely necessary
privileged: true

When it’s “needed”: Some guides tell you to use --privileged for things like USB passthrough or GPU access. Almost always, there’s a more targeted approach:

# Instead of privileged: true for GPU
devices:
  - /dev/dri:/dev/dri

# Instead of privileged: true for USB
devices:
  - /dev/ttyUSB0:/dev/ttyUSB0

7. Bind Published Ports to Localhost

When you publish a port, Docker binds it to 0.0.0.0 by default — accessible from the internet:

ports:
  - "3306:3306"  # ⚠️ MySQL exposed to the world

If the service only needs to be accessed locally or by other containers:

ports:
  - "127.0.0.1:3306:3306"  # ✅ Only accessible from the host

Better yet: Don’t publish the port at all. Use Docker networks for container-to-container communication:

services:
  app:
    networks:
      - internal
  
  db:
    networks:
      - internal  # No ports published — only 'app' can reach it

networks:
  internal:

For more on this, see our guide on checking exposed ports.

8. Keep Images Updated

Old images have known vulnerabilities. Set up automatic updates with Watchtower:

services:
  watchtower:
    image: containrrr/watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      WATCHTOWER_SCHEDULE: "0 4 * * *"  # 4 AM daily
      WATCHTOWER_CLEANUP: "true"
    restart: unless-stopped

Check our Watchtower guide for the full setup.

9. Use Docker Secrets for Sensitive Data

Don’t put passwords in environment variables in your compose file — they show up in docker inspect:

# ⚠️ Visible in docker inspect
environment:
  DB_PASSWORD: supersecret123

Use Docker secrets or .env files instead:

# .env file (add to .gitignore!)
DB_PASSWORD=supersecret123
services:
  db:
    environment:
      DB_PASSWORD: ${DB_PASSWORD}

Or with Docker secrets (Swarm mode):

secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  db:
    secrets:
      - db_password

10. Network Segmentation

Don’t put all containers on the default bridge network. Create separate networks:

services:
  traefik:
    networks:
      - frontend
      - backend

  app:
    networks:
      - backend
      - database

  db:
    networks:
      - database  # Only accessible from 'app'

networks:
  frontend:
  backend:
  database:

This way, even if Traefik is compromised, the attacker can’t directly reach the database.

Quick Checklist

Run through this for every container:

  • Not running as root (or root is necessary and documented)
  • No --privileged flag
  • Docker socket not mounted (or using socket proxy)
  • Resource limits set
  • Published ports bound to localhost (or not published at all)
  • Capabilities dropped
  • Secrets not in plaintext environment variables
  • Image is recent and auto-updating
  • On a dedicated network (not default bridge)

Audit Your Setup

Want a quick security check? Run this one-liner to find containers with risky configurations:

# Find privileged containers
docker ps -q | xargs docker inspect --format '{{.Name}}: privileged={{.HostConfig.Privileged}}' | grep true

# Find containers with docker socket mounted
docker ps -q | xargs docker inspect --format '{{.Name}}: {{range .Mounts}}{{.Source}} {{end}}' | grep docker.sock

# Find containers running as root
docker ps -q | xargs -I {} sh -c 'echo "$(docker inspect --format "{{.Name}}" {}): $(docker exec {} whoami 2>/dev/null || echo "unknown")"'

Further Reading

Security isn’t a one-time task. Review your Docker setup monthly, keep images updated, and follow the principle of least privilege: every container should have only the access it absolutely needs.