Web shell (browser terminal)
The shell app gives developers a terminal inside their app's own running
container, straight from the browser. A registered Dokku SSH key can
approve browser access without an administrator. It lives at
https://shell.54.195.181.75.sslip.io and is installed automatically with the
platform.
Sessions are real shells inside the app container (docker exec, same entry
point as sudo paas-app-shell <app> on the host): unprivileged runtime user,
minimal environment, and every container restriction from
App container confinement applies. A browser
shell can never reach the host or another tenant.
Getting access
Access is granted per person, per app, for a limited time. Open
https://shell.54.195.181.75.sslip.io/?app=myapp (omit ?app= to access the
shell app). Without a valid session, the page displays a short-lived SSH
login command. Run it on your own computer using your registered Dokku SSH
key (specify -i <private-key> if needed), for example:
ssh -t -i ~/.ssh/id_ed25519 dokku@<platform-host> shell:login ABCD-EFGH
Read the app and browser address in the SSH prompt and type yes only
if they match your request. The waiting browser connects automatically. Codes
expire after 5 minutes and are single-use; the resulting app-scoped session
lasts 24 hours. A stolen code alone cannot log a different browser in.
The SSH key must already be registered with Dokku; local dokku shell:login
does not authenticate you. Treat registered keys as authorized for the apps
they request: this platform has no per-app ownership registry, and self-service
login does not add one. If you need app-specific grants, disable self-service
(SHELL_SSH_LOGIN=false in the service unit) and use the operator flow below.
Operator fallback (no registered SSH key): On the Dokku host run:
sudo paas-shell-token mint --user alice --app myapp --ttl 28800
This prints a signed token and a ready-to-share access URL:
https://shell.54.195.181.75.sslip.io/login?token=v1.eyJ...
The operator sends that URL to the developer (chat, email, ticket). The
--ttl is the token lifetime in seconds (default 3600 = 1 hour; 28800 = 8
hours). When it expires, the developer's next session needs a fresh URL.
Opening a shell (developer side)
- Open the operator-provided access URL in a browser (or use the SSH flow above).
- The platform verifies the token, sets a session cookie
(
HttpOnly, tied to the token's remaining lifetime), and lands you on the terminal page, already scoped to your app. - Type. It's a full terminal: Tab completes commands and paths; Ctrl-L clears
the display and Ctrl-R searches command history in Bash. Other app images
without Bash fall back to
/bin/shand may not support these shortcuts. Arrows, Ctrl keys, and resizing work. Theshellapp image includescurl,python(Python 3),jq, andless; shells in other apps use those apps' own images and toolsets. Close the tab or let it sit — sessions idle out after 30 minutes; the pty is always cleaned up server-side.
Re-opening the same URL later works until the token expires. The token rides
the URL exactly once (at /login); the live session uses the cookie, so the
token never sits in browser history or proxy logs beyond that first click.
GitHub Copilot CLI in the shell
The default shell app ships the GitHub Copilot CLI: type copilot for an
AI pair-programmer with built-in PaaS instructions and a paas-deploy skill.
Other app-scoped shells use their own app images and may not include Copilot.
Sign-in uses GitHub's device code flow when needed — see
GitHub Copilot CLI for the steps, deployment workflow, and
shared deploy-key limitations.
How a session is scoped
- The token binds one user to exactly one app. Switching
?app=to a different app prompts for a new SSH approval and replaces the browser's session cookie; switching back requires approval again. An operator access URL remains scoped to the app it was minted for. Asking for another app with the old token fails with403, and an unknown/stopped app fails with404— enforced server-side before the WebSocket opens. - The shell lands inside that app's own container (found via Dokku's
container labels), as the unprivileged runtime user (
uid 32767), with a scrubbed environment (TERM,HOME=/app,PATH, … — never the platform's or other apps' config). - Caps: 4 concurrent sessions per app, 64 platform-wide; 30-minute idle timeout; liveness checks drop dead connections.
Architecture at a glance
| Piece | Where it runs | Role |
|---|---|---|
shell Dokku app |
its own container | serves the terminal page (xterm.js) |
paas-shell-backend.service |
the Dokku host, 127.0.0.1:8790 |
verifies tokens, spawns the pty inside app containers, enforces scoping/caps/timeouts |
| nginx include | host (/home/dokku/shell/nginx.conf.d/) |
routes /ws/terminal, /login, and /login/ssh/ from the app domain to the backend |
paas-shell-login Dokku plugin |
Dokku host | checks the SSH key identity and approves codes over a group-restricted unix socket |
paas-shell-token |
host (/usr/local/bin/) |
operator CLI: mint/verify tokens |
The token HMAC secret lives only on the host
(/etc/paas-shell/token-secret), readable by root and the service user —
never inside any app container.
Audit trail
Every grant and every session is a JSON line in the service journal:
sudo journalctl -u paas-shell-backend
Look for auth.ssh_login.start, auth.ssh_login.approve,
auth.ssh_login.complete, and auth.ssh_login.reject for self-service
access; or auth.login for operator grants (user, app, token id, expiry,
source address). session.start and session.end record duration,
byte counts, and reason. Service liveness:
sudo systemctl status paas-shell-backend.
Operator reference
- Revoke/expire: tokens are stateless and expire on their own (
--ttl). To invalidate everything immediately, rotate the secret — replace/etc/paas-shell/token-secretwith a freshopenssl rand -base64 32andsudo systemctl restart paas-shell-backend. All outstanding tokens die. - Verify a token without using it:
sudo paas-shell-token verify --token <token>. - Update the service: re-run
terraform apply(source changes re-trigger the install) orsudo paas-install-shell-backendon the host. - Backend health from the host:
curl -s http://127.0.0.1:8790/health, or through the app domain:https://shell.54.195.181.75.sslip.io/backend-health.