Tech Expert & Vibe Coder

With 14+ years of experience, I specialize in self-hosting, AI automation, and Vibe Coding – building applications using AI-powered tools like Google Antigravity, Dyad, and Cline. From homelabs to enterprise solutions.

Implementing Docker Socket Proxy with Traefik to Secure Portainer from Container Breakouts

Why I Worked on This

I run Portainer to manage my Docker containers across several machines. It's convenient, but it bothered me that Portainer requires direct access to the Docker socket. If someone broke out of a container or exploited Portainer itself, they'd have full control over the host. I'd read about socket proxies as a way to limit what Portainer can actually do with that socket, so I decided to implement one and see if it made a real difference.

I already use Traefik for routing and TLS termination on my stack, so adding the proxy felt like a natural extension rather than introducing yet another tool.

My Real Setup

I'm running Docker on a dedicated Linux host (Debian-based). Traefik handles all my incoming traffic, terminates TLS with Let's Encrypt certificates, and routes requests to various services. Portainer sits behind Traefik and was previously mounting /var/run/docker.sock directly.

The goal was to insert a socket proxy between Portainer and the actual Docker socket, so Portainer could only perform specific, whitelisted operations instead of having unrestricted access.

What I Used

  • Traefik v3.4 as the reverse proxy
  • Portainer CE for container management
  • Tecnativa's docker-socket-proxy as the filtering layer
  • Docker Compose to define the entire stack

I chose Tecnativa's proxy because it's lightweight, well-maintained, and lets you control access with simple environment variables. It doesn't require complex configuration files or learning a new syntax.

How I Set It Up

Creating the Socket Proxy Service

First, I added the socket proxy to my docker-compose.yml. This service mounts the real Docker socket and exposes a filtered version over TCP on port 2375 (only accessible within the Docker network).

services:
  socket-proxy:
    image: tecnativa/docker-socket-proxy:latest
    container_name: socket-proxy
    restart: unless-stopped
    networks:
      - proxy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      CONTAINERS: 1
      NETWORKS: 1
      SERVICES: 1
      TASKS: 1
      VOLUMES: 1
      INFO: 1
      IMAGES: 1
      POST: 1
      BUILD: 0
      COMMIT: 0
      CONFIGS: 0
      DISTRIBUTION: 0
      EXEC: 0
      GRPC: 0
      NODES: 0
      PLUGINS: 0
      SECRETS: 0
      SESSION: 0
      SWARM: 0
      SYSTEM: 0
    security_opt:
      - no-new-privileges:true

What These Variables Do

Each environment variable enables or disables a specific Docker API endpoint. Setting something to 1 allows access; 0 blocks it. I enabled only what Portainer actually needs to function:

  • CONTAINERS, NETWORKS, SERVICES, TASKS, VOLUMES, INFO, IMAGES — Read operations Portainer uses to display state
  • POST — Allows creating/starting/stopping containers
  • BUILD, EXEC, COMMIT — Disabled because Portainer doesn't need to build images or execute commands inside containers in my workflow
  • SWARM, NODES, SECRETS — Disabled because I'm not using Swarm mode

This is the key difference. Without the proxy, Portainer could do anything the Docker socket allows. With it, Portainer can only perform the operations I explicitly permit.

Updating Portainer Configuration

Next, I changed Portainer's configuration to point at the socket proxy instead of mounting the real socket:

portainer:
  image: portainer/portainer-ce:latest
  container_name: portainer
  restart: unless-stopped
  networks:
    - proxy
  environment:
    - DOCKER_HOST=tcp://socket-proxy:2375
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.portainer.rule=Host(`portainer.docker.localhost`)"
    - "traefik.http.routers.portainer.entrypoints=websecure"
    - "traefik.http.routers.portainer.tls=true"
    - "traefik.http.services.portainer.loadbalancer.server.port=9000"

I removed the /var/run/docker.sock volume mount entirely. Portainer now connects to the proxy over TCP within the internal Docker network. From Portainer's perspective, nothing changed—it still sees a Docker socket. But that socket is now filtered.

Traefik Configuration

My Traefik setup was already in place, so I didn't need to change much. I just made sure Portainer had proper routing labels and that Traefik was on the same proxy network:

