Skip to content
ConfigurationAgents

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).

The controller dials out to each agent — the agent does not need to know where the controller is. Only the controller side gets 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=true

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

EndpointDescription
GET /api/v1/portwing/keysList active and revoked edge-agent keys
POST /api/v1/portwing/keysRegister { "pubkeyBase64": "...", "label": "edge-nas" }; the public key must decode to 32 raw Ed25519 bytes
DELETE /api/v1/portwing/keys/:keyIdRevoke 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.pem

The 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=true

Generate 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.

Portwing support is experimental. Its REST API and wire protocol may change before it is promoted out of DD_EXPERIMENTAL_PORTWING=true.
Container log downloads from an edge (Portwing) agent now honor the 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.sock

Controller — 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 below

That'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 varRequiredDescriptionDefault
DD_AGENT_SECRET🔴Secret token for authentication (must match Controller configuration)
DD_AGENT_SECRET_FILEPath to file containing the secret token
DD_SERVER_PORTPort to listen on3000
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)
Watcher and trigger env vars use different naming patterns. Watchers are 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)

This example uses a socket proxy for secure Docker socket access. Agents also support direct socket mount and remote TLS connections.
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=true

Controller 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 varRequiredDescriptionDefault
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__FILEPath 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}_PORTPort of the Agent3000
DD_AGENT_{name}_AUTHMODEHow 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__FILEPath 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}_CAFILECA certificate path for TLS connection
DD_AGENT_{name}_CERTFILEClient certificate path for TLS connection
DD_AGENT_{name}_KEYFILEClient key path for TLS connection
DD_AGENT_ALLOW_INSECURE_SECRETInsecure. 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.

Setting 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:3000
By default, the controller also watches its own local Docker socket. If you're running the controller as a pure aggregator with no local containers to monitor, set DD_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.pem

The 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.

The insecure-secret hard error above only applies to 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: 5s

Agent (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=true
Do not configure notification triggers (Slack, SMTP, etc.) on agents. Only docker and dockercompose triggers are supported in agent mode. Notification triggers must be configured on the controller.
You do not need to define 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:
    • docker and dockercompose triggers 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 with DD_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 controllerWhat it meansFix
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 HTTPConfigure 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 matchMake 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/reachableCheck 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 pathOpen the path from controller to agent, or check NAT
Repeated 401s against an agent that looks like it's runningThe agent container was started without command: --agent, so it's running as a controller and never validated the secretAdd 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.