Secure Docker and Portainer
This post will show the use of the Docker Socket Proxy with Portainer. It allows controlled and secure automation while still maintaining security. It's a reverse proxy that allows you to control what Docker API endpoints can be accessed by Docker clients such as Portainer, DIUN and Watchtower.
The Docker Socket Proxy acts as middleware between our containers and the Docker Socket, and this way we do not expose the Docker Socket directly to our containers. It's a reverse proxy for your Docker socket that allows you to control what Docker API endpoints can be accessed by Docker clients such as Portainer, Diun, and Watchtower.
With a Docket Socket Proxy, we can:
- Limit all actions that the container can do
- Only granting permissions that are truly necessary
- It minimizes the risk by restricting which actions can be done by applications.
- Allow only specific containers to use the Proxy
- In contrast to the simple Docker Socket is exposing the full socket to everyone
- Run a small, well-defined proxy container that is controlled and can be used to access the Docker API
- It prevents direct access to the Docker Daemon, which reduces attack surfaces in our environment
Using a Docker Socket Proxy in our environment, we will minimize the risk by restricting which actions can be done by applications, prevent direct access to the Docker Daemon, which reduces attack surfaces in our environment, and allow controlled and secure automation while still maintaining security.
To set up our own Docker Socket Proxy, we will use the LinuxServer or the Technative version of the Docker Socket Proxy to restrict the API access.
Create a VM
Create a VM using a cloud image. I used noble-server-cloudimg-amd64.img.raw, it will create a 3 GiB disk that I make a bit bigger by 2-7 GB, which usually is fine. For vCPU 1 or 2 (depending on your load) and RAM 512/512 if you don't add much, but for this use case, I would use a 10 or 16 GiB disk and 2048 RAM.
You will not remember the detail after a month or a year.
Add Xterm.js
Step 1
Add a serial port: go to VM → Hardware → hit Add → select Serial Port
Step2:
In the Console perform the following
sudo sh -c echo 'GRUB_CMDLINE_LINUX="quiet console=tty0 console=ttyS0,115200"' >> /etc/default/grubsudo update-grubsudo systemctl rebootorsudo init 6
Now you can use the X-term Console in a new window
Console→ selectxterm.jsVM→Hardware→Display→ Edit Display: Graphic card:Serial terminal 0
Setup of Cloud-init
Create your user: name, password and the SSH key, and set a IP for the VM.
- Use an SSH key to secure your SSH traffic.
- You can use a DHCP reservation.
Copy the MAC address, then make a DHCP reservation, and then use DHCP. This way, no other CT/VM can use the same IP, and it will also follow any changes you do to your IP range.
Install the QEMU Guest Agent
To have full control from Proxmox, you need to set up QEMU Guest Agent. You also need to have it activated in the GUI or from the CLI.
sudo apt install qemu-guest-agentEnable QEMU Guest Agent
sudo systemctl enable qemu-guest-agent.service --nowTest for success
sudo systemctl status qemu-guest-agent.serviceInstall Docker
This way is OK for a homelab but for production pleas follow the Docker Documentation. Other ways to install Docker can be found in my other posts about Docker.
sudo curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
Enable Docker
sudo systemctl enable docker --nowTest for success
sudo systemctl status dockerAdd your user to the docker group
sudo usermod -aG docker $USERInstall the Basic Stack
Then we create the basic stack using the Socket Proxy and Portainer. You will add other containers to your system later using Portainer. Or, you could also set them up in one go.
If you are working within a Docker Swarm environment and want the socket to use it, you should apply -d overlay to the network creation command
Example of a Basic Stack
services:
socket-proxy:
image: tecnativa/docker-socket-proxy:latest
#image: lscr.io/linuxserver/socket-proxy:latest
container_name: docker-proxy
environment:
- TZ=Europe/Helsinki
## Granted by Default
- EVENTS=1
- PING=1
- VERSION=1
# used for Portainer
- ALLOW_START=1 # for better security, set to 0
- ALLOW_STOP=1 # for better security, set to 0
- ALLOW_RESTARTS=1 # for better security, set to 0
# Set to 0 by default
- AUTH=0
- BUILD=0
- COMMIT=0
- CONFIGS=0
- CONTAINERS=1 # Allow listing and managing containers
- DELETE=1
- DISABLE_IPV6=0 # For no IPv6 set to 1
- DISTRIBUTION=0
- EXEC=1 # For terminal access. Disable for better security
- IMAGES=1 # For Portainer
- INFO=1 # For Portainer
- LOG_LEVEL=info # Set as you need
- NETWORKS=1 # Allow listing networks
- NODES=0 # Allow listing nodes in the swar
- PLUGINS=0
- POST=1 # Needed for Portainer, Traefic
- SECRETS=0
- SERVICES=1 #Allow listing and managing services, for Portaine
- SESSION=0
- SWARM=0 # Allow Swarm Mode
- SYSTEM=1 # Block system-level API access
- TASKS=1 # Allow listing tasks in the swarm, for Portainer
- VOLUMES=1 # Allow listing volumes
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
read_only: true
tmpfs:
- /run
networks:
- socket-proxy
# ipv4_address 162.0.2.99 # For a Static IP
security_opt:
- no-new-privileges:true
portainer:
container_name: portainer
image: portainer/portainer-ce:lts
ports:
- "9443:9443" # open for all
# - "127.0.0.1:9443:9443" # for local host only
command: -H tcp://docker-proxy:2375
restart: unless-stopped
depends_on:
- socket-proxy
volumes:
- ./data:/data
networks:
- socket-proxy
security_opt:
- no-new-privileges:true
networks:
socket-proxy:
driver: bridge
internal: false
#ipam:
# config:
# -subnet: 192.0.2.0/24 Security improvements:
- Isolated network - All containers communicate on a dedicated socket-proxy network, separating them from other Docker containers
- Localhost-only Portainer - 127.0.0.1:9443 binds Portainer only to localhost, preventing external access unless you set up a reverse proxy
no-new-privileges- Prevents privilege escalation within containers- Read-only Docker socket -
:roflag on the socket mount - Minimal permissions - ALLOW_START, ALLOW_STOP, and ALLOW_RESTARTS set to 0, so containers can only read Docker info, not control containers
- Set ALLOW_START=1, ALLOW_STOP=1, and ALLOW_RESTARTS=1 only if you need Portainer to manage containers. For read-only monitoring, keep them at 0.
Nginx Proxy Manager
Example compose.yml file
As we call the network as external, you need to create it before starting the container: docker network create proxy
The healthcheck section is not required for running a proxy server.
services:
proxy:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
container_name: npm
ports:
# These ports are in format <host-port>:<container-port>
- '80:80' # Public HTTP Port
- '443:443' # Public HTTPS Port
- '81:81' # Admin Web Port
# Add any other Stream port you want to expose
# - '21:21' # FTP
environment:
TZ: "Europe/Helsinki" #Australia/Brisbane"
# Uncomment this if you want to change the location of
# the SQLite DB file within the container
# DB_SQLITE_FILE: "/data/database.sqlite"
# Uncomment this if IPv6 is not enabled on your host
# DISABLE_IPV6: 'true'
# Uncomment for changing the default [email protected]/changeme
# INITIAL_ADMIN_EMAIL: [email protected] # Example
# INITIAL_ADMIN_PASSWORD: changeme # Example
healthcheck:
test: ["CMD", "/usr/bin/check-health"]
interval: 10s
timeout: 3s
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks: # Create the network: docker network create proxy
default:
external: true
name: proxyDocker Image Update Notifier – DIUN
Docker Image Update Notifier (DIUN) is a CLI application written in Go and delivered as a single executable or a Docker image to generate notifications when a Docker image is updated on a Docker registry. It's been around since 2019 and is also used in my main Docker Swarm Mode cluster.
The scanning frequency is set by timing it by DIUN_WATCH_SCHEDULE
---
diun:
image: crazymax/diun:latest
container_name: diun
command: serve
volumes:
- ./data/diun:/data
#- ./data/diun.yml:/diun.yml:ro
depends_on:
- socket-proxy
#env_file:
# - .diun.env
environment:
- "TZ=Europe/Helsinki"
- "LOG_LEVEL=info"
- "LOG_JSON=false"
- "DIUN_WATCH_WORKERS=20"
- "DIUN_WATCH_SCHEDULE=0 */6 * * *"
- "DIUN_WATCH_RUNONSTARTUP=true"
- "DIUN_WATCH_FIRSTCHECKNOTIF=true"
- "DIUN_PROVIDERS_DOCKER=true"
- "DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT=true"
# The email part
- "DIUN_PROVIDERS_DOCKER_ENDPOINT=tcp://docker-proxy:2375"
- "DIUN_NOTIF_MAIL_HOST=mail.example.com"
- "DIUN_NOTIF_MAIL_PORT=587"
- "[email protected]"
- "DIUN_NOTIF_MAIL_PASSWORD=aLongAndComplicatedPassword"
- "[email protected]"
- "[email protected]"
- "DIUN_NOTIF_MAIL_TLS=true"
restart: unless-stopped
networks:
- socket-proxy
security_opt:
- no-new-privileges:trueThere are better ways to handle the environment variables, like using a .env.duin-file or the duin.yml file; see the documentation.
Exclude a container
Just like with Watchtower, there might be certain containers you want to exclude from DIUN's notifications. Just add the section below to any container
services:
your_service_name:
image: your_image:your_tag
labels:
- diun.enable=falseUpdating a Container
Portainer
- Navigate to Stacks
- Click on the stack you are to update
- Click Editor tab at the top
- Click Update the stack
- Enable Re-pull image and redeploy toggle
- Click Update
Docker CLI:
cd /directory_of_compose_yamldocker compose stopcp -r /path-to-data /path-to-data.bak-$(date +%Y%m%d)– OPTIONALdocker compose pulldocker compose up -d