Securing docker swarm (rootless)

16. Mar 2026

In this blog post I want to show how you can secure docker swarm so that docker containers are not running in root context on the host system.

Table of contents

Normal operation (rootful)

Normally, when you use Docker, all containers run in the context of the root user on the host system. This is risky if an attacker breaks out of a container, because they automatically get root privileges on the host. Also, every container can mount the Docker socket:

Isolate container under dockremap user (non-root user)

To minimize the attack vector, we can run Docker containers under another user (dockremap) with no root privileges on the host system.

Problem: If a container needs access to the Docker socket, this setup does not allow it, because root privileges are required.

Side note: Container volumes need to have the the following owner to work: sudo chown 10000:10000 webapp-vol

Solution

We can expose the Docker socket on the local loopback address on the host: http://127.0.0.1:2375. However, this exposes the Docker socket without protection. To prevent that, we install nginx on each host in the Docker Swarm cluster. Through firewall rules, only the nginx process is allowed to access http://127.0.0.1:2375. nginx adds Basic Auth and publishes it on https://172.17.0.1:2376.

The 172.17.0.0/16 network is Docker's bridge network. Each container is part of this network, so each container can communicate with the host system itself (172.17.0.1).

My custom container SockBridge (based on the HAProxy Docker image)

listens on https://172.17.0.1:2376 and uses the Basic Auth username and password (provided by environment variables) to access the protected channel. SockBridge creates a Docker overlay network (sockbridge_net) and attaches itself to it.

All other containers that need Docker socket access can be attached to this network to gain access through the SockBridge container (http://sockbridge:2375).

Setup

Edit /etc/docker/daemon.json to enable container remapping and expose the Docker socket over HTTP:

The following commands are repeated for the following systems (node1; node2; node3):

sudo mkdir /etc/docker
sudo tee /etc/docker/daemon.json >/dev/null <<EOF
{
  "userns-remap": "default",
  "hosts": ["unix:///var/run/docker.sock","tcp://127.0.0.1:2375"]
}
EOF

After that, modify the systemd docker.service to enable Docker socket access over tcp://127.0.0.1:2375:

The following commands are repeated for the following systems (node1; node2; node3):

sudo mkdir -p /etc/systemd/system/docker.service.d

sudo tee /etc/systemd/system/docker.service.d/override.conf > /dev/null <<EOF
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd
EOF

sudo systemctl daemon-reload
sudo systemctl restart docker.service

Now install nginx:

The following commands are repeated for the following systems (node1; node2; node3):

sudo apt install -y nginx apache2-utils

Create the Basic Auth username/password for nginx, so it can secure the connection to https://172.17.0.1:2376 with credentials. (This username/password combination must be used in the environment variables of the SockBridge container):

The following commands are repeated for the following systems (node1; node2; node3):

sudo htpasswd -c /etc/nginx/docker.htpasswd admin

Create a self-signed certificate for nginx:

The following commands are repeated for the following systems (node1; node2; node3):

sudo openssl req -x509 -nodes -newkey rsa:4096 \
-keyout /etc/nginx/self.key \
-out /etc/nginx/self.crt \
-days 36500 \
-subj "/CN=nginx"

Create an nginx config:

The following commands are repeated for the following systems (node1; node2; node3):

sudo tee /etc/nginx/sites-available/docker-api >/dev/null <<EOF
server {
    listen 172.17.0.1:2376 ssl;
    
    ssl_certificate     /etc/nginx/self.crt;
    ssl_certificate_key /etc/nginx/self.key;

    location / {
        auth_basic "Docker API";
        auth_basic_user_file /etc/nginx/docker.htpasswd;

        proxy_pass http://127.0.0.1:2375;
        proxy_http_version 1.1;

        proxy_set_header Host \$host;
        proxy_set_header Connection "";
    }
}
EOF

sudo ln -s /etc/nginx/sites-available/docker-api /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo systemctl stop nginx.service
sudo systemctl start nginx.service

Only allow the nginx process to access http://127.0.0.1:2375:

The following commands are repeated for the following systems (node1; node2; node3):

echo iptables-persistent iptables-persistent/autosave_v4 boolean false | sudo debconf-set-selections
echo iptables-persistent iptables-persistent/autosave_v6 boolean false | sudo debconf-set-selections
sudo apt-get -y install iptables-persistent

sudo tee /etc/iptables/rules.v4 >/dev/null <<EOF
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]

-A OUTPUT -p tcp -d 127.0.0.1 --dport 2375 -m owner --uid-owner www-data -j ACCEPT
-A OUTPUT -p tcp -d 127.0.0.1 --dport 2375 -j REJECT

COMMIT
EOF

sudo systemctl restart netfilter-persistent

OPTIONAL: Test access to the Docker socket:

