services: ghost: image: ghost:5 user: "1000:1000" volumes: - ghost_data:/var/lib/ghost/content
services: ghost: image: ghost:5 user: "1000:1000" volumes: - ghost_data:/var/lib/ghost/content
services: ghost: image: ghost:5 user: "1000:1000" volumes: - ghost_data:/var/lib/ghost/content
services: uptime-kuma: image: louislam/uptime-kuma:1 read_only: true tmpfs: - /tmp volumes: - kuma_data:/app/data
services: uptime-kuma: image: louislam/uptime-kuma:1 read_only: true tmpfs: - /tmp volumes: - kuma_data:/app/data
services: uptime-kuma: image: louislam/uptime-kuma:1 read_only: true tmpfs: - /tmp volumes: - kuma_data:/app/data
services: ghost: image: ghost:5 deploy: resources: limits: memory: 512M cpus: "1.0" pids_limit: 100
services: ghost: image: ghost:5 deploy: resources: limits: memory: 512M cpus: "1.0" pids_limit: 100
services: ghost: image: ghost:5 deploy: resources: limits: memory: 512M cpus: "1.0" pids_limit: 100
# DON'T DO THIS
environment: - database__connection__password=mysecretpassword123
# DON'T DO THIS
environment: - database__connection__password=mysecretpassword123
# DON'T DO THIS
environment: - database__connection__password=mysecretpassword123
# docker-compose.yml
environment: - database__connection__password=${GHOST_DB_PASSWORD}
# docker-compose.yml
environment: - database__connection__password=${GHOST_DB_PASSWORD}
# docker-compose.yml
environment: - database__connection__password=${GHOST_DB_PASSWORD}
# .env
GHOST_DB_PASSWORD=a-real-strong-password-here
# .env
GHOST_DB_PASSWORD=a-real-strong-password-here
# .env
GHOST_DB_PASSWORD=a-real-strong-password-here
chmod 600 .env
chown root:root .env
chmod 600 .env
chown root:root .env
chmod 600 .env
chown root:root .env
docker scout cves ghost:5
docker scout cves ghost:5
docker scout cves ghost:5
# Install
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin # Scan
trivy image ghost:5
# Install
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin # Scan
trivy image ghost:5
# Install
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin # Scan
trivy image ghost:5
# Don't do this — you get whatever "latest" means today
image: ghost:latest # Do this — you know exactly what you're running
image: ghost:5.118.0
# Don't do this — you get whatever "latest" means today
image: ghost:latest # Do this — you know exactly what you're running
image: ghost:5.118.0
# Don't do this — you get whatever "latest" means today
image: ghost:latest # Do this — you know exactly what you're running
image: ghost:5.118.0
services: ghost: image: ghost:5 networks: - frontend nginx-proxy-manager: image: jc21/nginx-proxy-manager:latest networks: - frontend ports: - "80:80" - "443:443" uptime-kuma: image: louislam/uptime-kuma:1 networks: - monitoring networks: frontend: driver: bridge monitoring: driver: bridge internal: true
services: ghost: image: ghost:5 networks: - frontend nginx-proxy-manager: image: jc21/nginx-proxy-manager:latest networks: - frontend ports: - "80:80" - "443:443" uptime-kuma: image: louislam/uptime-kuma:1 networks: - monitoring networks: frontend: driver: bridge monitoring: driver: bridge internal: true
services: ghost: image: ghost:5 networks: - frontend nginx-proxy-manager: image: jc21/nginx-proxy-manager:latest networks: - frontend ports: - "80:80" - "443:443" uptime-kuma: image: louislam/uptime-kuma:1 networks: - monitoring networks: frontend: driver: bridge monitoring: driver: bridge internal: true
ports: - "127.0.0.1:3001:3001" # Only accessible from the host
ports: - "127.0.0.1:3001:3001" # Only accessible from the host
ports: - "127.0.0.1:3001:3001" # Only accessible from the host
sudo apt update && sudo apt upgrade docker-ce docker-ce-cli containerd.io
sudo apt update && sudo apt upgrade docker-ce docker-ce-cli containerd.io
sudo apt update && sudo apt upgrade docker-ce docker-ce-cli containerd.io
# Pull new versions
docker compose pull # Recreate containers with new images
docker compose up -d # Clean up old images
docker image prune -f
# Pull new versions
docker compose pull # Recreate containers with new images
docker compose up -d # Clean up old images
docker image prune -f
# Pull new versions
docker compose pull # Recreate containers with new images
docker compose up -d # Clean up old images
docker image prune -f
services: watchtower: image: containrrr/watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WATCHTOWER_CLEANUP=true - WATCHTOWER_SCHEDULE=0 0 4 * * *
services: watchtower: image: containrrr/watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WATCHTOWER_CLEANUP=true - WATCHTOWER_SCHEDULE=0 0 4 * * *
services: watchtower: image: containrrr/watchtower volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WATCHTOWER_CLEANUP=true - WATCHTOWER_SCHEDULE=0 0 4 * * *
services: ghost: image: ghost:5 cap_drop: - ALL cap_add: - CHOWN - SETUID - SETGID security_opt: - no-new-privileges:true
services: ghost: image: ghost:5 cap_drop: - ALL cap_add: - CHOWN - SETUID - SETGID security_opt: - no-new-privileges:true
services: ghost: image: ghost:5 cap_drop: - ALL cap_add: - CHOWN - SETUID - SETGID security_opt: - no-new-privileges:true
services: ghost: image: ghost:5 logging: driver: json-file options: max-size: "10m" max-file: "3"
services: ghost: image: ghost:5 logging: driver: json-file options: max-size: "10m" max-file: "3"
services: ghost: image: ghost:5 logging: driver: json-file options: max-size: "10m" max-file: "3"
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }
}
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }
}
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }
}
services: ghost: image: ghost:5 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:2368/ghost/api/admin/site/"] interval: 30s timeout: 10s retries: 3 start_period: 30s
services: ghost: image: ghost:5 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:2368/ghost/api/admin/site/"] interval: 30s timeout: 10s retries: 3 start_period: 30s
services: ghost: image: ghost:5 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:2368/ghost/api/admin/site/"] interval: 30s timeout: 10s retries: 3 start_period: 30s
docker events --filter type=container
docker events --filter type=container
docker events --filter type=container
□ Container runs as non-root (user: field or USER in Dockerfile)
□ Filesystem is read-only (read_only: true + explicit volume mounts)
□ Memory and CPU limits set (deploy.resources.limits)
□ PID limit set (pids_limit)
□ Capabilities dropped and selectively added (cap_drop: ALL)
□ no-new-privileges enabled (security_opt)
□ Secrets in .env with 600 permissions, not in compose file
□ Image version pinned (tag, not :latest)
□ Image scanned for CVEs (docker scout or trivy)
□ Container on a purpose-specific network, not default bridge
□ Only necessary ports exposed, bound to 127.0.0.1 if host-only
□ Log rotation configured (max-size + max-file)
□ Health check defined
□ Docker socket NOT mounted (unless required and justified)
□ Container runs as non-root (user: field or USER in Dockerfile)
□ Filesystem is read-only (read_only: true + explicit volume mounts)
□ Memory and CPU limits set (deploy.resources.limits)
□ PID limit set (pids_limit)
□ Capabilities dropped and selectively added (cap_drop: ALL)
□ no-new-privileges enabled (security_opt)
□ Secrets in .env with 600 permissions, not in compose file
□ Image version pinned (tag, not :latest)
□ Image scanned for CVEs (docker scout or trivy)
□ Container on a purpose-specific network, not default bridge
□ Only necessary ports exposed, bound to 127.0.0.1 if host-only
□ Log rotation configured (max-size + max-file)
□ Health check defined
□ Docker socket NOT mounted (unless required and justified)
□ Container runs as non-root (user: field or USER in Dockerfile)
□ Filesystem is read-only (read_only: true + explicit volume mounts)
□ Memory and CPU limits set (deploy.resources.limits)
□ PID limit set (pids_limit)
□ Capabilities dropped and selectively added (cap_drop: ALL)
□ no-new-privileges enabled (security_opt)
□ Secrets in .env with 600 permissions, not in compose file
□ Image version pinned (tag, not :latest)
□ Image scanned for CVEs (docker scout or trivy)
□ Container on a purpose-specific network, not default bridge
□ Only necessary ports exposed, bound to 127.0.0.1 if host-only
□ Log rotation configured (max-size + max-file)
□ Health check defined
□ Docker socket NOT mounted (unless required and justified)
services: myapp: image: myapp:1.2.3 user: "1000:1000" read_only: true tmpfs: - /tmp volumes: - app_data:/data environment: - SECRET_KEY=${APP_SECRET_KEY} networks: - backend deploy: resources: limits: memory: 256M cpus: "0.5" pids_limit: 50 cap_drop: - ALL security_opt: - no-new-privileges:true logging: driver: json-file options: max-size: "10m" max-file: "3" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 10s retries: 3 start_period: 15s networks: backend: driver: bridge internal: true
services: myapp: image: myapp:1.2.3 user: "1000:1000" read_only: true tmpfs: - /tmp volumes: - app_data:/data environment: - SECRET_KEY=${APP_SECRET_KEY} networks: - backend deploy: resources: limits: memory: 256M cpus: "0.5" pids_limit: 50 cap_drop: - ALL security_opt: - no-new-privileges:true logging: driver: json-file options: max-size: "10m" max-file: "3" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 10s retries: 3 start_period: 15s networks: backend: driver: bridge internal: true
services: myapp: image: myapp:1.2.3 user: "1000:1000" read_only: true tmpfs: - /tmp volumes: - app_data:/data environment: - SECRET_KEY=${APP_SECRET_KEY} networks: - backend deploy: resources: limits: memory: 256M cpus: "0.5" pids_limit: 50 cap_drop: - ALL security_opt: - no-new-privileges:true logging: driver: json-file options: max-size: "10m" max-file: "3" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 10s retries: 3 start_period: 15s networks: backend: driver: bridge internal: true - A Linux VPS with Docker and Docker Compose v2 installed (here's how I set mine up)
- Basic familiarity with docker-compose.yml syntax
- SSH access to your server (hardened, ideally)
- A running Docker stack you want to secure (even a single container counts) - File permissions on volumes. If your volume data was created by root, a non-root container can't write to it. Fix this with chown 1000:1000 on the host directory before switching.
- Some images expect root. Official Nginx, for example, needs root to bind to port 80. Inside a compose stack where a reverse proxy handles external traffic, your backend containers don't need to bind privileged ports at all.
- Rootless Docker mode goes further — the Docker daemon itself runs without root. This is a bigger architectural change and adds complexity around networking and storage drivers. For most self-hosters, running containers as non-root (the user: field) gives you 90% of the security benefit with 10% of the friction. - memory: 512M — the container gets killed if it tries to use more than 512 MB of RAM. Docker sends a SIGKILL, not a gentle shutdown.
- cpus: "1.0" — the container can use at most one CPU core. Prevents a single container from starving everything else.
- pids_limit: 100 — caps the number of processes inside the container. This is your fork bomb insurance. - cap_drop: ALL removes every capability.
- cap_add gives back only what the application needs. You find out which ones by dropping all and reading the error messages.
- no-new-privileges: true prevents any process inside the container from gaining additional privileges through setuid binaries. One of the highest-value single lines you can add. - Cause: The volume data is owned by root and the non-root user can't write to it.
- Fix: Run sudo chown -R 1000:1000 /path/to/volume on the host before restarting. - Cause: The application tries to write to a directory that isn't mounted as a volume or tmpfs.
- Fix: Check the container logs (docker logs <container>) for "read-only file system" errors. Add the needed path as a tmpfs mount or a named volume. - Cause: The app needs specific Linux capabilities you haven't added back.
- Fix: Start with cap_drop: ALL, then add capabilities one at a time based on the error messages. Common ones: CHOWN, SETUID, SETGID, NET_BIND_SERVICE. - Cause: Docker manipulates iptables directly, bypassing UFW/firewalld.
- Fix: Bind ports to localhost (127.0.0.1:3001:3001 instead of 3001:3001), or configure Docker to respect iptables by setting "iptables": false in /etc/docker/daemon.json (but this breaks Docker networking unless you add manual rules). - Cause: The health check command runs inside the container, which may not have curl installed.
- Fix: Use wget -q --spider instead of curl, or for minimal images use a language-native health endpoint check. - Run containers as non-root — eliminates the most dangerous default.
- Isolate your networks — stops lateral movement between services.
- Scan your images — catches known vulnerabilities before they're running in production.