There is a moment in every deployment where something goes wrong and you need to look inside the container. Check a log file. Verify a config path. See if a volume actually mounted. In a traditional setup, you SSH into the server, run docker exec -it container_name /bin/sh, and poke around. In a PaaS, that escape hatch should be one click away.
We built two features that most self-hosted PaaS platforms do not offer: a browser-based terminal that drops you into any running container, and a Docker Desktop-style file explorer that lets you browse, create, edit, and delete files without ever touching a command line.
The Competitive Landscape
Before building, we surveyed every major self-hosted PaaS:
- Coolify: No web terminal. No file browser.
- Easypanel: No web terminal. No file browser.
- Dokku: SSH-only access. No browser-based tools.
- CapRover: Has a web terminal (basic). No file browser.
- Railway: Has a web terminal (cloud-only). No file browser.
The file browser gap is particularly striking. Every competitor's documentation says some variant of "if you need to access files, deploy FileBrowser as a separate service." That is not a solution -- it is a workaround. sh0 has both features built into the dashboard.
Web Terminal: Architecture
The web terminal has three layers:
1. Frontend: xterm.js v6 renders a terminal in the browser.
2. Transport: A WebSocket connection carries keystrokes and output bidirectionally.
3. Backend: Rust spawns docker exec -i as a child process and pipes between the WebSocket and the process's stdin/stdout.
The Backend Handler
The terminal handler in crates/sh0-api/src/handlers/terminal.rs accepts a WebSocket upgrade request:
GET /api/v1/apps/:id/terminal?token=<jwt>&shell=/bin/shOn connection, it:
1. Validates the JWT token.
2. Looks up the app's container ID.
3. Spawns docker exec -i as a child process using tokio::process::Command.
4. Enters a bidirectional pump loop: WebSocket messages become stdin writes, stdout reads become WebSocket messages.
5. On disconnect, kills the child process and cleans up.
let mut child = Command::new("docker")
.args(["exec", "-i", &container_id, &shell])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;let mut child_stdin = child.stdin.take().unwrap(); let child_stdout = child.stdout.take().unwrap();
// Pump stdout -> WebSocket let ws_tx_clone = ws_tx.clone(); tokio::spawn(async move { let mut reader = BufReader::new(child_stdout); let mut buf = [0u8; 4096]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { let _ = ws_tx_clone.send(Message::Binary(buf[..n].to_vec())).await; } Err(_) => break, } } });
// Pump WebSocket -> stdin while let Some(Ok(msg)) = ws_rx.recv().await { if let Message::Binary(data) = msg { let _ = child_stdin.write_all(&data).await; } }
// Cleanup let _ = child.kill().await; ```
The shell is configurable: /bin/sh (default, works everywhere), /bin/bash (if available), or /bin/ash (Alpine). The frontend offers a dropdown to switch shells, and the choice persists for the session.
The Frontend Component
AppTerminal.svelte wraps xterm.js v6 with the fit addon for automatic resizing:
<script lang="ts">
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';let { appId } = $props<{ appId: string }>(); let terminal: Terminal; let ws: WebSocket; let connected = $state(false); let shell = $state('/bin/sh'); let terminalEl: HTMLDivElement;
function connect() { terminal = new Terminal({ theme: { background: '#1a1b26', // Tokyo Night foreground: '#a9b1d6', cursor: '#c0caf5', black: '#32344a', red: '#f7768e', green: '#9ece6a', yellow: '#e0af68', blue: '#7aa2f7', magenta: '#ad8ee6', cyan: '#449dab', white: '#787c99', }, fontFamily: 'JetBrains Mono, monospace', fontSize: 14, scrollback: 5000, cursorBlink: true, });
const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(terminalEl); fitAddon.fit();
const token = getAuthToken();
ws = new WebSocket(
${wsBase}/api/v1/apps/${appId}/terminal?token=${token}&shell=${shell}
);
ws.binaryType = 'arraybuffer';
ws.onopen = () => { connected = true; terminal.focus(); }; ws.onmessage = (e) => terminal.write(new Uint8Array(e.data)); ws.onclose = () => { connected = false; };
terminal.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(new TextEncoder().encode(data)); } });
// Resize handling new ResizeObserver(() => fitAddon.fit()).observe(terminalEl); }
The Tokyo Night colour theme was a deliberate choice. It is a popular dark theme in the developer community, and it matches sh0's dark mode palette. The 5,000-line scrollback buffer lets you scroll through output without losing history, while keeping memory bounded.
The ResizeObserver on the terminal element ensures that when the browser window resizes or the user adjusts a split pane, the terminal reflows its columns and rows. Without this, lines would wrap incorrectly or columns would render beyond the visible area.
File Explorer: Architecture
The file explorer is more complex than the terminal because it involves multiple operations: browsing, creating, editing, and deleting. Each operation runs via docker exec commands on the target container.
Backend: File Operations
The backend in crates/sh0-api/src/handlers/storage.rs exposes six endpoints for file operations:
GET /apps/:id/browse?path=/&container=<id> # List directory
GET /apps/:id/files/read?path=/etc/nginx.conf # Read file contents
POST /apps/:id/files/write # Write file contents
POST /apps/:id/files/mkdir # Create directory
POST /apps/:id/files/new # Create empty file
DELETE /apps/:id/files?path=/tmp/debug.log # Delete file or folderEvery operation is a docker exec command. Browsing runs ls -la. Reading runs cat. Writing pipes content to tee. Creating directories runs mkdir -p. Deletion runs rm -rf with safeguards.
BusyBox Compatibility
The first bug we hit was that ls --time-style=long-iso does not work on Alpine-based containers. Alpine uses BusyBox, which has a different ls implementation. Our parser expected GNU ls output and choked on BusyBox's format.
The fix was a three-level fallback:
async fn list_directory(container_id: &str, path: &str) -> Result<Vec<FileEntry>> {
// Try GNU ls first
let output = docker_exec(container_id, &["ls", "-la", "--time-style=long-iso", path]).await;
if output.is_ok() {
return parse_gnu_ls(output.unwrap());
}// Fallback to BusyBox --full-time let output = docker_exec(container_id, &["ls", "-la", "--full-time", path]).await; if output.is_ok() { return parse_busybox_ls(output.unwrap()); }
// Last resort: plain ls -la let output = docker_exec(container_id, &["ls", "-la", path]).await?; parse_plain_ls(output) } ```
The parser also handles edge cases: symlink targets (stripped from the display name), timezone fields in BusyBox output, and the total line at the top of ls output.
Path Traversal Prevention
Every file operation validates the requested path:
fn validate_path(path: &str) -> Result<()> {
if path.contains("..") {
return Err(ApiError::bad_request("Path traversal not allowed"));
}
Ok(())
}Additionally, the delete endpoint blocks deletion of protected system directories:
const PROTECTED_PATHS: &[&str] = &[
"/bin", "/etc", "/usr", "/lib", "/sbin",
"/proc", "/sys", "/dev",
];fn validate_delete_path(path: &str) -> Result<()> { validate_path(path)?; let normalized = path.trim_end_matches('/'); if PROTECTED_PATHS.contains(&normalized) { return Err(ApiError::bad_request("Cannot delete protected system directory")); } Ok(()) } ```
This is defence in depth. The dashboard UI also prevents navigating outside the container's filesystem, but the backend enforces it independently. A malicious API call with path=/../../../etc/passwd is rejected before docker exec ever runs.
Container ID Resolution
A subtle bug emerged when users tried to browse files in a database container. The container selector dropdown showed databases by their service UUID (a 36-character string like a1b2c3d4-e5f6-...), but the browse API expected a Docker container ID (a 12 or 64 character hex string).
The solution was resolve_container_id():
async fn resolve_container_id(app_id: &str, container_ref: &str, db: &DbPool) -> Result<String> {
// Heuristic: UUIDs are 36 chars with hyphens, Docker IDs are 12/64 hex chars
if container_ref.len() == 36 && container_ref.contains('-') {
// Look up the service's Docker container ID from the database
let service = db.get_service(container_ref).await?;
// IDOR prevention: verify the service belongs to this app
if service.app_id != app_id {
return Err(ApiError::forbidden("Service does not belong to this app"));
}
Ok(service.container_id)
} else {
// Assume it is already a Docker container ID
Ok(container_ref.to_string())
}
}The IDOR (Insecure Direct Object Reference) check is important. Without it, a user could pass a service UUID from a different app and browse that container's files. The check ensures that the referenced service actually belongs to the app specified in the URL path.
Frontend: The Two-Panel Layout
AppFiles.svelte implements a Docker Desktop-style layout:
Left panel (280px): A recursive directory tree (FileTree.svelte) with a container selector dropdown at the top. Folders expand on click, loading their children lazily from the API. A folder/file icon distinguishes entries at a glance.
Right panel (remaining width): Either a directory listing table (when a folder is selected), a file viewer/editor (when a file is selected), or an empty state.
The toolbar spans the top of the right panel: breadcrumb navigation, New File, New Folder, Delete, and Refresh buttons. The create workflow is inline -- click New File, type a name directly in the directory listing, press Enter. No modal, no dialog.
<!-- FileTree.svelte (simplified) -->
<script lang="ts">
let { appId, containerId } = $props();
let root = $state<TreeNode>({ path: '/', children: [], loaded: false }); async function loadChildren(node: TreeNode) {
const entries = await filesApi.browse(appId, node.path, containerId);
node.children = entries
.filter(e => e.isDirectory)
.map(e => ({ path: ${node.path}${e.name}/, children: [], loaded: false }));
node.loaded = true;
}
// Expand uses $effect with untrack to prevent infinite loops
$effect(() => {
const key = ${appId}-${containerId};
untrack(() => {
root = { path: '/', children: [], loaded: false };
loadChildren(root);
});
});
```
The Infinite Effect Loop
The most insidious bug in the file explorer was a Svelte 5 reactivity issue. FileTree.svelte had an $effect that both read and wrote root (a $state variable). When loadChildren() mutated root by setting node.children and node.loaded, Svelte 5 detected a state change and re-triggered the effect, which loaded children again, which mutated state again -- an infinite loop of API calls.
The fix required two changes:
1. untrack() around mutations. All state mutations inside the effect are wrapped in untrack(), telling Svelte "these writes should not trigger a re-run of this effect."
2. $derived session key. Instead of the effect depending on root, it depends on a derived key computed from appId and containerId. This means the effect only re-runs when the container changes, not when the tree expands.
3. Error guard. Setting node.loaded = true on error prevents the effect from retrying a failed load indefinitely.
This is a pattern worth remembering for anyone building tree structures with Svelte 5: effects that mutate their own dependencies need explicit untrack() boundaries.
The Edit Experience
When a file is selected, the right panel shows its contents. For text files, an "Edit" button switches to a textarea with Save and Cancel buttons. For binary files, the component detects non-printable characters and shows a "Binary file -- cannot edit" message instead.
{#if editMode}
<textarea bind:value={editContent}
class="w-full h-96 font-mono text-sm bg-[var(--bg-secondary)]
text-[var(--text-primary)] p-4 rounded" />
<div class="flex gap-2 mt-3">
<Button onclick={saveFile}>{t('files.save')}</Button>
<Button variant="ghost" onclick={() => editMode = false}>
{t('files.cancel')}
</Button>
</div>
{:else}
<pre class="p-4 text-sm font-mono overflow-x-auto">{fileContent}</pre>
<Button onclick={() => { editContent = fileContent; editMode = true; }}>
{t('files.edit')}
</Button>
{/if}The file metadata bar below the toolbar shows permissions (e.g., -rw-r--r--), size (human-readable), and last modified date. This is information developers glance at instinctively -- "is this file writable? How old is it?"
Why This Matters
The web terminal and file explorer are not flashy features. They are pragmatic ones. They eliminate the most common reason a developer SSHs into a server after deploying an app: "I need to look inside the container."
For sh0's target audience -- developers who manage their own servers but do not want to spend their days in Docker CLI -- these features close the gap between "I deployed my app" and "I can fully manage my app." The terminal is the escape hatch for anything the dashboard does not cover. The file explorer is the visual tool for the 80% of file operations that do not need a shell.
Together, they make sh0 feel less like a deployment tool and more like a development environment.
---
Next in the series: Real-Time Logs: WebSocket Streaming from Docker Containers -- how we built log streaming with JWT-authenticated WebSockets, auto-reconnect, and a terminal-style viewer.