traefik:
  image: traefik:v3.4
  container_name: traefik
  restart: unless-stopped
  security_opt:
    - no-new-privileges:true
  networks:
    - proxy
  ports:
    - "80:80"
    - "443:443"
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock:ro
    - ./certs:/certs:ro
    - ./dynamic:/dynamic:ro
  command:
    - "--entrypoints.web.address=:80"
    - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
    - "--entrypoints.web.http.redirections.entrypoint.scheme=https"
    - "--entrypoints.websecure.address=:443"
    - "--entrypoints.websecure.http.tls=true"
    - "--providers.file.filename=/dynamic/tls.yaml"
    - "--providers.docker=true"
    - "--providers.docker.exposedbydefault=false"
    - "--providers.docker.network=proxy"
    - "--log.level=INFO"
    - "--accesslog=true"

Traefik still mounts the real Docker socket because it needs to discover services dynamically. But Traefik isn't exposed to the internet in a way that could be exploited—it's just reading container labels.

What Worked

After bringing the stack up with docker compose up -d, Portainer connected to the socket proxy without issues. I could still:

  • View running containers
  • Start and stop containers
  • View logs
  • Inspect networks and volumes
  • Pull images

Everything I normally use Portainer for worked exactly as before. The difference is that now, if someone compromised Portainer or broke out of a container it manages, they wouldn't have unrestricted access to the Docker daemon. They couldn't, for example:

  • Execute arbitrary commands inside containers (EXEC=0)
  • Build malicious images (BUILD=0)
  • Access Swarm secrets or configs (disabled)
  • Manipulate the Docker system itself (SYSTEM=0)

The proxy acts as a whitelist. Only the operations I explicitly enabled are allowed.

Testing the Restrictions

I tested this by trying to use docker exec through Portainer's console feature. It failed with a permission error, which is exactly what should happen. The socket proxy blocked the request because EXEC was disabled.

I also checked the logs on the socket proxy container and saw denied requests being logged. This gave me confidence that the filtering was actually working.

What Didn't Work

Initial Permission Issues

The first time I brought up the stack, Portainer couldn't connect to the socket proxy. I got a "permission denied" error. The problem was that I'd set POST=0 initially, thinking Portainer only needed read access. That was wrong—Portainer needs POST to start and stop containers.

After enabling POST=1, everything worked.

Confusion Around What to Enable

The Tecnativa proxy documentation lists all the available variables, but it doesn't clearly explain what each one does in practical terms. I had to experiment and check Portainer's behavior to figure out the minimum set of permissions.

For example, I wasn't sure if TASKS was necessary. It turns out it is—Portainer uses it to display container state. Disabling it caused some UI elements to break.

This trial-and-error process took longer than I expected. I would have preferred a reference that mapped Docker API endpoints to specific Portainer features.

No Built-In Audit Trail

The socket proxy logs blocked requests, but it doesn't provide a detailed audit trail of allowed requests. I can see what was denied, but not what was permitted and by whom. For a production setup, I'd want more granular logging.

I considered adding a separate logging layer, but that felt like overkill for my home lab.

Key Takeaways

  • The socket proxy does what it claims. It filters Docker API requests and blocks anything not explicitly allowed. This is a real security improvement over mounting the socket directly.
  • Setup is straightforward. Once you understand which permissions Portainer needs, the configuration is just a few environment variables.
  • It's not zero-risk. If Portainer is compromised, an attacker could still do damage with the permissions you've granted. The proxy reduces the attack surface but doesn't eliminate it.
  • Testing is essential. You need to verify that the services you care about still work after enabling the proxy. Don't assume the default settings are correct for your use case.
  • Documentation could be better. The socket proxy's docs are minimal. You'll spend time figuring out which variables to enable unless someone has already documented it for your specific tool.

Would I Recommend This?

Yes, if you're already running Portainer or another tool that requires Docker socket access. The socket proxy is a simple, effective way to limit what that tool can do. It won't stop every attack, but it makes container breakouts and privilege escalation harder.

For my setup, it was worth the hour or so it took to configure and test. I sleep a little better knowing that Portainer doesn't have unrestricted access to my Docker daemon.

If you're not using Portainer or a similar management tool, you probably don't need this. But if you are, adding a socket proxy is a low-effort security win.