Guarding the Docker Socket with docker-proxy
Handing something the Docker socket is the same as handing it root. The Docker Engine API can create a container that mounts your whole host filesystem, run it privileged, and read every secret on the machine. And the socket has no permissions model of its own: if you can reach it, you can do everything. That is the gap docker-proxy fills. It is a fast proxy, written in Rust on Tokio and Hyper, that sits in front of the Engine API and enforces the access control Docker never gave you. Here is the full tour, from install to a real policy. The project lives at github.com/Nemu-Bridge/docker-proxy.
Why put a proxy in front of Docker
The usual reason you expose the Docker API is that something else needs it: a CI runner, a dashboard, a monitoring agent, a container that manages other containers. The moment you do, that thing holds the keys to the entire host, because the API has no notion of "this client may only list containers" or "nobody may run privileged." It is all or nothing.
docker-proxy turns that all-or-nothing into a policy. Requests hit the proxy instead of the raw socket, and the proxy checks them against ordered rules before forwarding anything upstream. It can block endpoints, restrict methods, inspect the JSON body of a request, enforce roles, filter fields out of responses, and rate limit callers. It is a single binary with no runtime dependencies beyond Docker itself, which makes it easy to drop onto a host.
The design goal is a proxy you can trust in front of the most dangerous socket on the box, so the defaults are strict: it binds to loopback, it fails closed when auth is misconfigured, and it strips the headers that would otherwise leak through. You opt into exposure and permissiveness on purpose, never by accident.
Installing and running it
The quickest path is the setup script, which downloads the right binary for your platform, verifies checksums, generates an admin and a readonly token, writes a config to /etc/docker-proxy/config.yaml, and installs a systemd service.
curl -fsSL https://raw.githubusercontent.com/Nemu-Bridge/docker-proxy/main/setup | sudo bashIf piping a script into sudo bash makes you uneasy, and it should, download it first and read it before running. That is exactly the kind of caution this tool is built to reward.
curl -fsSL -o setup https://raw.githubusercontent.com/Nemu-Bridge/docker-proxy/main/setup
less setup
sudo ./setupPrefer to build it yourself? It is one Cargo command, and the binary lands in target/release.
cargo build --releaseRunning it is just as plain. With no config it listens on 127.0.0.1:2376 and auto-detects the Docker socket. You can point it at a config file, and you can validate that config without starting the server.
./docker-proxy
DOCKER_PROXY_CONFIG=/etc/docker-proxy/config.yaml ./docker-proxy
./docker-proxy --check-configOnce it is up, you talk to it exactly like the Docker API, but now with a token. Point the Docker CLI at the proxy's address and it works transparently.
curl -H "Authorization: Bearer <token>" http://127.0.0.1:2376/version
docker -H tcp://127.0.0.1:2376 psThe config file at a glance
Configuration is a single YAML file, found at ./config.yaml or wherever DOCKER_PROXY_CONFIG points. It has three optional top-level sections, and environment variables are interpolated with the ${VARIABLE_NAME} syntax so you never hardcode a token.
global: # ports, binding, logging, TLS, metrics, audit
auth: # how callers prove who they are
rules: # ordered access control policyThe global block holds the operational knobs. Two worth calling out: audit_log turns on an append-only JSON record of every blocked or flagged request, and metrics exposes a Prometheus endpoint.
global:
port: 2376
bind: 127.0.0.1
log_level: info
log_format: json
audit_log: /var/log/docker-proxy/audit.json
metrics:
enabled: true
path: /metricsAuthentication, and failing closed
The auth section decides how callers identify themselves, and its most important behavior is what happens when you get it wrong. If auth is missing or the token list is empty, every request is rejected with a 401. You have to explicitly write type: none to run it open, which means you can never accidentally leave the door unlocked. That is what "fail closed" means, and it is the right default for something this dangerous.
The common mode is bearer tokens, where each token maps to a role. Pull the values from environment variables so they never sit in the file.
auth:
type: bearer
secret: "${ADMIN_TOKEN}"
tokens:
- token: "${READONLY_TOKEN}"
role: readonly
- token: "${USER_TOKEN}"
role: userFor a stronger setup, mutual TLS ties a role to a client certificate. The proxy reads the certificate's common name and maps it to a role, with a fallback for anyone who authenticates but does not match a specific mapping.
auth:
type: mtls
mtls:
cert_role_map:
- cn: "admin.example.com"
role: admin
- cn: "*.readonly.example.com"
role: readonly
default_role: userToken checks use constant-time comparison to avoid timing attacks, and repeated failures are punished: ten failed attempts from one IP within a minute triggers a five minute lockout. You get roles and brute-force protection without configuring either.
Rules are the heart of it
The rules section is where the real policy lives. It is an ordered list, evaluated top to bottom, and the first matching rule with a terminating action wins. Each rule is a set of conditions that must all match, plus an action to take when they do.
A condition names a field, an operator, and usually a value. The fields cover everything you would want to gate on: the request path, the method, the client_ip, any request header.<name>, and, most powerfully, body.<path>, which reaches into the JSON body of the request using dot notation. That last one is what lets you block a container from being created privileged, by inspecting the payload before Docker ever sees it.
The operators are the ones you would expect and a few more: equals and not_equals, contains, starts_with and ends_with, regex via matches, list membership with in and not_in (which is CIDR-aware for IPs), and presence checks with exists. Conditions can also be grouped with or for more expressive policies.
The actions are the verbs. deny blocks with a status and message, allow explicitly permits and stops evaluation, require_role blocks unless the caller holds a role, response_filter rewrites the JSON coming back from Docker, and rate_limit applies a per-IP token bucket. Any rule can also be marked dry_run, which logs what it would have done without actually blocking, so you can test a policy in production before enforcing it.
Rules you will actually write
The abstract stuff clicks the moment you see real rules. Here is the one that made me want this tool: refuse to create a privileged container, decided by looking inside the request body.
- name: "block-privileged"
conditions:
- field: path
operator: equals
value: "/containers/create"
- field: body.HostConfig.Privileged
operator: equals
value: true
action: deny
message: "Privileged containers are not allowed"Making volumes read-only is just as direct: match the volumes path, and deny anything that is not a GET.
- name: "readonly-volumes"
conditions:
- field: path
operator: starts_with
value: "/volumes"
- field: method
operator: not_equals
value: GET
action: deny
message: "Volume mutations are forbidden"Some actions should be admin-only. The require_role action gates container creation behind the admin role, so a readonly or user token gets a clean rejection.
- name: "admin-only-create"
conditions:
- field: path
operator: equals
value: "/containers/create"
action: require_role
role: admin
message: "Admin role required"Response filtering is the feature I did not know I wanted. A container inspect normally returns its full environment, which often contains secrets. This rule redacts those fields out of the response before it reaches the client.
- name: "redact-environment"
conditions:
- field: path
operator: matches
value: "^/containers/[^/]+/json$"
action: response_filter
response_filter:
- field: Config.Env
action: redact
- field: Config.Cmd
action: redactYou can pin access to an internal network with a CIDR-aware list, and rate limit everything with a token bucket.
- name: "internal-network-only"
conditions:
- field: client_ip
operator: not_in
value:
- "10.0.0.0/8"
- "192.168.0.0/16"
- "127.0.0.0/8"
action: deny
message: "Access restricted to internal network"
- name: "rate-limit-all"
conditions:
- field: path
operator: matches
value: "^/"
action: rate_limit
rate_limit:
requests: 50
period: 30
penalty: 30
status: 429
message: "Rate limit exceeded"And when you are not sure a rule is right, ship it as a dry_run first. It logs every request it would have blocked, so you can read the audit log for a day and see what real traffic the rule catches before you turn it on for real.
- name: "watch-image-pulls"
conditions:
- field: path
operator: starts_with
value: /images/create
action: deny
dry_run: true
message: "(dry-run) image pull would be denied"Seeing what happens
A firewall you cannot observe is one you will not trust, so the proxy gives you two lenses. If audit_log is set, every denied, dry-run, or auth-failure event is appended as a JSON line with the who, what, and which rule.
{
"timestamp": "2026-05-12T19:09:18Z",
"event": "deny",
"peer_ip": "10.0.0.5",
"method": "POST",
"path": "/containers/create",
"user_role": "readonly",
"rule_name": "admin-only-create",
"status": 403,
"message": "Admin role required"
}The Prometheus endpoint gives you the aggregate view: counters for total, allowed, denied, dry-run, auth failures, lockouts, and rate limits, plus a histogram of upstream latency. It is the difference between guessing and knowing whether your policy is doing anything.
docker_proxy_requests_total
docker_proxy_requests_denied_total
docker_proxy_auth_failures_total
docker_proxy_rate_limited_total
docker_proxy_upstream_latency_msOperating it
Editing policy does not mean downtime. Validate a config change before you apply it, then reload the running process without dropping connections by sending it a SIGHUP.
docker-proxy --check-config
kill -HUP $(pgrep docker-proxy)On a systemd host the setup script already installed a unit that starts after Docker, restarts on failure, and runs from the standard config path, so the usual systemctl restart docker-proxy and journalctl -u docker-proxy -f are all you need. And if writing YAML by hand is not your idea of fun, there is an interactive setup wizard with more than a dozen built-in rule templates for the common lockdowns.
cargo run --bin docker-proxy-setupUnder the hood there is a lot more you get for free: constant-time token comparison, request sanitization that normalizes path traversal and rejects null bytes, stripping of authorization and hop-by-hop headers, transparent streaming passthrough for docker exec, attach and logs -f, and hard limits on body size, path length, and concurrent connections so a hostile client cannot exhaust the host. It is the boring, careful work that makes a security tool actually secure.
Conclusion
The Docker socket is one of the most powerful and least protected things on a server, and for a long time the only real advice was "never expose it." docker-proxy gives you a better answer: expose it deliberately, behind a policy you wrote, with auth that fails closed, rules that inspect requests down to the JSON body, response filtering that hides secrets, and an audit trail that shows you exactly what happened.
That is the theme of most of the infrastructure I build. The dangerous thing does not have to be off limits, it has to be governed. Put a small, fast, observable gate in front of it, write down what is allowed, and deny the rest. Everything you need to start is in the repo at github.com/Nemu-Bridge/docker-proxy, and the setup script has you guarding the socket in about a minute.
Thanks for reading. Questions or disagreements? Email me.





