Docker and Container Security on VPS: Production Setup Guide
Docker containers provide isolation and portability, but proper security configuration is essential for production deployments. This comprehensive guide covers secure Docker setup from installation to production deployment, incorporating the latest 2025 security standards and best practices.
Quick Security Assessment
Before diving into implementation, assess your current security posture:
- Are you running Docker as root? (Consider rootless mode)
- Do your containers run as non-root users?
- Are you using the latest Docker version? (Critical for Leaky Vessels and other CVE patches)
- Do you scan images for vulnerabilities before deployment?
- Are you using security profiles (AppArmor, SELinux, or seccomp)?
Docker Installation and Initial Security
## Installing Docker on Ubuntu 22.04
# Update system packages
sudo apt update && sudo apt upgrade -y
# Install prerequisites
sudo apt install apt-transport-https ca-certificates curl software-properties-common
# Add Docker GPG key and repository
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin
## Post-Installation Security Configuration
# Option 1: Traditional Docker with Security Hardening
# Create Docker group (less secure than rootless)
sudo groupadd docker
sudo usermod -aG docker $USER
newgrp docker
# Option 2: Docker Rootless Mode (Recommended for 2025)
# Install rootless prerequisites
sudo apt install -y uidmap slirp4netns
# Install Docker rootless
curl -fsSL https://get.docker.com/rootless | sh
# Add to PATH
echo 'export PATH=$HOME/bin:$PATH' >> ~/.bashrc
echo 'export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock' >> ~/.bashrc
source ~/.bashrc
# Start rootless Docker
systemctl --user start docker
systemctl --user enable docker
Note: Rootless mode limitations:
- Cannot bind to ports < 1024 (use reverse proxy or port forwarding)
- Slightly reduced network performance (slirp4netns overhead)
- Some volume mount restrictions
# Configure Docker Daemon Security
Create/etc/docker/daemon.json (or ~/.config/docker/daemon.json for rootless):
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3",
"labels": "production"
},
"live-restore": true,
"userland-proxy": false,
"no-new-privileges": true,
"default-runtime": "runc",
"runtimes": {
"runc": {
"path": "runc"
}
},
"storage-driver": "overlay2",
"storage-opts": [
"overlay2.override_kernel_check=true"
],
"exec-opts": ["native.cgroupdriver=systemd"],
"userland-proxy-path": "/usr/bin/docker-proxy",
"userns-remap": "default"
}
Container Security Best Practices
## Image Security
# Use Official Base Images
# Good: Official images are regularly updated
FROM node:18-alpine
# Better: Specify exact versions
FROM node:18.17.0-alpine3.18
# Best: Use distroless or minimal images
FROM gcr.io/distroless/nodejs18-debian11
# Scan Images for Vulnerabilities
##### Docker Scout (Native Integration - Best for Docker Hub users)
# Enable Docker Scout
docker scout quickview nodejs:18-alpine
# Detailed CVE analysis
docker scout cves nodejs:18-alpine
# Compare versions for security improvements
docker scout compare nodejs:18-alpine nodejs:20-alpine
##### Trivy (Most Comprehensive - Recommended for CI/CD)
# Install Trivy
sudo apt-get install wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee -a /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install trivy
# Scan for all vulnerabilities
trivy image --severity HIGH,CRITICAL nodejs:18-alpine
# Scan with SBOM generation
trivy image --format spdx-json --output sbom.json nodejs:18-alpine
# Scan IaC and secrets
trivy config .
##### Grype (Lightweight, SBOM-focused)
# Install Grype
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# Basic scan
grype nodejs:18-alpine
# Generate and scan SBOM
syft nodejs:18-alpine -o spdx-json > sbom.json
grype sbom:sbom.json
##### Automated Scanning in CI/CD
# GitHub Actions example
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'nodejs:18-alpine'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
## Dockerfile Security Hardening
# Example Secure Dockerfile (2025 Best Practices)
# Multi-stage build for smaller attack surface
FROM node:20.11.0-alpine3.19 AS builder
# Use specific versions and checksums
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
# Create non-root user early
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001 -G nodejs
# Set working directory
WORKDIR /app
# Install dependencies with security audit
COPY package*.json ./
RUN npm ci --only=production && \
npm audit fix && \
npm cache clean --force
# Copy and build application
COPY --chown=nextjs:nodejs . .
RUN npm run build
# Production stage - distroless for minimal attack surface
FROM gcr.io/distroless/nodejs20-debian12
# Copy from builder
COPY --from=builder --chown=1001:1001 /app/dist /app
COPY --from=builder --chown=1001:1001 /app/node_modules /app/node_modules
# Set security labels
LABEL security.scan="true" \
security.nonroot="true" \
maintainer="[email protected]"
# Run as non-root user
USER 1001
# Set working directory
WORKDIR /app
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["node", "healthcheck.js"]
# Expose port (non-privileged)
EXPOSE 3000
# Use exec form for proper signal handling
ENTRYPOINT ["node"]
CMD ["server.js"]
# Alternative: Hardened Alpine Image
# Using Docker's new hardened images (95% smaller attack surface)
FROM docker/hardened-node:20-alpine
# Security-focused build
RUN apk add --no-cache \
--repository https://alpine.global.ssl.fastly.net/alpine/edge/main \
--repository https://alpine.global.ssl.fastly.net/alpine/edge/community \
dumb-init && \
rm -rf /var/cache/apk/* /tmp/*
# Rest of configuration...
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "server.js"]
## Runtime Security Configuration
# Maximum Security Runtime Options
# Create custom seccomp profile
cat > /etc/docker/seccomp/webapp.json <<EOF
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{"names": ["read", "write", "open", "close", "fstat", "mmap", "mprotect", "munmap", "brk", "rt_sigaction", "rt_sigprocmask", "ioctl", "pread64", "pwrite64", "readv", "writev", "pipe", "select", "sched_yield", "mremap", "msync", "mincore", "madvise", "shmget", "shmat", "shmctl", "dup", "dup2", "pause", "nanosleep", "getitimer", "alarm", "setitimer", "getpid", "sendfile", "socket", "connect", "accept", "sendto", "recvfrom", "sendmsg", "recvmsg", "shutdown", "bind", "listen", "getsockname", "getpeername", "socketpair", "setsockopt", "getsockopt", "clone", "fork", "vfork", "execve", "exit", "wait4", "kill", "uname", "semget", "semop", "semctl", "shmdt", "msgget", "msgsnd", "msgrcv", "msgctl", "fcntl", "flock", "fsync", "fdatasync", "truncate", "ftruncate", "getdents", "getcwd", "chdir", "fchdir", "rename", "mkdir", "rmdir", "creat", "link", "unlink", "symlink", "readlink", "chmod", "fchmod", "chown", "fchown", "lchown", "umask", "gettimeofday", "getrlimit", "getrusage", "sysinfo", "times", "getuid", "getgid", "setuid", "setgid", "geteuid", "getegid", "setpgid", "getppid", "getpgrp", "setsid", "setreuid", "setregid", "getgroups", "setgroups", "setresuid", "getresuid", "setresgid", "getresgid", "getpgid", "setfsuid", "setfsgid"], "action": "SCMP_ACT_ALLOW"}
]
}
EOF
# Run with maximum security
docker run \
--name secure-app \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=100m \
--tmpfs /run:noexec,nosuid,size=10m \
--security-opt=no-new-privileges:true \
--security-opt=apparmor:docker-default \
--security-opt=seccomp=/etc/docker/seccomp/webapp.json \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--user 1001:1001 \
--memory=512m \
--memory-swap=512m \
--cpus=0.5 \
--pids-limit=50 \
--restart=on-failure:3 \
--log-driver=json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
--network=frontend \
--env-file=.env.production \
--health-cmd="curl -f http://localhost:3000/health || exit 1" \
--health-interval=30s \
--health-timeout=10s \
--health-retries=3 \
--health-start-period=40s \
myapp:latest
# SELinux/AppArmor Profiles
# For Red Hat/CentOS (SELinux)
sudo semanage fcontext -a -t container_file_t '/app(/.*)?'
sudo restorecon -Rv /app
# For Ubuntu/Debian (AppArmor)
sudo aa-complain docker-default
sudo aa-enforce docker-default
Production Docker Compose Setup
## Secure docker-compose.yml Example
version: '3.8'
services:
web:
image: nginx:1.24-alpine
container_name: web
restart: unless-stopped
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./html:/usr/share/nginx/html:ro
ports:
- "80:80"
- "443:443"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
deploy:
resources:
limits:
memory: 128M
cpus: '0.5'
networks:
- frontend
depends_on:
- app
app:
build: .
container_name: app
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
environment:
- NODE_ENV=production
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
user: "1001:1001"
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
networks:
- frontend
- backend
depends_on:
- database
database:
image: postgres:15-alpine
container_name: database
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: dbuser
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
networks:
- backend
networks:
frontend:
driver: bridge
internal: false
backend:
driver: bridge
internal: true
volumes:
db_data:
driver: local
secrets:
db_password:
file: ./secrets/db_password.txt
Network Security
## Custom Bridge Networks
# Create isolated networks
docker network create --driver bridge \
--subnet=172.20.0.0/16 \
--ip-range=172.20.240.0/20 \
frontend
docker network create --driver bridge \
--subnet=172.21.0.0/16 \
--ip-range=172.21.240.0/20 \
--internal \
backend
## Firewall Configuration with Docker
# Configure UFW to work with Docker
sudo ufw --force reset
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH
sudo ufw allow 22/tcp
# Allow HTTP/HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Configure Docker integration
echo 'DEFAULT_FORWARD_POLICY="ACCEPT"' | sudo tee -a /etc/default/ufw
# Restart UFW
sudo ufw --force enable
sudo systemctl restart docker
Secrets Management
## Docker Secrets (Swarm Mode)
# Initialize swarm
docker swarm init
# Create secret
echo "supersecretpassword" | docker secret create db_password -
# Use in service
docker service create \
--name myapp \
--secret db_password \
--env POSTGRES_PASSWORD_FILE=/run/secrets/db_password \
postgres:15-alpine
## External Secrets Management
# Using HashiCorp Vault
vault kv put secret/myapp \
db_password="supersecret" \
api_key="abc123"
# Using environment files (less secure)
docker-compose --env-file=.env.production up -d
Monitoring and Logging
## Container Monitoring Setup
# monitoring/docker-compose.yml
version: '3.8'
services:
prometheus:
image: prom/prometheus:v2.45.0
container_name: prometheus
restart: unless-stopped
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
ports:
- "9090:9090"
security_opt:
- no-new-privileges:true
grafana:
image: grafana/grafana:10.0.0
container_name: grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_PASSWORD=secure_password
volumes:
- grafana_data:/var/lib/grafana
ports:
- "3000:3000"
security_opt:
- no-new-privileges:true
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.47.0
container_name: cadvisor
restart: unless-stopped
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
ports:
- "8080:8080"
security_opt:
- no-new-privileges:true
volumes:
prometheus_data:
grafana_data:
## Centralized Logging
# logging/docker-compose.yml
version: '3.8'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.8.0
container_name: elasticsearch
environment:
- discovery.type=single-node
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- xpack.security.enabled=false
volumes:
- es_data:/usr/share/elasticsearch/data
ports:
- "9200:9200"
logstash:
image: docker.elastic.co/logstash/logstash:8.8.0
container_name: logstash
volumes:
- ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf:ro
ports:
- "5044:5044"
- "9600:9600"
depends_on:
- elasticsearch
kibana:
image: docker.elastic.co/kibana/kibana:8.8.0
container_name: kibana
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
ports:
- "5601:5601"
depends_on:
- elasticsearch
volumes:
es_data:
Backup and Recovery
## Automated Backup Script
#!/bin/bash
# docker-backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backup/docker"
COMPOSE_DIR="/opt/myapp"
# Create backup directory
mkdir -p $BACKUP_DIR
# Stop containers
cd $COMPOSE_DIR
docker-compose down
# Backup volumes
docker run --rm \
-v myapp_db_data:/data \
-v $BACKUP_DIR:/backup \
alpine tar czf /backup/db_data_$DATE.tar.gz -C /data .
# Backup configuration
tar czf $BACKUP_DIR/config_$DATE.tar.gz $COMPOSE_DIR
# Start containers
docker-compose up -d
# Cleanup old backups (keep 7 days)
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete
echo "Backup completed: $DATE"
## Recovery Procedure
#!/bin/bash
# docker-restore.sh
BACKUP_FILE=$1
COMPOSE_DIR="/opt/myapp"
if [ -z "$BACKUP_FILE" ]; then
echo "Usage: $0 <backup_file>"
exit 1
fi
# Stop containers
cd $COMPOSE_DIR
docker-compose down
# Remove old volume
docker volume rm myapp_db_data
# Create new volume and restore data
docker volume create myapp_db_data
docker run --rm \
-v myapp_db_data:/data \
-v $(dirname $BACKUP_FILE):/backup \
alpine tar xzf /backup/$(basename $BACKUP_FILE) -C /data
# Start containers
docker-compose up -d
echo "Restore completed"
Docker Content Trust and Image Signing
## Enable Docker Content Trust
# Enable DCT globally
export DOCKER_CONTENT_TRUST=1
# Generate signing keys
docker trust key generate my-signer
# Add signer to repository
docker trust signer add --key my-signer.pub my-signer myregistry.com/myapp
# Sign and push image
docker trust sign myregistry.com/myapp:latest
# Verify signatures
docker trust inspect --pretty myregistry.com/myapp:latest
## Using Cosign for Container Signing (CNCF Standard)
# Install cosign
curl -O -L https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
sudo mv cosign-linux-amd64 /usr/local/bin/cosign
sudo chmod +x /usr/local/bin/cosign
# Generate keys
cosign generate-key-pair
# Sign container image
cosign sign --key cosign.key myregistry.com/myapp:latest
# Verify signature
cosign verify --key cosign.pub myregistry.com/myapp:latest
Runtime Security Monitoring
## Falco Runtime Security
# falco-deployment.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: falco
spec:
selector:
matchLabels:
app: falco
template:
metadata:
labels:
app: falco
spec:
containers:
- name: falco
image: falcosecurity/falco:latest
securityContext:
privileged: true
volumeMounts:
- name: docker-sock
mountPath: /host/var/run/docker.sock
- name: kernel-headers
mountPath: /host/usr
env:
- name: FALCO_BPF_PROBE
value: "true"
volumes:
- name: docker-sock
hostPath:
path: /var/run/docker.sock
- name: kernel-headers
hostPath:
path: /usr
## Container Runtime Security with Sysdig
# Install Sysdig
curl -s https://download.sysdig.com/stable/install-sysdig | sudo bash
# Monitor container syscalls
sudo sysdig -pc -c topprocs_cpu container.name=myapp
# Detect anomalous behavior
sudo sysdig -c falco_rules
Security Auditing and Compliance
## Regular Security Checks
#!/bin/bash
# docker-security-audit.sh
echo "=== Docker Security Audit ==="
# Check for running privileged containers
echo "Privileged containers:"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" --filter "label=security.privileged=true"
# Check for containers running as root
echo -e "\nContainers running as root:"
for container in $(docker ps -q); do
user=$(docker inspect -f '{{.Config.User}}' $container)
name=$(docker inspect -f '{{.Name}}' $container)
if [ -z "$user" ] || [ "$user" = "root" ] || [ "$user" = "0" ]; then
echo "$name: $user (or root)"
fi
done
# Check for containers with excessive capabilities
echo -e "\nContainers with capabilities:"
docker ps -q | xargs docker inspect --format '{{.Name}}: {{.HostConfig.CapAdd}}'
# Check for bind mounts to sensitive paths
echo -e "\nSensitive bind mounts:"
docker ps -q | xargs docker inspect --format '{{.Name}}: {{range .Mounts}}{{if eq .Type "bind"}}{{.Source}}:{{.Destination}} {{end}}{{end}}'
echo -e "\nAudit completed."
Container Orchestration Security (Docker Swarm/Kubernetes)
## Docker Swarm Security
# Initialize swarm with TLS
docker swarm init --cert-expiry 720h --dispatcher-heartbeat 5s
# Create encrypted overlay network
docker network create \
--driver overlay \
--opt encrypted \
--subnet=10.0.0.0/24 \
secure-overlay
# Deploy with secrets
echo "db-password" | docker secret create db_pass -
docker service create \
--name webapp \
--secret db_pass \
--network secure-overlay \
--replicas 3 \
--limit-cpu 0.5 \
--limit-memory 512M \
myapp:latest
## Pod Security Standards (Kubernetes)
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
Security Checklist for Production
## Image Security
- [ ] Use specific version tags (never 'latest' in production)
- [ ] Scan all images for vulnerabilities before deployment
- [ ] Use distroless or minimal base images
- [ ] Sign images with Docker Content Trust or Cosign
- [ ] Implement multi-stage builds to reduce attack surface
- [ ] Run as non-root user (UID > 1000)
- [ ] Remove unnecessary packages and files
- [ ] Use COPY instead of ADD in Dockerfiles
- [ ] Set HEALTHCHECK instructions
## Runtime Security
- [ ] Drop all capabilities and add only required ones
- [ ] Use read-only root filesystem
- [ ] Enable no-new-privileges
- [ ] Configure seccomp, AppArmor, or SELinux profiles
- [ ] Set memory and CPU limits
- [ ] Use user namespace remapping or rootless mode
- [ ] Disable inter-container communication when not needed
- [ ] Mount secrets as tmpfs, not in image layers
## Network Security
- [ ] Use custom bridge networks, not default
- [ ] Implement network segmentation (frontend/backend)
- [ ] Configure TLS for all endpoints
- [ ] Use internal networks for databases
- [ ] Implement rate limiting and DDoS protection
- [ ] Regular security audits with tools like Docker Bench
## Monitoring & Compliance
- [ ] Centralized logging with ELK or similar
- [ ] Runtime security monitoring (Falco/Sysdig)
- [ ] Regular vulnerability scanning in CI/CD
- [ ] Implement SIEM for security events
- [ ] Regular backup and disaster recovery testing
- [ ] Compliance scanning (CIS Docker Benchmark)
- [ ] Security incident response plan
Performance vs Security Trade-offs
| Configuration | Security Level | Performance Impact | Use Case |
| Rootless Mode | High | 10-15% network overhead | Development, CI/CD |
| User Namespaces | High | Minimal | Production |
| Read-only FS | High | Minimal | Stateless apps |
| Seccomp Profiles | Medium-High | 1-3% overhead | All production |
| Network Policies | Medium | Minimal | Multi-tenant |
| Resource Limits | Medium | Can improve | All production |
Conclusion
Securing Docker containers in production requires a defense-in-depth approach across multiple layers. The landscape in 2025 emphasizes:
- Shift-Left Security: Scan and sign images early in CI/CD pipelines
- Zero-Trust Architecture: Never trust, always verify - even internal containers
- Minimal Attack Surface: Use distroless images and drop unnecessary privileges
- Runtime Protection: Implement LSMs (AppArmor/SELinux/seccomp) and monitoring
- Supply Chain Security: Verify image provenance with signing and SBOMs
- Automated Compliance: Regular scanning against CIS benchmarks
- Incident Preparedness: Have monitoring, logging, and response plans ready
- Start with rootless mode or user namespaces
- Implement comprehensive vulnerability scanning
- Use read-only filesystems where possible
- Enable security profiles (AppArmor/SELinux)
- Monitor runtime behavior for anomalies
- Regular updates and patch management
This guide reflects current best practices as of September 2025. Container security evolves rapidly - subscribe to security advisories from Docker, your OS vendor, and the CNCF.
Last updated: September 9, 2025