A browser SSH terminal for your servers. You log in with your identity provider (OIDC), and the app signs a short-lived SSH certificate for each session. No SSH keys to hand out, rotate or revoke.
Website & setup generator: certshell.dev
- You log in through OIDC (e.g. Pocket ID). Your IdP groups decide what you can reach.
- The app is an SSH certificate authority. For every terminal it mints a fresh key and a 15‑minute cert valid for one host, one login, with no forwarding.
- Hosts trust the CA through a one-line enroll script. Existing
authorized_keyskeep working, so enrolling can't lock you out. - Admins map IdP group → host → login in the web UI. No edits on the host after enrollment.
- Every login, cert and session goes to an audit log (in the app and on stdout). The host's sshd logs each cert's ID and serial too.
- Tasks: save a script and the hosts it runs on (e.g.
apt-get upgrade -yon every web server), run it in one click, and see each host's exit code and output. Hosts with the same output are grouped; the rest show a diff. - Tags: admins tag hosts. Filter or group the host list by tag, and pick task hosts the same way. Tags only organise; they don't change access.
- Fleet: OS, uptime, pending updates, reboot flag and disk use for every host, collected every 6 hours with a no-terminal cert.
- Alerts: failed tasks, offline hosts, reboots, security changes or terminals on chosen hosts go to ntfy, Discord, Slack, Telegram, email and more, through shoutrrr.
Scope: browser terminal and tasks. No native ssh/scp/sftp and no port forwarding.
Both do much more than CertShell. How they differ for SSH, as of October 2026 (spot a mistake? open an issue):
| CertShell | Teleport Community Edition | Warpgate | |
|---|---|---|---|
| License | MIT | Source AGPL-3.0. Binaries under a commercial license, free for companies under 100 employees and $10M revenue | Apache-2.0 |
| SSO | Any OIDC provider | GitHub only (OIDC and SAML need Enterprise) | OIDC, plus built-in TOTP |
| How hosts let you in | A fresh cert per session: 15 minutes, one host, one login | Short-lived certs, via the Teleport agent or agentless OpenSSH | Warpgate's own SSH key in authorized_keys, or a stored password |
| Browser terminal | Yes | Yes | Yes |
Native ssh client |
No | Yes (tsh) |
Yes |
| Session recording | No. Audit log of logins, certs and sessions | Yes | Yes |
| Other protocols | None | Kubernetes, databases, desktops, apps | HTTPS, Kubernetes, MySQL, PostgreSQL, RDP, VNC |
- Pick Teleport if you need more than SSH, session recording or native
ssh, and you fit the Community license or will pay for Enterprise (which OIDC needs). - Pick Warpgate if you want native SSH clients and the browser behind one bastion, with session recording and more protocols.
- Pick CertShell if you want browser-only SSH behind any OIDC provider, a fresh cert per session instead of a standing key on every host, and saved scripts across hosts.
Sources: Teleport feature matrix and license, Warpgate README and SSH targets.
You need a server with Docker, ports 80/443 open, and two DNS names pointing at it (e.g. ssh.example.com, id.example.com). Pick a reverse proxy; both get HTTPS certificates automatically:
examples/caddy: Caddy.examples/traefik: Traefik with Let's Encrypt. Routes live inroutes.yml, so Traefik needs nodocker.sock.
Or skip the clone: the setup generator writes these files for you, or installs them with one command.
git clone --depth 1 https://github.com/maca134/certshell.git certshell-src
cp -r certshell-src/examples/caddy certshell && cd certshell # or examples/traefik
cp .env.example .env # set SSH_DOMAIN, ID_DOMAIN, POCKET_ID_ENCRYPTION_KEY
# Optional but recommended: encrypts the CA key at rest. The container runs as uid 1000.
mkdir secrets && openssl rand -base64 32 > secrets/ca_password
chown 1000:1000 secrets/ca_password && chmod 600 secrets/ca_password
docker compose up -d caddy pocket-id # or: traefik pocket-idNo
ca_password? Remove thesecrets:lines fromcompose.yaml. The CA key is then stored unencrypted.
Then in Pocket ID (https://id.example.com):
- Open
/setupand create your admin account and passkey. - User Groups → create
certshell-adminsand add yourself. - OIDC Clients → add
certshell:- Callback URL:
https://ssh.example.com/auth/callback - Allowed user groups: whoever may use CertShell at all.
- Callback URL:
- Copy the client ID and secret into
.env(OIDC_CLIENT_ID,OIDC_CLIENT_SECRET).
docker compose up -dOpen https://ssh.example.com and log in. More detail, plus adding users and recovery: Pocket ID walkthrough.
Already have a reverse proxy? Drop the caddy/traefik service, publish certshell on 127.0.0.1:3000, and set TRUSTED_PROXIES to your proxy's address. Already have an IdP? Drop pocket-id. Any OIDC provider works if it sends a groups claim that users can't change themselves.
- Admin → Hosts → Add host: a name and the address the app should connect to.
- Enroll snippet → copy it and run it as root on the host. It's valid for 10 minutes and one use.
- Under the host, Allow an IdP group a login (e.g.
ops→root).
Host requirements: Linux, FreeBSD or OPNsense, OpenSSH ≥ 7.3, curl (FreeBSD: pkg install curl), and reachable on port 22 from the app. Tasks run in the login's shell, so a login whose shell is csh/tcsh (possible on FreeBSD) needs csh-syntax scripts. The host must reach https://ssh.example.com only while enrolling.
The script writes the CA public key, adds sshd_config.d/50-certshell.conf (in /etc/ssh, or /usr/local/etc/ssh on OPNsense), checks sshd loads it (if sshd_config doesn't include sshd_config.d/*.conf, as on FreeBSD, it adds that Include as the first line, keeping a backup at sshd_config.certshell.bak), records the host key with the app, and reloads sshd. If it fails before the host key is recorded, a first enroll removes what it wrote. The app pins that host key, so a different machine at the same address is refused.
Removing a host. Remove on the host's panel. CertShell stops signing certs for it straight away. The host still trusts the CA until you run the command the dialog shows, as root on the host. It deletes sshd_config.d/50-certshell.conf and certshell_user_ca.pub, then reloads sshd. It works pasted into sh, bash or csh. An Include line the enroll script added stays; it loads nothing once the drop-in is gone. Open terminals to the host stay open until they close or you end them under Admin → Sessions.
OPNsense. Enroll from a root shell (console or SSH, menu option 8). First, under System → Settings → Administration → Secure Shell: enable it on an interface the app can reach, and tick Permit root user login if you'll allow root. OPNsense only lets the wheel group log in over SSH, so other logins must be in wheel. Root's shell is the OPNsense console menu, and commands run under csh: a root terminal opens on the menu (8 for a shell), and root tasks need csh syntax, so prefer a wheel login with /bin/sh for tasks. The drop-in lives outside config.xml: a reinstall from a config backup needs a re-enroll.
- Hosts lists every host + login the access map gives you. A dot shows whether the host's port 22 is reachable from the app. Click a login to open a terminal.
- Search the scrollback with the search button or Ctrl+Shift+F. Enter finds the next match, Shift+Enter the previous one, Esc closes.
- Multi-line pastes ask before running, unless the shell has bracketed paste on (then nothing runs until you press Enter).
- Terminals close after 30 minutes idle, and always after 8 hours.
A task is a saved script plus the host logins it runs on, e.g. apt-get upgrade -y on every web server.
- Tasks → New task: a name, the script, then tick hosts and pick a login on each. Only host logins the access map gives you are offered, and that is checked again on every run.
- Run starts every host at once. Each host shows ok or failed, its exit code and its output (the last 256 KB). Run again repeats it.
- Group by output puts hosts with the same exit code and output together, biggest group first. Every other group shows a diff against the biggest one. On by default when at least two hosts match; By host lists each host instead.
- The script runs in the login's shell without a terminal, so prompts get no input: use
-yand similar. Runs are killed after 30 minutes. - Keep running if the connection drops: turn on for scripts that can cut their own connection (VPN, sshd, firewall or network changes). The script then finishes even if the app loses the connection, and its output also goes to a log file in
/tmpon the host. - Tasks and runs are private to the user who made them. Limits: 100 tasks, 50 hosts per task, 16 KB per script. A run keeps its own copy of the script, so editing a task doesn't change past runs.
Members of OIDC_ADMIN_GROUP get an Admin section:
- Hosts: add, enroll, edit and delete hosts, and edit the access map (Adding a host).
- Fleet: each host's OS, uptime, pending updates (and how many are security), reboot-needed flag and
/disk use, with counts on top (need a reboot, have updates, disk ≥ 90%, not collected). Click a count to list those hosts, then New task on these hosts. Turn it on per host by setting a facts login under Hosts → Edit: CertShell logs in as it every 6 hours with a no-terminal cert and runs a fixed read-only script. That login needs no access rule. Update counts come from the host's own package cache, so they're as fresh as its lastapt update(or equivalent). - Users: everyone who has signed in, with their groups as of their last sign-in. Sign out & end sessions closes their terminals, stops their running tasks and signs them out of CertShell. Disable them at the IdP too, or they can sign straight back in.
- Sessions: open terminals right now. End any one of them.
- Alerts: send notifications through shoutrrr (ntfy, Gotify, Discord, Slack, Telegram, email, webhooks, …). Add a channel with its shoutrrr URL and pick events: a task run with a failed host, a host offline or back online (port 22 checked every minute), a host newly needing a reboot (from Fleet), security changes (hosts, access, enrollment, alert channels, sessions ended by an admin, a host key mismatch), or a terminal opened (optionally only on hosts with certain tags, or for certain logins). The URL is never shown again after saving, and is stored encrypted when
ca_passwordis set. Send test checks a channel. - Audit: logins, every cert signed, terminal start/end, task runs, host and access map changes, enrollments. The same events go to stdout.
| Variable | Required | Default |
|---|---|---|
APP_URL |
yes | Public URL, e.g. https://ssh.example.com |
OIDC_ISSUER |
yes | |
OIDC_CLIENT_ID |
yes | |
OIDC_CLIENT_SECRET |
yes | |
OIDC_ADMIN_GROUP |
no | certshell-admins |
CA_NAME |
no | certshell (comment on the CA key) |
TRUSTED_PROXIES |
no | none: X-Forwarded-For ignored. Comma-separated IPs. |
UPDATE_CHECK |
no | on: checks GitHub tags every 12h and shows admins a newer version. false turns it off. |
AUDIT_DAYS |
no | none: audit log kept forever. Whole days; older entries deleted at startup and daily. |
RUN_DAYS |
no | none: task runs and their output kept forever. Whole days, as AUDIT_DAYS. |
/run/secrets/ca_password |
no | none: CA key stored unencrypted, warning logged |
- HTTPS is required (secure cookies; passkeys at the IdP).
- Sessions end after 30 minutes idle or 8 hours, then go back through the IdP. Group changes at the IdP apply to new terminals within 8 hours.
- Terminals close after 30 minutes idle, and always after 8 hours.
The app holds the CA key. If the app is compromised, an attacker can get a shell on any enrolled host as any mapped login. The design limits that:
- Each host accepts only its own principals: one cert = one host + one login, 15 minutes, no forwarding.
- Small unauthenticated surface, strict security headers, rate limits.
- Hardened container: non-root, read-only root filesystem, all capabilities dropped.
- Audit trail outside the app: stdout logs and each host's sshd logs.
Also trusted: your IdP. Whoever can edit IdP groups can grant whatever the access map gives those groups.
Recommendations:
- Keep a break-glass SSH key offline, not signed by this CA.
- Put it behind a VPN if you can. Internet exposure is supported, not encouraged.
- Never mount
docker.sockinto thecertshellcontainer.
Full threat model: SPEC.md §3.3.
- Volume
certshell-data: the CA key and the app database (hosts, access map, audit log). secrets/ca_password, stored separately. Without it the CA key in the backup is useless; without a password the backup is your CA, so protect it.- Pocket ID: volume
pocket-id-dataandPOCKET_ID_ENCRYPTION_KEY.
The examples use ghcr.io/maca134/certshell:1, which follows every 1.x release. Pin 1.4 or 1.4.1 instead if you'd rather upgrade by hand.
docker compose pull && docker compose up -d- Back up
certshell-datafirst. Database migrations run at startup. - The restart closes open terminals and marks running task targets failed. Detached task scripts still finish on the host.
- Admins see a "… available" notice in the sidebar when a newer release exists (
UPDATE_CHECK).
docker compose logs certshell shows the reason behind most errors.
- "identity provider unavailable": the container can't reach
OIDC_ISSUER. Check DNS from inside the container, or theID_DOMAINnetwork alias the examples set for Pocket ID. - "login failed": usually the callback URL doesn't match
https://ssh.example.com/auth/callbackexactly, or the client ID/secret is wrong. The log line afterlogin failed:names the cause. - The IdP says you're not allowed: add your group to the OIDC client's allowed groups (Pocket ID: the client's Access tab).
- Logged in, but no hosts: none of your groups is allowed on a host. Group names must match exactly (Pocket ID: the name, not the friendly name). Groups are read at sign-in, so sign out and back in after changing them.
- No Admin section: you're not in
OIDC_ADMIN_GROUP, or haven't signed in again since being added. - Enroll: "could not load … via an Include in …/sshd_config": the script added the
Include, but sshd still didn't pick up the drop-in, so it putsshd_configback. Usuallysshd_configsetsTrustedUserCAKeys,AuthorizedPrincipalsCommandorAuthorizedPrincipalsCommandUseritself (sshd keeps the first value); remove those lines, then run a new snippet. - Enroll: curl fails: the host can't reach
https://ssh.example.com, or the snippet is older than 10 minutes or already used. Generate a new one. - Host shows offline: port 22 on the host's address isn't reachable from the CertShell container. Check the address and firewalls.
Bun workspaces: apps/api (Hono), apps/web (React + xterm.js), packages/shared.
bun install
bun run test # unit tests
bun run e2e # Playwright
bun run lint
bun run typecheckDesign and decisions: SPEC.md.
