Getting Started
Deploy Portwing as a hardened remote Docker agent using the recommended sockguard two-layer configuration, or a simpler standard-mode setup.
Portwing is a security-first remote Docker agent. The Drydock controller connects inbound to each Portwing agent over HTTP (it initiates; Portwing serves). All compose files ship with hardened defaults — read_only, cap_drop: ALL, no-new-privileges, and Docker secrets instead of inline tokens.
Recommended deployment (hardened with sockguard)
The strongest posture combines three controls:
- Sockguard — a socket-level request filter so Portwing never touches the raw Docker socket directly. Even a fully compromised agent is constrained to the explicit API allowlist in
sockguard.yaml. - Secrets-mounted token — the token is read from a Docker secret at
/run/secrets/portwing_token, never exposed in environment variables ordocker inspectoutput. - Hardened container runtime — both containers run
read_only, drop all capabilities, and setno-new-privileges.
For the strongest auth posture, upgrade to Ed25519 per-request keys after the initial setup — see Authentication.
Step 1 — generate a token and download the example files
openssl rand -hex 32 > portwing_token.txt
sudo chown 65532:65532 portwing_token.txt && sudo chmod 0400 portwing_token.txt
export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
curl -fsSLO https://raw.githubusercontent.com/CodesWhat/portwing/main/examples/docker-compose.with-sockguard.yml
curl -fsSLO https://raw.githubusercontent.com/CodesWhat/portwing/main/examples/sockguard.yamlStep 2 — start the hardened stack
docker compose -f docker-compose.with-sockguard.yml up -dThis starts sockguard and Portwing as two separate containers sharing a filtered socket via a named volume. Sockguard writes a filtered Unix socket into that volume; Portwing talks to that filtered socket via DOCKER_SOCKET rather than mounting /var/run/docker.sock directly.
# Portwing + sockguard — two-layer defense.
#
# Sockguard sits between Portwing and the host's Docker socket and writes a
# filtered unix socket into a shared named volume. Portwing talks to that
# filtered socket instead of mounting /var/run/docker.sock directly, so even
# a fully compromised agent is constrained to the explicit API allowlist in
# sockguard.yaml.
#
# The bundled sockguard.yaml mirrors sockguard's drydock preset: container
# lifecycle, image pull/inspect/remove, events, and narrow network/volume
# reads. Compose stack deploys through Portwing need broader rules (network
# create, volume create, build) — extend sockguard.yaml deliberately if you
# use stacks, rather than bypassing the filter.
#
# Generate a token first and make it readable by the container user:
# openssl rand -hex 32 > portwing_token.txt
# sudo chown 65532:65532 portwing_token.txt && sudo chmod 0400 portwing_token.txt
#
# Both images run as UID 65532. Sockguard needs the numeric group ID of the
# host Docker socket to open it:
# export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
# Portwing itself needs no group_add here — it talks only to sockguard's
# filtered socket, which sockguard creates 0600 under the same UID.
services:
sockguard:
image: ghcr.io/codeswhat/sockguard:latest
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
group_add:
- "${DOCKER_SOCK_GID:?set to the GID of /var/run/docker.sock (see header)}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./sockguard.yaml:/etc/sockguard/sockguard.yaml:ro
- sockguard-socket:/var/run/sockguard
environment:
- SOCKGUARD_LISTEN_SOCKET=/var/run/sockguard/sockguard.sock
portwing:
image: ghcr.io/codeswhat/portwing:latest
restart: unless-stopped
depends_on:
- sockguard
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
user: "65532:65532" # image default; explicit so it survives image overrides
ports:
- "3000:3000"
volumes:
- sockguard-socket:/var/run/sockguard:ro
- portwing-stacks:/data/stacks
environment:
- DOCKER_SOCKET=/var/run/sockguard/sockguard.sock
- TOKEN_FILE=/run/secrets/portwing_token
secrets:
- portwing_token
secrets:
portwing_token:
file: ./portwing_token.txt
volumes:
sockguard-socket:
portwing-stacks:If you manage Docker Compose stacks through Portwing, the bundled sockguard.yaml uses the drydock preset (container lifecycle, image ops, events, narrow network/volume reads). Compose deploys require additional rules — extend sockguard.yaml deliberately (network create, volume create, build) rather than bypassing the filter.
Standard mode (no sockguard)
If you want a simpler setup — Drydock or another client connects inbound to port 3000 and Portwing talks to the raw Docker socket directly — use the standard compose file. It keeps all the runtime hardening but drops the sockguard layer.
openssl rand -hex 32 > portwing_token.txt
sudo chown 65532:65532 portwing_token.txt && sudo chmod 0400 portwing_token.txt
export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
curl -fsSLO https://raw.githubusercontent.com/CodesWhat/portwing/main/examples/docker-compose.standard.yml
docker compose -f docker-compose.standard.yml up -d# Portwing — standard mode (inbound HTTP on :3000), hardened defaults.
#
# The image runs as the non-root `portwing` user (UID 65532). Before starting,
# generate a token and make it readable by that user, and export the numeric
# group ID of your Docker socket so the agent can reach it:
# openssl rand -hex 32 > portwing_token.txt
# sudo chown 65532:65532 portwing_token.txt && sudo chmod 0400 portwing_token.txt
# export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
#
# The image ships a built-in HEALTHCHECK against /_portwing/health.
# See SECURITY.md ("container runtime identity") for why group_add is needed.
services:
portwing:
image: ghcr.io/codeswhat/portwing:latest
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
user: "65532:65532" # image default; explicit so it survives image overrides
group_add:
- "${DOCKER_SOCK_GID:?set to the GID of /var/run/docker.sock (see header)}"
ports:
- "3000:3000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- portwing-stacks:/data/stacks
environment:
- TOKEN_FILE=/run/secrets/portwing_token
# Stronger at-rest posture: replace TOKEN_FILE with an Argon2id hash
# (no plaintext credential anywhere on the agent host):
# portwing hash-token → TOKEN_HASH=$argon2id$...
secrets:
- portwing_token
secrets:
portwing_token:
file: ./portwing_token.txt
volumes:
portwing-stacks:Edge mode
Edge mode lets Portwing dial out to your Drydock controller over a WebSocket tunnel instead of listening for inbound connections. This is useful for hosts behind NAT or a firewall where you cannot publish an inbound port.
Production supported; use Drydock v1.6.0-rc.11+ with Portwing v0.9.0. The stable portwing/1.0 path is covered by a real multi-agent reconnect/load soak with concurrent exec, continuous logs, and controller backpressure. Drydock v1.6.0-rc.11+ implements the complete controller-owned watcher/update contract; older controllers may remain wire-compatible without that behavior. The endpoint is Ed25519-only: set PRIVATE_KEY_FILE and register the public key with Drydock; token-only connections are rejected.
portwing keygen -comment "edge-host-01" > portwing_ed25519.pem
sudo chown 65532:65532 portwing_ed25519.pem && sudo chmod 0400 portwing_ed25519.pem
export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
# Register the printed public key with your Drydock controller, then:
curl -fsSLO https://raw.githubusercontent.com/CodesWhat/portwing/main/examples/docker-compose.edge.yml
# Edit DRYDOCK_URL and AGENT_NAME, then:
docker compose -f docker-compose.edge.yml up -d# Portwing — edge mode: outbound WebSocket tunnel to Drydock.
# No inbound ports are published; the agent dials out, so it works
# behind NAT and firewalls. The built-in HEALTHCHECK still works
# (the health endpoint listens container-locally in edge mode too).
#
# Edge mode against Drydock v1.6.0-rc.11+ is Ed25519-only: the controller's
# /api/portwing/ws endpoint rejects token-based hellos. Generate a
# keypair and register its public key with Drydock before starting:
#
# portwing keygen -comment "edge-host-01" > portwing_ed25519.pem
# sudo chown 65532:65532 portwing_ed25519.pem && sudo chmod 0400 portwing_ed25519.pem
#
# keygen prints the PKCS#8 private key (used below as PRIVATE_KEY_FILE)
# plus an authorized_keys line. Register that public key with your
# Drydock controller (POST /api/v1/portwing/keys) so it trusts this agent.
#
# The chown/chmod matters twice over: the agent refuses to load a
# world-readable key file, and the non-root container user (UID 65532) must
# be able to read the bind-mounted secret. Also export the numeric group ID
# of your Docker socket so the agent can reach it:
# export DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
services:
portwing:
image: ghcr.io/codeswhat/portwing:latest
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
user: "65532:65532" # image default; explicit so it survives image overrides
group_add:
- "${DOCKER_SOCK_GID:?set to the GID of /var/run/docker.sock (see header)}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- portwing-stacks:/data/stacks
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
volumes:
portwing-stacks:Set DRYDOCK_URL to your Drydock instance, AGENT_NAME to a unique identifier for the host, and PRIVATE_KEY_FILE to the agent's Ed25519 key (register its public key with Drydock). No control port needs to be published on the agent side. Use Drydock v1.6.0-rc.11+ for the complete v0.9 watcher/update feature path. See Connection Modes for a full comparison of standard vs. edge mode and the private operations-listener boundary.
Evaluation only (not for production)
If you just want to try Portwing locally without setting up compose files, you can run it with a single docker run. Tokens passed via environment variable are visible in docker inspect and process listings.
Do not use this in production. The TOKEN environment variable is visible to anyone with access to docker inspect or the host process list. Use the hardened deployment above for any real workload.
docker run -d \
--name portwing \
--group-add $(stat -c '%g' /var/run/docker.sock) \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
-e TOKEN=$(openssl rand -hex 24) \
ghcr.io/codeswhat/portwing:latestWithout TOKEN, TOKEN_HASH, or AUTHORIZED_KEYS, Standard mode refuses to
start. Local unauthenticated evaluation requires ALLOW_UNAUTHENTICATED=true
on a loopback bind; non-loopback exposure additionally requires the separate
ALLOW_UNAUTHENTICATED_REMOTE=true acknowledgement.
The image runs as the non-root portwing user (UID 65532); --group-add grants it the Docker socket's group so it can reach the daemon.
Next steps
- Stronger auth — upgrade from a shared token to Ed25519 per-request signatures (zero shared secrets, per-client identity, replay protection). Generate a keypair with
portwing keygenand mount anauthorized_keysfile. See Authentication. - Connection modes — understand the difference between standard (inbound) and edge (outbound) modes, and when to use each. See Connection Modes.
- All environment variables —
DOCKER_SOCKET,TOKEN_HASH,AUTHORIZED_KEYS,AUDIT_LOG,ADAPTER, and more. See Configuration.