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.

Share
Secure Docker and Portainer
Photo by Piermario Eva / Unsplash

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.

ℹ️
Remember to fill in the basics in Notes or any app you use.
You will not remember the detail after a month or a year.

Add Xterm.js

Step 1

Add a serial port: go to VMHardware → 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/grub
  • sudo update-grub
  • sudo systemctl reboot or sudo init 6

Now you can use the X-term Console in a new window

  • Console → select xterm.js
  • VMHardwareDisplay → 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-agent

Enable QEMU Guest Agent

sudo systemctl enable qemu-guest-agent.service --now

Test for success

sudo systemctl status qemu-guest-agent.service

Install 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 --now

Test for success

sudo systemctl status docker

Add your user to the docker group

sudo usermod -aG docker $USER

Install 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:

  1. Isolated network - All containers communicate on a dedicated socket-proxy network, separating them from other Docker containers
  2. Localhost-only Portainer - 127.0.0.1:9443 binds Portainer only to localhost, preventing external access unless you set up a reverse proxy
  3. no-new-privileges - Prevents privilege escalation within containers
  4. Read-only Docker socket - :ro flag on the socket mount
  5. Minimal permissions - ALLOW_START, ALLOW_STOP, and ALLOW_RESTARTS set to 0, so containers can only read Docker info, not control containers
    1. 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: proxy

Docker 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:true

There 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=false

Updating 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_yaml
  • docker compose stop
  • cp -r /path-to-data /path-to-data.bak-$(date +%Y%m%d) – OPTIONAL
  • docker compose pull
  • docker compose up -d


References

Socket Proxy [1] Xterm.js [2]


  1. The Socket Proxy is a security-enhanced proxy which allows you to apply access rules to the Docker socket, limiting the attack surface for containers such as Portainer or Traefik that need to use it.
    Linuxserver Docs, Images
    Or getting started using the Technativa version GitHub, their homepage ↩︎

  2. Xterm.js homepage, GitHub ↩︎