Agents
Agent Mode allows running Drydock in a distributed manner across multiple Docker hosts.
The Agent Mode allows running drydock in a distributed manner.
- Agent Node: Runs near the Docker socket (or other container sources). It performs discovery and update checks.
- Controller Node: The central instance. It manages its own local watchers AND connects to remote Agents. It aggregates containers from Agents, and handles persistence, UI, and Notifications.
Architecture
The Controller connects to one or more Agents via HTTP/HTTPS. The Agent pushes real-time updates (container changes, new versions found) to the Controller using Server-Sent Events (SSE).
DD_AGENT_{name}_HOST. The agent just needs its port reachable from the controller. (Both ends share the same secret so they trust each other.)Experimental edge-agent dial-out (Portwing)
When an agent is behind NAT or a firewall and cannot accept inbound controller requests, Drydock can accept an experimental Portwing edge-agent WebSocket instead. Enable the controller endpoint with:
environment:
- DD_EXPERIMENTAL_PORTWING=trueThe WebSocket endpoint is WS /api/v1/portwing/ws (compatibility alias: /api/portwing/ws) and speaks portwing/1.0. Authentication uses Ed25519 public-key challenge-response, not the shared DD_AGENT_SECRET token. Register authorized agent keys through the authenticated API:
| Endpoint | Description |
|---|---|
GET /api/v1/portwing/keys | List active and revoked edge-agent keys |
POST /api/v1/portwing/keys | Register { "pubkeyBase64": "...", "label": "edge-nas" }; the public key must decode to 32 raw Ed25519 bytes |
DELETE /api/v1/portwing/keys/:keyId | Revoke a key and disconnect live sessions authenticated with it |
You can also preload keys at startup with DD_PORTWING_AUTHORIZED_KEYS=/path/to/authorized_keys. The file format is ed25519 <base64-raw-pubkey> [comment], and the file must not be world-readable.
Edge-agent example (Docker Compose)
A Portwing edge agent runs on the remote/NAT'd host and dials out — no inbound ports need to be published on that side:
services:
portwing:
image: ghcr.io/codeswhat/portwing:latest
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DRYDOCK_URL=https://drydock.example.com
- PRIVATE_KEY_FILE=/run/secrets/portwing_key
- AGENT_NAME=edge-host-01
secrets:
- portwing_key
secrets:
portwing_key:
file: ./portwing_ed25519.pemThe Drydock controller enables the endpoint with DD_EXPERIMENTAL_PORTWING=true and needs the agent's public key registered before it connects:
services:
drydock:
image: codeswhat/drydock
ports:
- "3000:3000"
environment:
- DD_EXPERIMENTAL_PORTWING=trueGenerate the agent's keypair with portwing keygen -comment "edge-host-01", then register the printed public key with the controller (POST /api/v1/portwing/keys, or preload it via DD_PORTWING_AUTHORIZED_KEYS) before starting the agent — see the REST API table above.
DD_EXPERIMENTAL_PORTWING=true.timestamps query parameter, the same as regular (HTTP/SSE) agents. The dd:container_log_request wire message carries a timestamps field that a current Portwing agent reads, so the container logs endpoint (and the UI "show timestamps" toggle) works over the edge tunnel. Tail-follow (follow/until) is honored by the agent as a short bounded window, but the one-shot log download endpoint doesn't expose it; continuous live tailing over an edge agent isn't wired up yet. An older Portwing agent that predates the timestamps wire field simply omits timestamps, degrading gracefully.Quickstart
A minimal two-host setup: one agent watching a remote host, one controller aggregating it.
Agent (on the remote host) — run drydock with --agent, give it a secret and a watcher:
services:
drydock-agent:
image: codeswhat/drydock
command: --agent
ports:
- "3000:3000" # must be reachable from the controller
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- DD_AGENT_SECRET=pick-a-long-random-string
- DD_WATCHER_LOCAL_SOCKET=/var/run/docker.sockController — point at the agent's host with the same secret:
services:
drydock:
image: codeswhat/drydock
ports:
- "3000:3000"
environment:
- DD_LOCAL_WATCHER=false # optional: pure aggregator, no local watcher
- DD_AGENT_REMOTE1_HOST=192.168.1.50
- DD_AGENT_REMOTE1_SECRET=pick-a-long-random-string # must match the agent's DD_AGENT_SECRET
- DD_AGENT_ALLOW_INSECURE_SECRET=true # plain-HTTP LAN only; see note belowThat's the whole connection. The {name} segment (REMOTE1) is a label you choose. See Controller Configuration for naming rules. A working connection logs Handshake successful. Received N containers. on the controller. If it doesn't connect, see Troubleshooting. The rest of this page covers the full option set.
This example talks to the agent over plain HTTP, so it sets DD_AGENT_ALLOW_INSECURE_SECRET=true. Without that flag (or TLS) the controller refuses to boot rather than send the secret in cleartext. On anything but a trusted LAN, configure TLS instead (see Controller Environment Variables).
Agent Configuration
To run drydock in Agent mode, start the application with the --agent command line flag.
Agent Environment Variables
| Env var | Required | Description | Default |
|---|---|---|---|
DD_AGENT_SECRET | 🔴 | Secret token for authentication (must match Controller configuration) | |
DD_AGENT_SECRET_FILE | ⚪ | Path to file containing the secret token | |
DD_SERVER_PORT | ⚪ | Port to listen on | 3000 |
DD_SERVER_TLS_* | ⚪ | Standard Server TLS options | |
DD_WATCHER_{name}_* | 🔴 | Watcher configuration (At least one is required) | |
DD_ACTION_DOCKER_{name}_* | ⚪ | Docker trigger for container updates (required to update containers from UI/API) | |
DD_REGISTRY_{name}_* | ⚪ | Registry configuration (For update checks) |
DD_WATCHER_{name}_{option} (no provider segment — Docker is the only watcher type). Triggers are DD_ACTION_DOCKER_{name}_{option} (provider segment required). A common mistake is writing DD_ACTION_DOCKER_PRUNE=true (missing the name) — this creates a broken trigger named "prune" instead of a trigger with PRUNE=true. Use DD_ACTION_DOCKER_{name}_PRUNE=true instead.Agent Example (Docker Compose)
services:
drydock-socket-proxy:
image: lscr.io/linuxserver/socket-proxy:latest
container_name: drydock-socket-proxy
restart: unless-stopped
labels:
- dd.watch=true
- dd.update.mode=infrastructure
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- CONTAINERS=1
- IMAGES=1
- EVENTS=1
- INFO=1
- POST=1
- DELETE=1
- NETWORKS=1
- VOLUMES=1
healthcheck:
test: wget --spider http://localhost:2375/version || exit 1
interval: 5s
timeout: 3s
retries: 3
start_period: 5s
drydock-agent:
image: codeswhat/drydock
command: --agent
depends_on:
drydock-socket-proxy:
condition: service_healthy
ports:
- "3000:3000"
environment:
# Shared secret (must match controller's DD_AGENT_{name}_SECRET)
- DD_AGENT_SECRET=mysecretkey
# Watcher — discovers containers on this host
- DD_WATCHER_LOCAL_HOST=drydock-socket-proxy
- DD_WATCHER_LOCAL_PORT=2375
# Docker trigger — allows updating containers from UI/API
- DD_ACTION_DOCKER_LOCAL_PRUNE=trueController Configuration
To connect a Controller to an Agent, use the DD_AGENT_{name}_* environment variables. The {name} segment is a label you choose to identify the agent (e.g. REMOTE1, NAS, WORKER2). It's stored lowercased by the config parser, so DD_AGENT_REMOTE1_* shows up as remote1 in logs and the UI.
Controller Environment Variables
| Env var | Required | Description | Default |
|---|---|---|---|
DD_AGENT_{name}_SECRET | 🔴 | Secret token to authenticate with the Agent. Required in the default token auth mode; not used when AUTHMODE=ed25519. | |
DD_AGENT_{name}_SECRET__FILE | ⚪ | Path to file containing the secret token. Note the double underscore, which follows the general DD_*__FILE secret-file convention. | |
DD_AGENT_{name}_HOST | 🔴 | Hostname or IP of the Agent | |
DD_AGENT_{name}_PORT | ⚪ | Port of the Agent | 3000 |
DD_AGENT_{name}_AUTHMODE | ⚪ | How the controller authenticates each request to this agent: token (sends the shared X-Dd-Agent-Secret) or ed25519 (signs each request with an Ed25519 key using Portwing's per-request signature scheme). See Ed25519 request signing. | token |
DD_AGENT_{name}_SIGNINGKEYID | 🔴¹ | Required when AUTHMODE=ed25519. The key id sent as the X-Portwing-Key-ID header — the 16-hex-char id Portwing derived from the public key at registration (hex(SHA-256(pubkey)[:8])). | |
DD_AGENT_{name}_SIGNINGKEY | 🔴¹ | Required when AUTHMODE=ed25519. PEM-encoded PKCS#8 Ed25519 private key (the format Portwing's keygen produces). | |
DD_AGENT_{name}_SIGNINGKEY__FILE | ⚪ | Path to a file containing the PEM Ed25519 private key, instead of inlining it. Note the double underscore, per the secret-file convention. | |
DD_AGENT_{name}_CAFILE | ⚪ | CA certificate path for TLS connection | |
DD_AGENT_{name}_CERTFILE | ⚪ | Client certificate path for TLS connection | |
DD_AGENT_{name}_KEYFILE | ⚪ | Client key path for TLS connection | |
DD_AGENT_ALLOW_INSECURE_SECRET | ⚪ | Insecure. When true, allows configuring agents with a secret over plain HTTP. Drydock will log a warning on every startup but won't refuse to boot. Only enable on trusted private networks where you accept that the agent secret is sent in cleartext. | false |
¹ Required only when AUTHMODE=ed25519; ignored otherwise.
If an agent's required config is missing, the controller logs a Skipping agent {name}: … warning and starts without that agent rather than failing to boot — Missing host when the host is absent, Missing secret in the default token mode, or Missing signingkeyid or signingkey for ed25519 authmode in ed25519 mode. So a typo'd agent name shows up as a missing agent in the UI, not a startup crash. Check the controller log for Skipping agent warnings if an agent never appears.
DD_AGENT_ALLOW_INSECURE_SECRET=true means your agent secret is transmitted over the network in cleartext on every request. An attacker with network access can capture it and impersonate the controller. Prefer HTTPS (configure DD_AGENT_{name}_CERTFILE/DD_AGENT_{name}_CAFILE) or terminate TLS at a reverse proxy. Only use this flag on isolated private networks where you explicitly accept that risk.Without this flag set, a secret configured over plain HTTP is a hard startup error — the controller refuses to boot. You'll see:
Agent remote1 is configured with a secret over insecure HTTP (http://192.168.1.50:3000). Configure HTTPS (certfile/cafile) to protect X-Dd-Agent-Secret.Fix it by configuring TLS (DD_AGENT_{name}_CAFILE/CERTFILE), terminating TLS at a reverse proxy, or — on a trusted LAN only — setting DD_AGENT_ALLOW_INSECURE_SECRET=true (which demotes the error to a one-time warning).
Controller Example (Docker Compose)
services:
drydock-controller:
image: codeswhat/drydock
environment:
- DD_AGENT_REMOTE1_HOST=192.168.1.50
- DD_AGENT_REMOTE1_SECRET=mysecretkey
- DD_AGENT_ALLOW_INSECURE_SECRET=true # trusted LAN only, use TLS in production
ports:
- 3000:3000DD_LOCAL_WATCHER=false to disable the default local watcher.Ed25519 request signing (standard-mode)
By default the controller authenticates to each agent with a shared secret token (the X-Dd-Agent-Secret header). Setting DD_AGENT_{name}_AUTHMODE=ed25519 switches that agent to per-request Ed25519 signing instead: every request the controller sends carries four X-Portwing-* headers — X-Portwing-Key-ID, X-Portwing-Timestamp (unix seconds), X-Portwing-Nonce, and a base64url X-Portwing-Signature over the canonical METHOD\nPATH\nSHA-256-hex(body)\ntimestamp\nnonce message — matching Portwing's verifier. No X-Dd-Agent-Secret is sent.
Use it when the remote agent verifies requests with a registered Ed25519 public key (Portwing's scheme) rather than a shared secret. Unlike a static token, a captured signature can't be replayed and there's no long-lived shared secret on the wire.
services:
drydock-controller:
image: codeswhat/drydock
environment:
- DD_AGENT_EDGE_HOST=edge.example.com
- DD_AGENT_EDGE_AUTHMODE=ed25519
- DD_AGENT_EDGE_SIGNINGKEYID=3f8a1c2e9b047d56
- DD_AGENT_EDGE_SIGNINGKEY__FILE=/run/secrets/agent_ed25519_key
secrets:
- agent_ed25519_key
secrets:
agent_ed25519_key:
file: ./agent_ed25519.pemThe signing key is parsed and validated once at startup — a missing or malformed key skips that agent with a warning rather than crashing the controller — and is masked in configuration dumps the same way the secret token is.
token mode. An ed25519 agent transmits no secret, so it will connect over plain HTTP without DD_AGENT_ALLOW_INSECURE_SECRET — though HTTPS is still recommended to protect the request and response payloads themselves.Complete Multi-Host Example
This example shows a controller watching its own containers and connecting to one remote agent. The controller handles notifications (Slack, SMTP, etc.); each agent handles its own container updates.
Controller
services:
drydock:
image: codeswhat/drydock
ports:
- "3000:3000"
depends_on:
drydock-socket-proxy:
condition: service_healthy
volumes:
- /path/to/store:/store
environment:
# --- Local watcher (controller's own containers) ---
- DD_WATCHER_LOCAL_HOST=drydock-socket-proxy
- DD_WATCHER_LOCAL_PORT=2375
- DD_WATCHER_LOCAL_WATCHBYDEFAULT=true
# --- Local docker trigger (update controller's own containers) ---
- DD_ACTION_DOCKER_LOCAL_PRUNE=true
# --- Remote agent connection ---
- DD_AGENT_REMOTE1_HOST=192.168.1.50
- DD_AGENT_REMOTE1_PORT=3000
- DD_AGENT_REMOTE1_SECRET=mysecretkey
- DD_AGENT_ALLOW_INSECURE_SECRET=true # trusted LAN only, use TLS in production
# --- Notifications (run on controller only) ---
- DD_NOTIFICATION_SMTP_ALERTS_HOST=smtp.example.com
- DD_NOTIFICATION_SMTP_ALERTS_PORT=587
- DD_NOTIFICATION_SMTP_ALERTS_USER=user@example.com
- DD_NOTIFICATION_SMTP_ALERTS_PASS=password
- DD_NOTIFICATION_SMTP_ALERTS_FROM=drydock@example.com
- DD_NOTIFICATION_SMTP_ALERTS_TO=admin@example.com
drydock-socket-proxy:
image: lscr.io/linuxserver/socket-proxy:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- CONTAINERS=1
- IMAGES=1
- EVENTS=1
- INFO=1
- POST=1
- DELETE=1
- NETWORKS=1
- VOLUMES=1
healthcheck:
test: wget --spider http://localhost:2375/version || exit 1
interval: 5s
timeout: 3s
retries: 3
start_period: 5sAgent (on 192.168.1.50)
services:
drydock-socket-proxy:
image: lscr.io/linuxserver/socket-proxy:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- CONTAINERS=1
- IMAGES=1
- EVENTS=1
- INFO=1
- POST=1
- DELETE=1
- NETWORKS=1
- VOLUMES=1
healthcheck:
test: wget --spider http://localhost:2375/version || exit 1
interval: 5s
timeout: 3s
retries: 3
start_period: 5s
drydock-agent:
image: codeswhat/drydock
command: --agent
depends_on:
drydock-socket-proxy:
condition: service_healthy
ports:
- "3000:3000"
environment:
# Must match controller's DD_AGENT_REMOTE1_SECRET
- DD_AGENT_SECRET=mysecretkey
# Watcher — discovers containers on this host
- DD_WATCHER_LOCAL_HOST=drydock-socket-proxy
- DD_WATCHER_LOCAL_PORT=2375
# Docker trigger — required to update containers via UI/API
- DD_ACTION_DOCKER_LOCAL_PRUNE=truedocker and dockercompose triggers are supported in agent mode. Notification triggers must be configured on the controller.DD_ACTION_DOCKER_* on the controller for each agent. During the handshake, the controller automatically discovers the agent's triggers and creates internal proxies. The controller only needs its own DD_ACTION_DOCKER_* for updating containers on its local host.Reliability notes
Transient enumeration failures
When the Docker watcher on an agent fails to enumerate containers (e.g. a brief socket-proxy hiccup or Docker daemon restart), the agent suppresses the snapshot emission rather than broadcasting an empty container list. The controller preserves the last-known container state instead of wiping it. The next successful cron cycle restores the accurate state.
Container list payload
When the controller proxies an update to an agent, it posts to the agent-local POST /api/triggers/{type}/{name} route (or …/batch for batch updates). Agent-local routes are intentionally unversioned and authenticated with X-Dd-Agent-Secret; controller API clients should continue to use the canonical /api/v1/* routes. For single-update calls on docker and dockercompose triggers the controller sends only { id, name } (plus an operationId when one is in flight) rather than the full container object, which prevents HTTP 413 errors where full payloads exceeded the agent's 256 KB body cap. Batch update calls (…/batch) still send the full container per item, so a very large fleet updated in one batch can still approach that cap.
Features in Agent Mode
- Watchers: Run on the Agent to discover containers.
- Registries: Configured on the Agent to check for updates.
- Triggers:
dockeranddockercomposetriggers are configured and executed on the Agent (allowing update of remote containers). The controller automatically proxies update requests to the correct agent.- Notification triggers (e.g.
smtp,discord) are configured and executed on the Controller. Notifications automatically include a[server-name]prefix identifying which server each update comes from. The controller name defaults to the detected Docker or Podman daemon host name when available, then the process hostname, and can be overridden withDD_SERVER_NAME.
Troubleshooting
Agent connections are driven entirely from the controller, so the controller log (docker logs <controller>) is where you diagnose them. The controller retries with exponential backoff (up to 60s between attempts), so a failing connection repeats in the log until the agent is healthy.
| What you see on the controller | What it means | Fix |
|---|---|---|
Connecting to agent remote1 at http://…:3000 then Handshake successful. Received N containers. | Connected and working | — |
Startup Error: Agent remote1 is configured with a secret over insecure HTTP … | A secret is set but the agent is plain HTTP | Configure TLS, or set DD_AGENT_ALLOW_INSECURE_SECRET=true on a trusted LAN |
SSE Connection failed: Request failed with status code 401. Retrying... | The secrets don't match | Make DD_AGENT_{name}_SECRET (controller) equal DD_AGENT_SECRET (agent) |
SSE Connection failed: connect ECONNREFUSED 192.168.1.50:3000. Retrying... | Agent unreachable — not running, crashed at boot, or its port isn't published/reachable | Check docker logs <agent> (see below), confirm its ports: are published, and check firewalls |
SSE Connection failed: connect ETIMEDOUT 192.168.1.50:3000. Retrying... | A firewall or NAT is blocking the path | Open the path from controller to agent, or check NAT |
Repeated 401s against an agent that looks like it's running | The agent container was started without command: --agent, so it's running as a controller and never validated the secret | Add command: --agent to the agent service |
ECONNREFUSED only tells you the agent isn't answering, not why, so check docker logs <agent>. The agent throws and exits at boot in two cases: neither DD_AGENT_SECRET nor DD_AGENT_SECRET_FILE is set, or no watcher is configured (it logs Agent mode requires at least one watcher configured.). Add the missing secret or a DD_WATCHER_* (e.g. DD_WATCHER_LOCAL_SOCKET=/var/run/docker.sock) and restart.
The agent also exposes an unauthenticated GET /health on its port: it returns 200 once at least one watcher is registered, or 503 with {"status":"unhealthy","reason":"no watchers registered"} otherwise. Use it for a compose healthcheck or an uptime probe.
Still stuck? Set DD_LOG_LEVEL=debug on the controller for the full agent-client handshake trace, and open a Q&A discussion with those lines.