Architecture¶
This page explains how DockSight is put together and — more importantly — why. Every design decision here has a cost and a reason; both are stated.
System overview¶
graph TB
subgraph client["Operator"]
BROWSER[Browser]
end
subgraph platform["Platform — one central server"]
NGINX[nginx reverse proxy :2002]
WEB[web — React dashboard]
SERVER[server — NestJS API + WS gateway]
PG[(PostgreSQL)]
REDIS[(Redis)]
NGINX --> WEB
NGINX --> SERVER
SERVER --> PG
SERVER --> REDIS
end
subgraph hostA["Docker host — data centre"]
AGENTA[docksight-agent]
DOCKERA[Docker Engine]
AGENTA -->|/var/run/docker.sock| DOCKERA
end
subgraph hostB["Docker host — behind NAT"]
AGENTB[docksight-agent]
DOCKERB[Docker Engine]
AGENTB -->|/var/run/docker.sock| DOCKERB
end
BROWSER -->|HTTPS| NGINX
AGENTA -.->|outbound WSS| NGINX
AGENTB -.->|outbound WSS| NGINX
Only two kinds of connection exist: a browser talking to nginx, and agents dialling nginx. Nothing ever connects to a Docker host.
Components¶
Platform¶
| Service | Image | Role |
|---|---|---|
nginx |
nginx:1.25-alpine |
The only published port. Routes / to the dashboard, /api and /agents to the backend. |
web |
ghcr.io/rodriguecyber/docksight-web |
React + TypeScript dashboard. |
server |
ghcr.io/rodriguecyber/docksight-server |
NestJS modular monolith: REST API, WebSocket gateway, Prisma. |
postgres |
postgres:17-alpine |
Source of truth: hosts, agents, users, audit. |
redis |
redis:7-alpine |
Cache and pub/sub for realtime fan-out. |
The server is a modular monolith, not microservices: domain modules
(agents, containers, hosts, logs, metrics, auth, audit) live in one
deployable. Module boundaries are enforced in code so a service can be extracted
later if it earns it — but a young project pays the operational cost of
microservices long before it collects the benefit.
Agent¶
A single static Go binary with no runtime dependencies. Internally:
| Package | Responsibility |
|---|---|
communication |
WebSocket client, registration, heartbeat, reconnect, message dispatch |
docker |
Docker Engine SDK access over the local socket or named pipe |
identity |
Durable UUID persisted to disk |
config |
YAML configuration loading with defaults |
logs |
Container log streaming |
lifecycle |
Startup and graceful shutdown |
CLI¶
The docksight binary installs and updates everything. It is a separate release
artifact from both the platform bundle and the agent — see
Release process.
Why WebSocket?¶
The platform needs to push commands to hosts and receive a stream of logs back. The candidates:
| Approach | Why it was rejected |
|---|---|
| Agent polls REST | Latency is bounded by the poll interval. Fast commands mean aggressive polling; idle fleets waste requests. Log streaming becomes an awkward chunked-transfer hack. |
| Platform calls the agent's HTTP API | Requires an inbound port on every Docker host — the exact problem DockSight exists to avoid. |
| gRPC streaming | Technically a good fit, but adds a proto toolchain to a Go agent and a TypeScript backend, and needs extra plumbing through proxies that only speak HTTP/1.1. |
| WebSocket | One long-lived, bidirectional, ordered connection. Native in Node and Go. Survives ordinary HTTP proxies. Upgrades from ws:// to wss:// with no application change. |
The cost is that WebSocket gives you a byte pipe and nothing else — no request
correlation, no retries, no schema. DockSight adds those back in a thin
message envelope: every message is
{ "type": "domain.action", "payload": {...} }, and commands carry a
requestId their responses echo.
Why agents dial out¶
This is the single most consequential decision in DockSight.
graph LR
subgraph rejected["Rejected — platform connects in"]
P1[Platform] -->|needs open port| H1[Host firewall]
H1 --> A1[Agent]
end
subgraph chosen["Chosen — agent connects out"]
A2[Agent] -->|standard egress| H2[Host firewall]
H2 --> P2[Platform]
end
What outbound-only buys you:
- Works behind NAT. Home labs, office networks, private subnets and most cloud defaults permit outbound 443 while blocking inbound entirely.
- No inbound attack surface on the host. A machine with the Docker socket on it is the most valuable target in the deployment; it exposes nothing.
- No per-host firewall work. Adding a host is installing a binary, not filing a network change.
- Failure is contained. A compromised platform can send messages an agent chooses to honour. It cannot reach the host's Docker socket directly.
The costs, stated honestly:
- The platform must be reachable from every host. It is a single point of failure for control (though not for the containers themselves, which keep running when the platform is down).
- The platform cannot initiate contact with an offline agent — it waits for the agent to reconnect.
- Because the agent chooses the platform, an agent's trust in the platform is established by configuration rather than by a certificate check today. See Security.
Why the backend sits behind nginx¶
server and web publish no ports of their own. Only nginx binds a host
port (2002 by default). That yields:
- One TLS termination point. Certificates are configured once, not per service.
- One origin for the browser. Dashboard, REST API and WebSocket endpoint
share a hostname, so there is no CORS configuration and no mixed
ws:///https://combination to get wrong. - A stable public path layout (
/,/api,/agents) decoupled from internal service names and ports. - The database is unreachable from outside.
postgresandredissit only on the internaldocksight-networkbridge. Nothing published, nothing to scan.
graph LR
I[Internet] -->|:2002 only| N[nginx]
N -->|/| W[web]
N -->|/api| S[server]
N -->|/agents| S
S --> PG[(postgres)]
S --> R[(redis)]
Ports
Only ${DOCKSIGHT_PORT:-2002} is published to the host. Change it in
/opt/docksight/.env and restart the stack.
Registration flow¶
When an agent starts — or reconnects after any interruption — it registers before doing anything else.
sequenceDiagram
autonumber
participant A as Agent
participant P as Platform
participant DB as PostgreSQL
A->>A: Load or create identity.json (UUID)
A->>P: WebSocket connect to /agents
P-->>A: 101 Switching Protocols
A->>P: agent.register { uuid, hostname, os, architecture, version }
P->>DB: Upsert agent by uuid, status = ONLINE
P-->>A: agent.registered { id, uuid, status, message }
P->>A: container.list
A-->>P: container.listed { containers }
loop every 30s
A->>P: agent.heartbeat { uuid }
P->>DB: update lastSeen, status = ONLINE
end
The UUID is generated by the agent, not the platform, and persisted at
/etc/docksight-agent/identity.json. That single choice is what makes the
system idempotent: reinstall the agent, upgrade it, reboot the host, and the
platform still sees the same host with its existing history. It is also why the
installer never overwrites that file — see Agent.
Command flow¶
sequenceDiagram
autonumber
participant U as Dashboard
participant S as Platform
participant A as Agent
participant D as Docker
U->>S: REST call (e.g. restart container)
S->>S: generate requestId
S->>A: container.restart { requestId, containerId }
A->>D: Docker Engine API
alt success
D-->>A: ok
A-->>S: container.result { requestId, ok: true }
S-->>U: 200
else failure
D-->>A: error
A-->>S: container.result { requestId, ok: false, error }
S-->>U: error including the agent's message
end
requestId correlation means the platform can have many operations outstanding
on a single socket, and a slow docker stop never blocks a concurrent log
stream. Docker errors propagate verbatim rather than being flattened into a
generic failure — the operator sees what Docker actually said.
Heartbeat and liveness¶
The agent sends agent.heartbeat every 30 seconds after registering.
Why bother, when TCP already has keepalives? Because a TCP connection can remain open through a NAT device long after the peer stopped functioning. A heartbeat at the application layer proves the agent's event loop is alive, not merely that a socket exists.
The platform updates lastSeen on each heartbeat and marks agents OFFLINE
when the socket closes. There is deliberately no acknowledgement: silence
from the platform is normal, and an ack would double traffic to tell the agent
something the socket already tells it.
Reconnection¶
stateDiagram-v2
[*] --> Connecting
Connecting --> Registering: socket open
Connecting --> Waiting: dial failed
Registering --> Connected: agent.registered
Registering --> Waiting: registration failed
Connected --> Waiting: socket closed
Waiting --> Connecting: after 5s
The agent reconnects with a fixed 5 second delay and re-registers each time. Because registration is an upsert keyed by UUID, reconnecting is harmless and repeatable — no cleanup, no session resumption, no duplicate host records.
The fixed delay is deliberate simplicity with a known limit: a thousand agents reconnecting after a platform restart would arrive in synchronised waves. Exponential backoff with jitter is on the Roadmap.
Containers keep running
A disconnected agent does not stop anything. Containers run normally; only management is unavailable until the agent reconnects.
Networking summary¶
| Connection | Direction | Port | Encrypted |
|---|---|---|---|
| Browser → platform | inbound to platform | 2002, or 443 behind your TLS proxy | HTTPS when you terminate TLS |
| Agent → platform | outbound from host | same as above | wss:// when the platform URL uses https:// |
| Agent → Docker | local only | Unix socket | n/a — never leaves the host |
| server → postgres / redis | internal bridge | 5432 / 6379 | internal network only |
The agent's scheme is derived from the platform URL given at install time:
https:// becomes wss://, http:// becomes ws://. See
Agent configuration.
Repository layout¶
docksight/
├── apps/
│ ├── web/ React dashboard
│ ├── server/ NestJS API + WebSocket gateway
│ ├── agent/ Go agent (separate Go module)
│ └── cli/ Go CLI: installer and updater
├── packages/
│ ├── protocol/ Shared TypeScript protocol contracts
│ ├── types/ Shared TypeScript types
│ ├── config/ Shared defaults
│ └── utils/ Cross-app helpers
├── bundle/ Platform bundle sources (compose, nginx, .env.example)
├── scripts/ build-release.sh, publish-release.sh
└── docs/ This documentation
More detail in Development.
Design principles¶
- Modular monolith first — extract services only when a real constraint demands it.
- Docker-only scope — no orchestrator abstractions in the core product.
- Outbound-only agents — the network model is a feature, not an accident.
- Independent artifacts — CLI, platform and agent version and ship separately, so one can be upgraded without the others.
- Idempotent installers — every install command is safe to re-run, and re-running is the supported upgrade path.
- Honest failures — surface the underlying error and the next command to run, rather than a generic message.
Related reading¶
- WebSocket protocol — the wire format in depth.
- Protocol specification — field-level reference and source of truth.
- Architecture decision records:
docs/decisions/.