Skip to content

Connection Modes

How Portwing connects to the Drydock controller — inbound Standard mode and outbound Edge mode for NAT/firewall-constrained hosts.

Portwing supports two connection modes. The active mode is determined at startup from your environment variables — no flag required.

Mode Detection

DRYDOCK_URL set + PRIVATE_KEY_FILE set
    → Edge Mode   (agent dials outbound WebSocket to controller)

Otherwise
    → Standard Mode   (agent runs an HTTP server; controller connects inbound)

Standard Mode

Status: fully implemented and production-ready.

Portwing runs a plain HTTP server on PORT (default 3000). The Drydock controller connects inbound and drives the session: it calls GET /api/containers, GET /api/watchers, and GET /api/triggers during the handshake, then holds a long-lived SSE stream on GET /api/events for real-time container events. All Docker Engine API paths are transparently proxied through the agent.

Standard mode is active whenever DRYDOCK_URL is not set.

Required networking

The agent host must accept inbound TCP connections on PORT. Run Portwing behind a TLS-terminating reverse proxy (nginx, Caddy, Traefik) — do not expose it on plain HTTP to the internet.

Key environment variables

VariableDefaultPurpose
PORT3000Listening port
BIND_ADDRESS0.0.0.0Listening address
TOKENShared secret; controller sends it as X-Dd-Agent-Secret
TOKEN_HASHArgon2id hash of the token (preferred over TOKEN)
TLS_CERT / TLS_KEYPEM files to enable built-in TLS; both must be set together or startup fails
TRUSTED_PROXIESComma-separated CIDRs for real-IP extraction

For Ed25519 key authentication (no shared secret on the wire), see Authentication. For the complete variable reference, see Configuration.

Compose snippet

services:
  portwing:
    image: ghcr.io/codeswhat/portwing:latest
    restart: unless-stopped
    read_only: true
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    # The image runs as non-root UID 65532; grant the Docker socket's group:
    #   export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
    group_add:
      - "${DOCKER_SOCK_GID:?set to the GID of /var/run/docker.sock}"
    ports:
      - "3000:3000"
    environment:
      TOKEN_HASH: "${PORTWING_TOKEN_HASH}"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

See Getting Started for the full Sockguard variant and TLS examples.


Edge Mode

Status: production supported with stable portwing/1.0; full v0.9 feature compatibility requires Drydock v1.6.0-rc.11+.

Edge mode is covered by a real multi-agent reconnect/load soak, including concurrent exec, continuous logs, and controller backpressure. Drydock v1.6.0-rc.11+ implements Portwing v0.9.0's controller-owned watcher/update contract; an older controller may connect at the wire level without that behavior. The endpoint is Ed25519-only: set PRIVATE_KEY_FILE and register the public key with Drydock — token-only (TOKEN / TOKEN_HASH) connections are rejected.

In Edge mode the agent initiates an outbound WebSocket connection to the Drydock controller at DRYDOCK_URL + /api/portwing/ws. No inbound control port needs to be published on the agent host, making it suitable for hosts behind NAT, firewalls, and dynamic IP addresses. A local operations listener still serves health, readiness, metrics, and audit export; keep it private.

Edge mode is active when both of the following are true:

  1. DRYDOCK_URL is set.
  2. PRIVATE_KEY_FILE is set.

Setting DRYDOCK_URL without PRIVATE_KEY_FILE is rejected at startup with a fatal error — the Drydock controller endpoint is Ed25519-only, so the agent enforces PRIVATE_KEY_FILE at startup rather than looping forever on a rejected hello. TOKEN and AUTHORIZED_KEYS are not accepted for edge mode.

Key environment variables