# Docker socket should be accessible via nginx (use username and password from step 4):
curl -k -u admin:P@ssw0rd https://172.17.0.1:2376/version
# Example output:
{"Platform":{"Name":"Docker Engine - Community"},"Version":"29.3.0","ApiVersion":"1.54","MinAPIVersion":"1.40","Os":"linux","Arch":"amd64","Components":[{"Name":"Engine","Version":"29.3.0","Details":{"ApiVersion":"1.54","Arch":"amd64","BuildTime":"2026-03-05T14:25:48.000000000+00:00","Experimental":"false","GitCommit":"83bca51","GoVersion":"go1.25.7","KernelVersion":"6.8.0-106-generic","MinAPIVersion":"1.40","Os":"linux"}},{"Name":"containerd","Version":"v2.2.2","Details":{"GitCommit":"301b2dac98f15c27117da5c8af12118a041a31d9"}},{"Name":"runc","Version":"1.3.4","Details":{"GitCommit":"v1.3.4-0-gd6d73eb8"}},{"Name":"docker-init","Version":"0.19.0","Details":{"GitCommit":"de40ad0"}}],"GitCommit":"83bca51","GoVersion":"go1.25.7","KernelVersion":"6.8.0-106-generic","BuildTime":"2026-03-05T14:25:48.000000000+00:00"}

# Direct Docker socket access should be blocked:
curl -k http://127.0.0.1:2375/version
# Example output:
curl: (7) Failed to connect to 127.0.0.1 port 2375 after 0 ms: Couldn't connect to server

Deploy the SockBridge container:

!!! Only on machine node1 !!!:

sudo docker network create --driver overlay --attachable sockbridge_net
git clone https://github.com/SuitDeer/SockBridge.git
cd SockBridge

Now edit the environment variables inside docker-compose.yml like this:

  • UPSTREAM_DOCKER_URL: https://172.17.0.1:2376
  • DOCKER_BASIC_AUTH_USER:
  • DOCKER_BASIC_AUTH_PASS:

Start the SockBridge stack:

!!! Only on machine node1 !!!:

sudo docker stack deploy -c docker-compose.yml sockbridge

Example: Traefik

Traefik needs Docker socket access to read labels attached to other containers.

Create a traefik.yml with the following content:

!!! Only on machine node1 !!!:

services:
  traefik:
    image: traefik:v3
    command:
      # API & Dashboard
      - "--api.dashboard=true" # Enable the dashboard
      - "--api.insecure=false" # Explicitly disable insecure API mod

      # Enable Docker Swarm provider
      ############# IMPORTANT: Use docker.socket via SockBridge container ############# 
      - "--providers.swarm.endpoint=http://sockbridge:2375"
      #################################################################################
      # Watch for Swarm service changes (requires socket access)
      - "--providers.swarm.watch=true"
      # Recommended: Dont expose services by default require explicit labels
      - "--providers.swarm.exposedbydefault=false"
      # Specify the default network for Traefik to connect to services
      - "--providers.swarm.network=reverse_proxy"

      # Define entrypoints
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.websecure.http.tls=true"

      # Redirect HTTP to HTTPS
      - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
      - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
      - "--entrypoints.web.http.redirections.entrypoint.permanent=true"

      # Lets Encrypt configuration
      - --certificatesresolvers.letsencrypt.acme.email=exampleexamplecom
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web

      # Logging
      - --log.level=WARN
      - --accesslog=true

    ports:
      # Publish ports in host mode for direct access
      - target: 80
        published: 80
        protocol: tcp
      - target: 443
        published: 443
        protocol: tcp

    volumes:
      # Persistent storage for certificates
      - ./traefik/letsencrypt:/letsencrypt

    networks:
      - traefik_net
      ############# IMPORTANT: Use sockbridge_net network to access SocketBridge container #############
      - sockbridge_net
      ##################################################################################################

    deploy:
      mode: replicated
      replicas: 1
      placement:
        constraints:
          - node.role == manager
      labels:
        - "traefik.enable=true"

        # Dashboard router
        - "traefik.http.routers.dashboard.rule=PathPrefix(`/dashboard`) || PathPrefix(`/api`)"
        - "traefik.http.routers.dashboard.entrypoints=websecure"
        - "traefik.http.routers.dashboard.service=api@internal"
        - "traefik.http.routers.dashboard.tls=true"

        # Basicauth middleware
        - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:{SHA}kjwdTnMQyyIBCMDiDbVeCjtdFrg="
        - "traefik.http.routers.dashboard.middlewares=dashboard-auth@swarm"

        # Service hint
        - "traefik.http.services.traefik.loadbalancer.server.port=8080"

networks:
  traefik_net:
  ############# IMPORTANT: Use sockbridge_net network to access SocketBridge container #############
  sockbridge_net:
    external: true
  ##################################################################################################

Deploy the Traefik stack:

!!! Only on machine node1 !!!:

sudo docker stack deploy --resolve-image=always -c traefik.yaml traefik
High Available NFS Server with DRBD