Skip to content

Latest commit

 

History

History
508 lines (388 loc) · 10.1 KB

File metadata and controls

508 lines (388 loc) · 10.1 KB

Container Security for Beginners: The 8 Docker Mistakes to Stop Doing

YouTube Video


Overview

Docker is amazing for homelabs; but the “it works” default is often not the “it’s safe” default.

This guide covers 8 common Docker mistakes and gives you real-world, copy‑paste fixes using popular homelab services (Vaultwarden, Postgres, Portainer, Uptime Kuma, etc.).

Goal: safer containers without turning your homelab into a PhD project.


What you need

  • Docker + Docker Compose
  • A test host (or VM) where you can safely break stuff
  • Optional but recommended: a reverse proxy (Caddy/NPM/Traefik)

Baseline demo setup (used in examples)

We’ll use a simple “app + database” stack and tighten it up as we go.

Bad (common) starting point:

services:
  vaultwarden:
    image: vaultwarden/server:latest
    ports:
      - "8080:80"
    environment:
      - ADMIN_TOKEN=supersecret
      - DATABASE_URL=postgresql://vaultwarden:pw@db:5432/vaultwarden

  db:
    image: postgres:latest
    environment:
      - POSTGRES_PASSWORD=pw
      - POSTGRES_USER=vaultwarden
      - POSTGRES_DB=vaultwarden
    ports:
      - "5432:5432"

We’ll fix problems like root containers, latest tags, flat networks, socket mounts, etc.


Mistake 1 - Running everything as root

Why it’s bad/ If the app gets exploited, the attacker starts as root inside the container, which can help them:

  • tamper with files mounted from the host
  • abuse Linux capabilities
  • pivot to other containers

Bad example/ Most images will run as root unless you change it.

services:
  uptimekuma:
    image: louislam/uptime-kuma:1.23.16
    ports:
      - "3001:3001"

Fix/ Prefer images that already drop privileges. If they don’t, set a non-root user.

services:
  uptimekuma:
    image: louislam/uptime-kuma:1.23.16
    user: "1000:1000"
    ports:
      - "3001:3001"
    volumes:
      - uptimekuma-data:/app/data
    restart: unless-stopped

volumes:
  uptimekuma-data:
mkdir -p uptimekuma-data
sudo chown -R 1000:1000 uptimekuma-data

If the container needs to listen on 80/443, put a reverse proxy in front and keep apps on high ports.


Mistake 2 - Using privileged: true

Why it’s bad/ privileged: true essentially turns off most container isolation. It grants broad access to the host.

Bad example/

services:
  someapp:
    image: ghcr.io/linuxserver/swag
    privileged: true

Fix/ Remove privileged and add only what’s needed:

  • specific devices
  • specific capabilities
  • explicit mounts

Example: allow a container to read the time zone file (read-only) without privilege:

services:
  app:
    image: ghcr.io/linuxserver/heimdall:2.6.3
    volumes:
      - /etc/localtime:/etc/localtime:ro
    restart: unless-stopped

If you truly need host‑level access (rare), consider running that component on a dedicated VM.


Mistake 3 - Mounting the Docker socket into random containers

Why it’s bad/ Anyone who can access /var/run/docker.sock can control Docker (and often the host). This is one of the most common “own the box” shortcuts.

Bad example/ Portainer:

services:
  portainer:
    image: portainer/portainer-ce:latest
    ports:
      - "9000:9000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Fix/ options

Option A (best): Don’t mount the socket/ If you only need a web UI for one service, you probably don’t need Portainer at all.

Option B (safer): Use a Docker Socket Proxy/ This limits what the UI can do.

services:
  socket-proxy:
    image: tecnativa/docker-socket-proxy:0.1.1
    environment:
      - CONTAINERS=1
      - SERVICES=1
      - NETWORKS=1
      - TASKS=1
      - POST=0
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks: [mgmt]
    restart: unless-stopped

  portainer:
    image: portainer/portainer-ce:2.21.5
    command: -H tcp://socket-proxy:2375
    ports:
      - "9000:9000"
    networks: [mgmt]
    depends_on: [socket-proxy]
    restart: unless-stopped

networks:
  mgmt:

Still treat it as high risk: never expose it publicly.


Mistake 4 - latest tags everywhere

Why it’s bad/

  • restarts can pull breaking changes
  • impossible to reproduce your environment
  • can turn minor updates into outages

Bad example/

image: traefik:latest

Fix/ Pin versions:

image: traefik:v3.0.4

Minimal update routine:/

  • Update during a weekly window
  • Read release notes for major upgrades
  • Backup before updating stateful services

Mistake 5 - Containers with full read/write filesystem

Why it’s bad/ If compromised, attackers can:

  • drop tooling
  • modify app files
  • add persistence

Bad example/

services:
  gatus:
    image: twinproduction/gatus:latest

Fix/ Make the root filesystem read-only and explicitly mount only what must be writable.