VariableDefaultPurpose
DRYDOCK_URLController base URL, e.g. https://drydock.example.com
PRIVATE_KEY_FILERequired. Ed25519 private key (PEM PKCS#8); the hello message is signed with this key
TOKENNot accepted for edge mode — startup fails if set with DRYDOCK_URL and no PRIVATE_KEY_FILE
AUTHORIZED_KEYSNot accepted for edge mode
AGENT_NAMEhostnameHuman-readable label sent in the hello message
AGENT_IDrandom UUIDStable identity across reconnects (persist this)
RECONNECT_DELAY1Initial reconnect delay in seconds
MAX_RECONNECT_DELAY60Maximum reconnect delay in seconds
HEARTBEAT_INTERVAL30Ping interval in seconds
WELCOME_TIMEOUT30Seconds to wait for the controller welcome message
TLS_SKIP_VERIFYfalseSkip controller TLS verification (testing only)
CA_CERTPath to a PEM CA bundle for self-signed controller certs

Handshake sequence

On each connection attempt the agent:

  1. Opens a WebSocket to DRYDOCK_URL/api/portwing/ws and identifies itself with the portwing/1.0 protocol string carried in its hello message — an application-level identifier, not a negotiated Sec-WebSocket-Protocol sub-protocol header.
  2. Sends a hello message containing its agent ID, name, capabilities, Docker version, and a credential. When PRIVATE_KEY_FILE is configured the hello is Ed25519-signed (see Authentication); otherwise the token SHA-256 hash is included.
  3. Waits up to WELCOME_TIMEOUT seconds for the controller's welcome response.
  4. On success, sends component descriptors (dd:component_sync) before the initial container inventory (dd:container_sync), then sends host metrics.

Reconnection and keepalive

The agent retries automatically on any connection failure using exponential backoff with ±25% jitter:

Attempt 1: wait ~1s
Attempt 2: wait ~2s
Attempt N: wait min(2^N, MAX_RECONNECT_DELAY)s  (+/- 25% jitter)
On success: backoff resets to RECONNECT_DELAY

Not every connection failure feeds this loop, though. A hello rejection is classified before the agent reconnects: terminal codes (ed25519-required, unknown-key, bad-signature, protocol-mismatch, no-auth, invalid-agent-name, parse-error, expected-hello, agent-name-claimed) make the agent log a fatal error and exit instead of retrying forever; timing/capacity codes (timestamp-skew, replay, rate-limited, registry-full, agent-already-connected, …) and any unrecognized code are still retried with backoff as above. Because a terminal rejection ends the process rather than looping, operators should alert on that fatal log line — or on the container restart count under a restart policy — since it usually points to a configuration problem (a stale or unregistered key, a name collision) rather than a transient network blip.

During an established connection the agent sends a ping message every HEARTBEAT_INTERVAL seconds and expects a pong in return. The read deadline is set to max(2 × HEARTBEAT_INTERVAL, 60s). A missing pong triggers an immediate close and reconnect cycle.

An HTTP operations listener remains running on BIND_ADDRESS:PORT throughout, so Docker HEALTHCHECK directives and private monitoring continue to work even when the WebSocket is down. It serves /health, /ready, /_portwing/health, /metrics, and /_portwing/audit/export. The metrics and audit-export routes have no inbound authentication in Edge mode, so Edge mode defaults BIND_ADDRESS to 127.0.0.1. Treat the entire listener as a private-operations trust boundary: set a broader bind explicitly only for a dedicated container/pod monitoring network, and never publish it through an ingress, public service, or host port without an authenticating proxy.

On SIGINT or SIGTERM the agent closes all active exec sessions and sends a WebSocket close frame (code 1000, reason shutdown) before exiting.

Compose snippet

services:
  portwing:
    image: ghcr.io/codeswhat/portwing:latest
    restart: unless-stopped
    read_only: true
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    # The image runs as non-root UID 65532; grant the Docker socket's group:
    #   export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
    group_add:
      - "${DOCKER_SOCK_GID:?set to the GID of /var/run/docker.sock}"
    environment:
      DRYDOCK_URL: "https://drydock.example.com"
      AGENT_NAME: "my-host"
      PRIVATE_KEY_FILE: "/run/secrets/portwing_key"
    secrets:
      - portwing_key
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

secrets:
  portwing_key:
    file: ./portwing_ed25519

Choosing a Mode

Standard ModeEdge Mode
Inbound port requiredYesNo
Works behind NAT / firewallNoYes
Agent-side implementationCompleteComplete
Controller-side supportCompleteDrydock v1.6.0-rc.11+
Full v0.9 feature compatibilityDrydock v1.6.0-rc.11+Drydock v1.6.0-rc.11+
Recommended for productionYesYes (Drydock v1.6.0-rc.11+)

Use Standard mode when the controller can reach the host directly. Use Edge mode when the host cannot accept an inbound connection; both modes are production supported.