Container Log Streaming#
Real-time container log streaming in Dokploy is implemented as a WebSocket server at path /docker-container-logs. The entry point is setupDockerContainerLogsWebSocketServer in apps/dokploy/server/wss/docker-container-logs.ts. It handles both local (same-host) and remote (SSH) streaming, and supports both Docker Swarm service logs and standalone container logs.
Connection parameters (all passed as URL query params) :
| Param | Purpose |
|---|---|
containerId | Target container or service ID |
tail | Number of log lines to show initially (default: "100") |
search | Optional grep filter |
since | Time filter ("all" or duration like "1h") |
serverId | If set, streams from a remote server via SSH |
runType | "swarm" uses docker service logs; otherwise docker container logs |
All params are validated before use — isValidContainerId, isValidTail, isValidSince, isValidSearch — to prevent command injection . Authentication is enforced via validateRequest before any streaming begins .
Local vs. Remote Streaming#
Local (no serverId)#
The local path uses node-pty's spawn to open a PTY shell (xterm-256color, 80×30) that runs the docker logs command. Data from the PTY is forwarded directly to the WebSocket via ptyProcess.onData. Messages from the client are written back to the PTY via ptyProcess.write, allowing the client to interact with the terminal session. Not available in the cloud version .
Remote (serverId present)#
The remote path opens an SSH2 Client connection to the target server using its stored ipAddress, port, username, and sshKey.privateKey . Once connected, it calls client.exec(command, { pty: true }, ...) to run the Docker log command on the remote host. Both stdout and stderr from the SSH stream are forwarded to the WebSocket . An organization-ownership check is performed before connecting .
The Docker Command#
Both paths build the same base command :
- Swarm:
docker service logs --timestamps --raw --tail <N> [--since <T>] --follow <id> - Standalone:
docker container logs --timestamps --tail <N> [--since <T>] --follow <id>
If search is provided, the command is piped through grep --line-buffered -iF "<term>" (remote) or grep -iF '<term>' (local).
Orphaned Process Prevention#
A docker logs --follow process that outlives its consumer becomes an orphan, wasting resources. Dokploy addresses this differently for each path:
Remote (SSH): client.exec is called with { pty: true }. Allocating a PTY means the remote docker logs process receives SIGHUP when the SSH connection drops, causing it to exit. Without { pty: true }, closing the SSH connection leaves the remote process running. This was fixed in PR #3176. On WebSocket close, client.end() is called to explicitly tear down the SSH connection and trigger SIGHUP delivery.
Local (node-pty): On WebSocket close, ptyProcess.kill() is called. node-pty's kill() sends SIGTERM to the PTY process group, then escalates as needed. The PTY ensures signals propagate to the docker logs child process.
Both cleanups also call clearInterval(pingInterval) to stop the keep-alive timer.
Keep-Alive Ping Mechanism#
WebSocket connections were closing after ~60 seconds of log inactivity (error code 1006 — abnormal closure). This was fixed in PR #3035.
A setInterval sends ws.ping() every 45 seconds while ws.readyState === ws.OPEN. The browser's WebSocket implementation responds automatically with pong frames, satisfying proxy and server idle-timeout requirements without any client-side changes.
The interval is cleaned up in all three termination paths to prevent memory leaks :
- SSH
errorevent →clearInterval(pingInterval)thenws.close()+client.end() - WebSocket
closeevent (remote) →clearInterval(pingInterval)thenclient.end() - WebSocket
closeevent (local) →clearInterval(pingInterval)thenptyProcess.kill()
execAsync / execAsyncStream — No Cancellation#
The underlying command execution utilities used throughout the server have no cancellation or timeout mechanisms:
execAsyncwrapschild_process.execviautil.promisify. There is noAbortSignal, no timeout option, and no reference to the child process is exposed to callers.execAsyncStreamcreates a child process internally but similarly exposes no handle to callers — once started, it runs to completion or failure.execAsyncRemoteopens a fresh SSH connection per call withtimeout: 99999— this timeout is for connection establishment only, not command execution. There is no mechanism to abort a running remote command.
The non-streaming getContainerLogs in services/docker.ts uses these utilities to fetch a snapshot of container logs (no --follow). It is an entirely separate path from the WebSocket streaming path described above.
This gap extends beyond log fetching — builds and deployments dispatched via execAsync/execAsyncRemote also cannot be cancelled in-flight. If a build process is killed externally (e.g. OOM), there is no in-process cleanup path; the deployment row stays stuck at status = running. Mitigation: the "Clean all deployment queue" UI option (added in PR #3625) clears pending queue entries, but does not fix already-active stuck rows .