services:
  gatus:
    image: twinproduction/gatus:v5.13.1
    read_only: true
    tmpfs:
      - /tmp
    volumes:
      - ./config:/config:ro
    ports:
      - "8081:8080"
    restart: unless-stopped

Mistake 6 - Too many Linux capabilities

Why it’s bad/ Linux capabilities are “root powers”. Many apps need none.

Bad example/ A lot of containers implicitly run with default capabilities.

Fix/ Drop everything and add back only what’s required.

services:
  app:
    image: ghcr.io/immich-app/immich-server:v1.132.2
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    restart: unless-stopped

If you must bind to ports below 1024 inside the container, add only this:

cap_add:
  - NET_BIND_SERVICE

Mistake 7 - No network segmentation

Why it’s bad/ A flat network makes lateral movement easy: compromise one container --> scan and attack everything.

Bad example/ Everything on default network, database exposed to the LAN:

services:
  app:
    image: vaultwarden/server:1.32.7
    ports:
      - "8080:80"
  db:
    image: postgres:16
    ports:
      - "5432:5432"

Fix/ Use separate networks:

  • proxy for inbound traffic
  • backend for internal services

...and don’t publish database ports.

services:
  vaultwarden:
    image: vaultwarden/server:1.32.7
    networks: [proxy, backend]
    # only expose via reverse proxy, or bind to localhost for testing
    ports:
      - "127.0.0.1:8080:80"

  db:
    image: postgres:16.4
    networks: [backend]
    # no ports -> not reachable from LAN
    environment:
      - POSTGRES_USER=vaultwarden
      - POSTGRES_DB=vaultwarden
      - POSTGRES_PASSWORD=pw

networks:
  proxy:
  backend:

Mistake 8 - No secrets hygiene

Why it’s bad/ Environment variables are convenient, but secrets often leak:

  • compose exports
  • logs
  • process listings

Bad example/

environment:
  - POSTGRES_PASSWORD=pw
  - ADMIN_TOKEN=supersecret

Fix/ (recommended) Use Docker secrets (Compose supports this fine for local homelabs).

services:
  db:
    image: postgres:16.4
    environment:
      - POSTGRES_USER=vaultwarden
      - POSTGRES_DB=vaultwarden
      - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
    secrets:
      - db_password

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

And lock file permissions:

mkdir -p ./secrets
openssl rand -base64 32 > ./secrets/db_password.txt
chmod 700 ./secrets
chmod 600 ./secrets/db_password.txt

Bonus “quick wins” (low effort, high value)

Add restart policies

restart: unless-stopped

Add healthchecks

healthcheck:
  test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
  interval: 30s
  timeout: 5s
  retries: 3

Set resource limits (stop noisy neighbors)

services:
  app:
    image: nginx:1.27.3
    mem_limit: 1g
    cpus: "1.0"

Note: deploy: limits are enforced by Swarm; for plain Docker Compose you can use mem_limit, cpus, etc. depending on your compose version.

Add no-new-privileges

security_opt:
  - no-new-privileges:true

Scan images (easy mode)

trivy image vaultwarden/server:1.32.7

A safer baseline compose (copy/paste template)

services:
  app:
    image: yourimage:1.2.3 # pin version (or digest)
    container_name: app
    # Prefer reverse proxy networks. If you must publish ports, bind to localhost:
    ports:
      - "127.0.0.1:8080:8080"
    # Run as non-root (set UID/GID that matches your host/user or volume perms)
    user: "1000:1000"
    # Least privilege
    cap_drop: ["ALL"]
    # cap_add: ["NET_BIND_SERVICE"] # only if you really need low ports <1024
    security_opt:
      - no-new-privileges:true
    # Filesystem hardening
    read_only: true
    tmpfs:
      - /tmp
      - /run
    # Only mount what you need, prefer read-only
    volumes:
      - app_data:/data
      # - ./config:/config:ro
    # Put secrets in files, not plain env
    environment:
      - TZ=Europe/Berlin
      # - PASSWORD_FILE=/run/secrets/app_password
    secrets:
      # - app_password
      []
    # Health + restart hygiene
    healthcheck:
      test: ["CMD", "sh", "-lc", "echo ok"] # replace with real check
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    # Network segmentation
    networks:
      - frontend
      - backend
    # Resource limits (compose support varies by engine, but still useful)
    mem_limit: 512m
    cpus: 1.0

    # Avoid: privileged: true
    # Avoid: mounting /var/run/docker.sock

networks:
  frontend:
  backend:
    internal: true # blocks outbound access from that network (optional)

volumes:
  app_data:

secrets:
  # app_password:
  #   file: ./secrets/app_password.txt

Next steps

  • Put a reverse proxy in front (Caddy / NPM / Traefik) and remove random exposed ports
  • Segment “risky” services into separate VMs (especially anything internet-facing)
  • Add backups + restore tests for stateful services
  • Centralize logs (Loki/Graylog/Wazuh) and actually look at them