A browser-based Linux terminal service for internal use. Each user gets an isolated Docker container with a full bash environment accessible directly from the browser.
Inspired by webminal.org, but built with a modern stack.
- In-browser terminal — xterm.js with full color support, tab completion, and vim/nano
- Isolated sandboxes — each session runs in its own Docker container (256MB RAM, 0.5 CPU)
- Session management — auto-terminates idle sessions (30 min), hard limit of 4 hours
- User management — admin panel to create users and force-terminate sessions
- Reconnect support — container stays alive on disconnect; reconnect to the same session
- SSH private key injection — users can save an SSH private key from the web UI for new sessions
- No build step — frontend is plain HTML/CSS/JS served by nginx
| Layer | Technology |
|---|---|
| Frontend | Vanilla JS + xterm.js 5.5 (CDN) |
| Backend | Python 3.11 + FastAPI |
| Terminal | subprocess PTY → docker exec -it |
| Isolation | Docker container per session |
| Auth | JWT (HS256) + bcrypt |
| Storage | SQLite |
| Proxy | nginx |
| Deploy | Docker Compose |
Prerequisites: Docker Desktop (or Docker Engine + Compose plugin)
# 1. Clone and configure
cp .env.example .env
# Edit .env: set SECRET_KEY to a random hex string
python3 -c "import secrets; print('SECRET_KEY=' + secrets.token_hex(32))" >> .env
# 2. Build and start
docker compose build
docker compose up -d
# 3. Create the first admin user
echo -e "admin\nadmin@yourcompany.com\nyourpassword" | \
docker compose exec -T backend python scripts/create_admin.py
# 4. Open in browser
open http://localhost- Go to
http://your-serverand sign in - Click New Terminal to create a session
- A bash terminal opens in your browser — use it like any Linux shell
- Click Disconnect or close the tab to disconnect (session stays alive for reconnect)
- Sessions idle for 30 minutes are automatically terminated
- Optional: save an SSH private key on the dashboard; new shells will load it into the container, and it is exposed as
SSH_PRIVATE_KEY
Go to Admin (top-right, visible to admin accounts) to:
- Create or deactivate user accounts
- Promote users to admin
- View and force-terminate any active session
# Via admin panel in the browser, or via API:
curl -X POST http://localhost/api/admin/users \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"username":"alice","email":"alice@co.com","password":"pass123","is_admin":false}'All settings are in .env (copy from .env.example):
| Variable | Default | Description |
|---|---|---|
SECRET_KEY |
(required) | JWT signing secret — generate with python3 -c "import secrets; print(secrets.token_hex(32))" |
PORT |
80 |
Host port for nginx |
IDLE_TIMEOUT |
1800 |
Seconds before idle session is terminated (30 min) |
MAX_SESSION_TIME |
14400 |
Hard session time limit in seconds (4 hours) |
MAX_SESSIONS_PER_USER |
3 |
Max concurrent sessions per user |
CONTAINER_MEMORY |
256m |
Memory limit per sandbox container |
CONTAINER_CPUS |
0.5 |
CPU share per sandbox container |
CONTAINER_PIDS_LIMIT |
50 |
PID limit per sandbox container (prevents fork bombs) |
Each terminal session gets an Ubuntu 22.04 container with:
- Shells: bash + bash-completion
- Editors: vim, nano
- File tools: less, tree, find, file
- Text processing: grep, awk, sed
- Network: curl, wget, netcat, ping, dig, net-tools
- Development: git, python3, pip
- Archives: tar, gzip, zip, unzip
- Misc: man pages, sudo (within container)
Containers run as user sandbox (non-root) with dropped capabilities and no outbound internet access by default.
Edit sandbox/Dockerfile, then rebuild:
docker compose build
# New sessions will use the updated imageIn backend/services/docker_manager.py, in ensure_sandbox_network(), remove "Internal": True from the network creation call. Rebuild the backend:
docker compose build backend && docker compose up -d backendPOST /api/auth/login # Form: username + password → JWT
GET /api/auth/me # Current user profile
GET /api/profile/ssh-key # SSH private key presence status for current user
PUT /api/profile/ssh-key # Save/replace the SSH private key
DELETE /api/profile/ssh-key # Delete the saved SSH private key
POST /api/sessions # Create session (starts a container)
GET /api/sessions # List your active sessions
GET /api/sessions/{id} # Session status
DELETE /api/sessions/{id} # Terminate session
GET /api/admin/users # [admin] List all users
POST /api/admin/users # [admin] Create user
PUT /api/admin/users/{id} # [admin] Update user (activate/deactivate/admin)
GET /api/admin/sessions # [admin] All active sessions
DELETE /api/admin/sessions/{id} # [admin] Force-terminate any session
WS /ws/{session_id}?token=JWT # Terminal WebSocket
GET /health # Health check
Make sure WebSocket upgrade headers are forwarded:
Upgrade: websocket
Connection: Upgrade
The nginx inside the stack already handles this for the /ws/ path. If you put another proxy in front, configure it to proxy-pass WebSocket connections.
For production, terminate TLS at your outer reverse proxy (or modify nginx/nginx.conf to add HTTPS). The JWT tokens and passwords are sent in plaintext over HTTP — use HTTPS in production.
- The SQLite database is stored at
./data/web_terminal.db(volume-mounted) - Session containers are ephemeral — destroyed on termination
- User accounts and session history persist across restarts
Each active session uses up to 256MB RAM + 0.5 CPU on the host (not in the backend container). Plan accordingly:
- 10 concurrent users: ~2.5GB RAM, ~5 CPU cores
- Adjust
CONTAINER_MEMORYandCONTAINER_CPUSin.envas needed
Terminal connects but shows no output
docker compose logs backend --tail=50
docker ps --filter "label=web-terminal=sandbox""Container not running" when connecting The sandbox container crashed. Check:
docker compose logs backend | grep "container"Sessions not cleaning up The cleanup loop runs every 60 seconds. Check it's running:
docker compose logs backend | grep -i "cleanup\|terminate"docker.sock permission denied On Linux, the backend container needs access to the Docker socket. The compose file runs the backend as root. If you've modified this, check socket permissions:
ls -la /var/run/docker.sock