Technical Documentation: shellhub-io/shellhub
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
ShellHub is an SSH gateway that ties every login to a person on your team. A small agent on each
device dials out to the ShellHub server, and you reach the device through the server with ssh,scp, sftp or the browser. The device needs no public IP or inbound port, whether it is a
server, a cloud instance or an embedded Linux device in the field.
Quickstart
ShellHub Cloud. Create a free account at cloud.shellhub.io. The
console walks you through your first device.
Self-hosted. You need Docker with Compose, and ports 80 and 22 free on the host:
git clone -b v0.27.0 https://github.com/shellhub-io/shellhub.git
cd shellhub
make startOpen http://<your-server>/setup to create the admin account and its namespace, the group that
holds your devices and the people who can reach them. The
deployment guide covers HTTPS and production
settings.
Add a device. Run the installer on the device:
curl -sSf http://<your-server>/install.sh | shThe agent prints a pairing code. Enter it in the console to pair the device, then open a shell
from the console or from any SSH client:
ssh <user>@<namespace>.<device>@<your-server>To enroll many devices without pairing each one, create a provisioning key under Add Device
→ Fleet and pass it to the installer.
Features
- Standard SSH. OpenSSH, PuTTY,
scp,sftpand port forwarding work unchanged. - Web terminal. Open a shell on any device from the console.
- SSH Identities. Every SSH login belongs to a person on your team, not to a key copied into
authorized_keys, and it works with the plainsshclient, with no wrapper to install. Remove
someone from the team and their access ends on every device at once. - Access Policies. Default-deny rules for who reaches which devices and as which Linux user,
managed in one place. A policy can also require a fresh sign-in before each session. - Sessions. ShellHub logs every connection with the user, the source IP and the device.
- Tags. Group devices and
filter by tag. - Platforms. Packages for Yocto,
Buildroot,
FreeBSD and
Snap.
Editions
| Edition | Runs on | Adds |
|---|---|---|
| Community | Your servers | This repository, Apache 2.0 |
| Cloud | Ours, shared | MFA, server-side recording, web endpoints, more namespaces, free to start |
| Enterprise | Ours, dedicated to you | MFA, server-side recording, web endpoints, more namespaces, SAML SSO |
See editions for the full comparison.
Contributing
Read CONTRIBUTING.md for the development setup and how to send a change. Ask
questions and share ideas in Discussions;
report bugs in Issues. Security reports go
through SECURITY.md.
License
O.S. Systems develops ShellHub and releases it under the
Apache License 2.0.
Overview (`agent/README.md`)
The ShellHub is a lightweight software component that runs the device and provide communication between the device and ShellHub's server. Its main role is to provide a reserve SSH server always connected to the ShellHub server, allowing SSH connections to be established to the device even when it is behind a firewall or NAT.
Features
Secure Communication: The agent establishes encrypted connections between remote devices and the ShellHub server, ensuring data confidentiality and integrity during communication.
Device Registration: Upon installation on a new device, the agent registers it with the ShellHub server, making it accessible and manageable from the web interface.
Authentication and Access Control: The agent handles user authentication, allowing administrators to enforce access control based on user credentials and defined permissions.
Session Management: The agent manages SSH sessions initiated by users, providing secure remote access to registered devices through the ShellHub web interface.
Heartbeat and Health Checks: The agent sends regular heartbeat signals to the ShellHub server, indicating its status and health, facilitating device availability monitoring.
Getting Started
TODO
Configuration
TODO
Compatibility
The ShellHub Agent is compatible with various Linux distributions. For a list of supported operating systems and versions, please check the compatibility documentation.
TODO:
Support
If you need assistance or have questions about the ShellHub Agent, feel free to reach out to us on our support forum.
Please note that the ShellHub Agent is continually evolving, and some features may have changed or been added since this README was last updated. For the latest information, always refer to the official documentation and project repository.
TODO
Overview (`devscripts/README.md`)
Devscripts
This directory contains scripts which can be useful for development.
They are not intented for regular use by end users.
Requirements
- Bash (although we try to avoid bashism in devscripts, things can happen)
- docker-compose
- HTTPie (https://httpie.org)
- jq (https://stedolan.github.io/jq)
Scripts
add-device: Add a test device to ShellHub (--fakefor API-only,--agentfor real agent container)get-devices: Get devices from APIlint-code: Run code lintertest-unit: Run unit testgen-mock: Generate/update mock objects for testingrun-agent: Runs a native agent, building if necessary, with the provided tag.update-go: Updates the Go version of the project to. comment-scan: Reports the comments the code style does not allow;--diffchecks only what your branch adds, as CI does.
Overview (`gateway/README.md`)
Gateway
The gateway is ShellHub's entry point. It terminates TLS and routes inbound HTTP requests and
WebSocket connections to the server and the console.
Routing is all it does. Authentication, the root aliases and the per-device tunnel subdomains are
resolved by the API itself, which is what lets a Kubernetes deployment expose the server and the
console on an Ingress with this proxy nowhere in the path.
Architecture
The binary is the proxy: it embeds Caddy as a library rather than
supervising a separate process. Caddy registers its features as modules in init(), so what this
proxy can do is the import list in modules.go — which is why adding a DNS provider is a line of
Go here rather than a rebuild step with xcaddy.
At startup the binary builds a Caddyfile from the environment, adapts it to Caddy's JSON
configuration and loads it. Nothing is written to disk and nothing reloads it afterwards: a
different configuration means a different container.
Configuration
Every setting comes from an environment variable, parsed into GatewayConfig in config.go and
laid out by Caddyfile.tmpl.
To read what an environment produces, ask the binary:
docker compose exec gateway /gateway configTLS
With SHELLHUB_AUTO_SSL enabled, Caddy obtains and renews certificates itself, answering the
ACME challenge in memory. The wildcard that serves web endpoints is proven over DNS-01, using the
provider named by SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER.
SHELLHUB_ACME_CA_SERVER chooses which CA to ask. Leave it empty for Let's Encrypt; point it at
their staging directory to rehearse without spending the real rate limit, or at a private CA.
Staging certificates are signed by a root nothing trusts, so they validate the flow and never
serve traffic — and switching back to production means clearing the storage first, or Caddy keeps
using the account and certificates it already has.
The account key and the certificates live in /data, which the image declares as a volume.
Losing it means asking the CA for everything again, and Let's Encrypt caps how often it will
answer, so a deployment that cares should mount a named volume there rather than rely on the
anonymous one Docker creates.
SHELLHUB_TLS_CERT_FILE and SHELLHUB_TLS_KEY_FILE serve a certificate the operator supplies
instead of obtaining one, which is the only way to run HTTPS on a name no public authority will
sign: an internal hostname, or a domain this deployment does not own. Both are paths inside the
gateway container, so whatever holds them has to be mounted there.
They are read only when SHELLHUB_AUTO_SSL is enabled, and they are all or nothing: setting one
without the other stops the gateway from starting, checked even with automatic HTTPS off. A
half-set pair is a typo in every case, and the alternative to refusing it is quietly asking a
public CA for a name the operator meant to serve themselves.
Behind a load balancer
SHELLHUB_PROXY enables the PROXY protocol, and SHELLHUB_PROXY_TRUSTED_IPS names the peers
allowed to use it. This list matters: a peer that may send the preamble is a peer that chooses
the client's address, and that address is what the login lockout and the GeoIP rules are keyed
on. The default trusts anyone, which is what the previous configuration did.
Development
air rebuilds the binary on change, including changes to Caddyfile.tmpl — the template is
compiled in, so a rebuild is what applies it.
Overview (`openapi/README.md`)
How it works?
It is simples; When ShellHub is in developement mode (with the SHELLHUB_ENV
set to development), it will enable, through the gateway, a URL to preview the
OpenAPI in any OpenAPI file after a page reload.
How to use?
Preview
Its usage is simple too, you just need up the shellhub-io/shellhub containers in development
mode and access the URL.
- Access the URL http://localhost/openapi/ to check the preview.
Full spec vs customer docs
There are two builds of every edition spec, from the same source:
- Full spec — the internal source of truth, documenting every route (public,
internal, and admin). It backs the frontend client generation and the
dev-mode response validator. This is what the preview above serves. - Customer docs — the published, customer-facing documentation. A
drop-non-customerdecorator (plugins/customer-filter.js) keeps only the
namespace-scoped, API-key usable surface: it drops/adminand/internal
routes, operations that do not accept an API key, and anything flaggedx-internal: true; it also strips thejwtscheme so the docs show api-key
only. These are the*-customerAPI entries inredocly.yaml, published on
release byopenapi-cd.yml.
Preview the customer docs locally at
http://localhost/openapi/customer.html.
Lint
You can also linter the OpenAPI instances to check if everything is correct. You just need to run
the script called lint inside scripts folder.
Lint the OpenAPI instances.
Usage:
./scripts/lint <instance>
Instances:
community Lint the community instance
cloud Lint the cloud instance
enterprise Lint the enterprise instance
Options:
--help Display this help messageMock
If you need to mock a ShellHub instance, the scripts folder has another one called mock,
what will to make available a full service to you test the API based on instance you chose in
http://localhost/openapi/mock.
Mock the OpenAPI instances.
Usage:
./scripts/mock <instance>
Instances:
community Mock the community instance
cloud Mock the cloud instance
enterprise Mock the enterprise instance
Options:
--help Display this help messageProxy
If you need to check if the API contract is right, you can use the script called proxy,
what will to make available a full service to you send request to the API based on instance
you chose in http://localhost/openapi/proxy.
Proxy the OpenAPI instances.
Usage:
./scripts/proxy <instance>
Instances:
community Proxy the community instance
cloud Proxy the cloud instance
enterprise Proxy the enterprise instance
Options:
--help Display this help messageGenerate
You can generate a TypeScript client from the OpenAPI specification for any of the available instances using the
generate script. This will build the client and place it in the specified output directory.
Generate the TypeScript client.
Usage:
./scripts/generate <output-directory>
Options:
--help Display this help messageFor example, to generate a client in the ../shellhub/ui/src/api/client/ directory, you would run:
./scripts/generate ../shellhub/ui/src/api/client/The TypeScript client will be generated in the specified folder and can be used to interact with the ShellHub API.
Integrations
Community
Today, the Community has a script called openapi to execute commands against the OpenAPI
spec provided by ShellHub gateway and this repository.
Overview (`server/README.md`)
API Service
API Service is responsible for processing API requests, including device admission,
session management, authentication (both users and devices) and bridging WebSocket
connections to SSH.
Overview (`tests/compose/README.md`)
Compose wrapper tests
bats tests for bin/docker-compose. They verify the wrapper's decisions —
which overlays it chains into COMPOSE_FILE, which env files it loads intoCOMPOSE_ENV_FILES, and that the cloud-without-enterprise guard aborts.
The tests do not start containers and do not invoke Compose. They stubdocker in PATH with a script that echoes the wrapper's exported env, then
assert on that output.
Layout
tests/compose/
helpers.bash # capture_with() + require_cloud() + docker stub setup
wrapper.bats # scenarios that don't need cloud/
with_cloud.bats # scenarios that do; skip per test when ../cloud/ is absentEach scenario is declared inline via capture_with VAR1=value VAR2=value ...,
which writes a tmpfile, points the wrapper at it via ENV_OVERRIDE, and
removes it after. No persistent fixtures, no golden files.
Running locally
Install bats-core: https://github.com/bats-core/bats-core
Then from the repo root:
bats tests/compose/ # full suite; with_cloud skips if cloud/ is absent
bats tests/compose/wrapper.bats # subset that doesn't need cloud/CI
checks.yml runs wrapper.bats on every PR that touches the wrapper,
the env files, or the compose YAMLs. The cloud-dependent scenarios are
covered by the cloud repo's CI workflow (it already clones both repos side
by side); wiring is a separate change.
Ui Apps Docs Src Pages Selfhosted Quickstart (`ui/apps/docs/src/pages/selfhosted/quickstart.mdx`)
layout: ../../layouts/DocsLayout.astro title: Self-Hosting Quickstart
import { Code } from "astro:components";
import Callout from "../../components/Callout.astro";
import Shot from "../../components/Shot.astro";
import { releaseTag } from "../../data/version";
Self-Hosting Quickstart
From nothing to a running server.
<Callout variant="info" action={{ href: "https://cloud.shellhub.io", label: "Try it on Cloud" }}>
Trying ShellHub rather than committing to it? Cloud takes a few clicks and we run the server. The
agents and the daily workflow are identical, so nothing you learn there is wasted if you
self-host afterwards.
Requirements
Docker and Docker Compose, and a host with a stable address that your devices can reach. The
composition is four services plus PostgreSQL.
1. Clone
<Code lang="bash" code={$ git clone -b ${releaseTag} https://github.com/shellhub-io/shellhub.git $ cd shellhub} />
Every release is on the releases page if you
need a different one.
2. Generate keys
$ make keygenThis creates three secrets — the API key pair and the SSH host key — which are mounted as Docker
secrets.
Back these up. Regenerating the SSH host key changes the server's host key, and every SSH
client that has connected before will refuse to connect until its known-hosts entry is cleared.
3. Configure
Never edit .env — it carries the defaults and an upgrade will overwrite it. Put your settings in.env.override, which is read last and wins:
$ touch .env.override# .env.override
SHELLHUB_DOMAIN=shellhub.example.com
SHELLHUB_AUTO_SSL=true
SHELLHUB_POSTGRES_PASSWORD=<something-that-is-not-admin>At minimum, set the domain and change the database password. Every variable is in
Environment Variables.
4. Start
$ make startThis runs keygen if you have not, then brings the stack up detached.
5. Create the first account
The stack comes up with no users. The first one is created in the browser:
- Open the server's address. Until the first account exists every path redirects to
/setup. - Enter a name, a username and an email. This user is the instance's superadmin.
- Keep the suggested namespace or click Edit. Lowercase, no dots — it appears in every SSHID.
- Enter a password of at least 5 characters, and click Complete setup.
You are signed in. On Community that namespace is the namespace — Community is
single-namespace, and the server refuses to create another. See Editions.
6. Connect a device
Nothing from here on is specific to self-hosting. The Quickstart installs the
agent, accepts the device, opens a terminal on it and gives your team access to it.
Skip its account section — the setup screen just did that — and use your own address wherever it
says cloud.shellhub.io.
Managing the stack
$ make start # bring it up
$ make stop # stop it
$ make restart # stop then start
$ ./bin/docker-compose ps
$ ./bin/docker-compose logs -fWhat you just deployed
- A gateway, terminating TLS and fronting web traffic
- A server, carrying the API and the SSH endpoint in one process
- The web console
- PostgreSQL, holding everything that matters
- A cache and task queue
The SSH port is published by the server, not the gateway. That matters if you put a load balancer
in front. See Ports and Connectivity.
Before you depend on it
- Decide who owns the server, by name.
- Decide where the database backup goes, and restore it once to prove it works. See
Backup. - Decide whether an outage is acceptable, or whether you need more than one host.
- Read Ports and Connectivity, so the network conversation
happens once rather than during an incident.
Where to go next
- Running on Your Own Infrastructure — What you now own.
- Environment Variables — Everything configurable.
- Backup — Do this before you need it.
Ui Apps Docs Cspell.Json (`ui/apps/docs/cspell.json`)
{
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
"language": "en-US",
"useGitignore": true,
"dictionaryDefinitions": [
{
"name": "shellhub",
"path": "./config/spelling.txt",
"addWords": true
}
],
"dictionaries": ["shellhub"]
}
Ui Apps Docs Package.Json (`ui/apps/docs/package.json`)
{
"name": "@shellhub/docs",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "npm run openapi && astro dev --force",
"build": "npm run openapi && astro build && npm run check:links",
"check:links": "node scripts/check-links.mjs dist",
"openapi": "redocly bundle community-customer@v1 --config ../../../openapi/redocly.yaml -o public/openapi/community-openapi.yaml && redocly bundle cloud-customer@v1 --config ../../../openapi/redocly.yaml -o public/openapi/cloud-openapi.yaml && redocly bundle enterprise-customer@v1 --config ../../../openapi/redocly.yaml -o public/openapi/enterprise-openapi.yaml",
"shots": "./scripts/capture-shots.sh",
"preview": "astro preview",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"sync": "astro sync",
"lint": "npm run lint:code && npm run lint:mdx && npm run lint:spelling && npm run lint:prose",
"lint:code": "eslint "src//*.ts"",
"lint:mdx": "remark "src//.mdx" --frail --quiet",
"lint:prose": "vale src",
"lint:spelling": "cspell "src/**/.mdx" --no-progress"
},
"dependencies": {
"@astrojs/mdx": "^8.1.0",
"@astrojs/react": "^6.0.6",
"@astrojs/sitemap": "^3.7.4",
"@shellhub/design-system": "*",
"asciinema-player": "^3.17.0",
"astro": "^7.4.1"
},
"devDependencies": {
"@redocly/cli": "^2.65.0",
"@types/jsdom": "^30.0.0",
"cspell": "^10.4.0",
"mdx2vast": "^0.5.0",
"remark-cli": "^12.0.1",
"remark-frontmatter": "^5.0.0",
"remark-lint-heading-increment": "^4.0.1",
"remark-lint-mdx-jsx-no-void-children": "^1.0.1",
"remark-lint-mdx-jsx-unique-attribute-name": "^1.0.1",
"remark-lint-no-duplicate-headings-in-section": "^4.0.1",
"remark-lint-no-empty-url": "^4.0.1",
"remark-mdx": "^3.1.1",
"remark-preset-lint-recommended": "^7.0.1",
"yaml": "^2.9.1"
}
}
Ui Apps Docs Tsconfig.Json (`ui/apps/docs/tsconfig.json`)
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"paths": {
"@/": ["./src/"]
}
}
}
Ui Apps Docs Config Spelling (`ui/apps/docs/config/spelling.txt`)
amd64
arm64
armv
ASYNQ
bbclass
bitbake
Buildroot
CGO
cloudapp
Cyberduck
defconfig
deinstall
Dockerfile
extractably
FileZilla
FreeBSD
frontmatter
geoip
GOARCH
GOARM
GOOS
homelab
homelabs
provisioningkey
journalctl
keepalives
LDFLAGS
libxcrypt
MAXMIND
MCP
menuconfig
multistream
mycompany
mynamespace
myuser
OpenAPI
openembedded
packagegroup
packagegroups
paircode
Podman
postcommand
Quadlet
raspberrypi
Redocly
reflash
reflashed
reflashes
rescoped
revdial
rootfs
selfhosted
ShellHub
shellhubio
sshkeys
SSHID
Streamable
styhead
superadmin
sysvinit
systemd
triaging
unparsable
WebSocket
whinlatter
wrynose
yamux
Yocto
Ui Apps Docs Public Robots (`ui/apps/docs/public/robots.txt`)
User-agent: *
Allow: /
Sitemap: https://docs.shellhub.io/sitemap-index.xml
Ui Apps Docs Src Pages Api (`ui/apps/docs/src/pages/api.mdx`)
layout: ../layouts/DocsLayout.astro title: HTTP API
import Callout from "../components/Callout.astro";
HTTP API
ShellHub's HTTP API speaks JSON, and it is the same one the console uses.
API Reference lists every route; this page is how you get in and what a
credential can do once you are.
Authenticating
Two credentials work, and they are for different things.
An API key goes in the X-API-Key header:
$ curl -H "X-API-Key: <your-api-key>" https://cloud.shellhub.io/api/devicesIt carries a namespace and a role. It belongs to the namespace rather than to anybody's session,
and any expiry it has is one you set at creation, not a session lifetime the server imposes.
A bearer token is the JWT a person gets from signing in:
$ curl -H "Authorization: Bearer <token>" https://cloud.shellhub.io/api/devicesThe role behind it is read again on every request rather than trusted from the token, so a role
change takes effect immediately.
Anything unattended should hold an API key. A script driving a bearer token has to handle
sign-in and expiry, and if it does that with a person's credentials it breaks the day that person
leaves.
Creating a key
From the console, under Team → API Keys. A key has:
| Field | Meaning |
|---|---|
| Name | Unique within the namespace. How you identify it later |
| Role | administrator, operator, or observer |
| Expiry | 30, 60 or 90 days, a year, or never |
Name keys for what they do: ci-deploy, inventory-sync, not key1. The key's own routes
address it by name. An access policy and an SSH identity refer to it by the id creation returns,
so renaming a key breaks neither.
A key is scoped to the one namespace it was created in. It reaches nothing in another namespace,
and it is not accepted on the instance administration surface under /admin/api, which takes a
credential of its own.
Give a key the least role that does the job, and set an expiry where you can. Most integrations
only read, so an observer key is enough, and a key with a deadline stops mattering on its own if
somebody forgets it exists. See User Roles.
What a key cannot do
Some routes refuse an API key whatever its role, because they act for a person:
- Creating, listing, updating or deleting API keys, its own included
- Managing provisioning keys
- Generating an invitation link, or accepting an invitation
- Creating, listing or leaving namespaces
- Changing a user's own profile or password, and anything to do with MFA or the vault
- Approving an SSH login or pairing a device
The refusal is structural, not a permission you can grant.
A key over SSH
An API key's token never opens an SSH session, and an SSH identity never calls the API. See
SSH Access for how the key connects over SSH, enrolling its public key and
writing the policy that grants it.
Rotating a key
Create the new key, move consumers to it, then delete the old one. Name them so you can tell them
apart mid-rotation, ci-deploy-2026 rather than editing in place. Deleting takes effect
immediately, and anything still using the old key starts getting 401.
When a credential is refused
A protected route answers 401 whether the key does not exist, has expired, or was never valid,
and does not tell an unauthenticated caller which of those it was. A 401 means "this credential
is not usable", not "this credential does not exist".
Paginating a response
List routes take page and per_page, and answer the total in the X-Total-Count header:
$ curl -H "X-API-Key: $KEY" \
"https://cloud.shellhub.io/api/devices?page=1&per_page=100"The specification
API Reference renders it in the browser. The files are the same thing, to diff
or to hand to a generator:
| Edition | Specification |
|---|---|
| Community | community-openapi.yaml |
| Cloud | cloud-openapi.yaml |
| Enterprise | enterprise-openapi.yaml |
Take the one matching your edition. Editions expose different routes: a Community server has
no MFA, firewall rule, or web endpoint routes at all, so a client generated from the wrong file
produces methods that answer 404. That is not a permission problem, because the route is not there. See
Editions.
Where to go next
- API Reference — Every route, per edition.
- MCP Server — The same API, for AI agents.
- SSH Access — The key's way in to devices.
Ui Apps Docs Src Pages Connect (`ui/apps/docs/src/pages/connect.mdx`)
layout: ../layouts/DocsLayout.astro title: Connecting to a Device
import Shot from '../components/Shot.astro';
Connecting to a Device
Two ways in. The browser needs nothing installed; a native client works with no plugin.
From the browser
Open the device in the console and click the terminal button.
<Shot id="device-connect" route="/devices" of={{ role: "dialog" }} before={[{ click: { role: "button", name: "Connect" } }]} alt="The Connect panel for a device, with the ways to open a session on it" />
The web terminal runs over a WebSocket to the server and out through the agent's existing
connection. Nothing is installed and nothing is configured.
The browser generates its own key the first time and holds it non-extractably — it cannot be
exported, works only in that browser, and disappears with the browser's site data. That key
becomes an SSH identity like any other, so a session opened this way is attributed to you the
same as one from your laptop. See SSH Identities.
From your own SSH client
Use the device's SSHID as the username:
$ ssh <username>@<namespace>.<device>@<server>For example:
$ ssh [email protected]@cloud.shellhub.io| Part | Meaning |
|---|---|
root |
The unix user on the device |
mycompany |
Your namespace |
raspberrypi |
The device's name in that namespace |
cloud.shellhub.io |
The ShellHub server |
The device details page shows the whole string with a copy button.
Because the SSHID is just an SSH username, everything that speaks SSH works: OpenSSH, PuTTY,scp, sftp, Ansible. See SSH Client Configuration for making it less
tedious to type.
The first connection from a native client
The first time you offer a key ShellHub has not seen, the login does not fail — it parks, and
asks a member to approve it in the console. Approving binds that key to your account.
Every connection after that goes straight through. No approval, no wrapper, no special client.
See Login Approvals.
Authentication
Which unix user you connect as is the username part of the SSHID, and whether you may is
decided by access policies — not by anything on the device. A policy can grant deploy and notroot on the same machine.
The device still has to accept the credential for that unix user, exactly as it would for a
direct SSH connection. ShellHub decides whether the connection is allowed; the device decides
whether the login succeeds.
What else you can do over the same connection
- File Transfer —
scpandsftp - Port Forwarding — reach a service on the device or its network
- Web Endpoints — publish an HTTP service at a URL
Where to go next
- SSH Client Configuration — Aliases and config entries.
- Connection Denied — When it is refused.
Ui Apps Docs Src Pages Get Started (`ui/apps/docs/src/pages/get-started.mdx`)
layout: ../layouts/DocsLayout.astro title: Quickstart
import Callout from "../components/Callout.astro";
import Shot from '../components/Shot.astro';
Quickstart
By the end of this page you will have a device you can open a shell on from anywhere, in the
browser or in your own SSH client. Nothing on the device listens for inbound connections.
Create your account
- Sign up with a name, a username, an email and a password,
and accept the privacy policy. - Click the link in the confirmation email. Until you do, signing in returns you to the
confirmation screen, which can send the email again. - Sign in.
The console opens on Get your first shell, a page that takes you through the rest of this
guide one step at a time: your namespace, the agent, the pairing and the first shell. On Cloud it
first asks a few questions about you. Leave it before pairing and it picks up where you stopped.
A namespace is what devices join. Its name is part of every address you use to reach a device, so
keep it short: 3 to 30 characters, lowercase letters, numbers and hyphens.
Connect your device
A device is any machine running the agent. The agent opens one outbound connection to the server
and keeps it, which is what lets you reach the device through a NAT you do not control.
Install the agent
The Install the agent step shows the command. Run it on the machine you want to reach:
$ curl -sSf https://cloud.shellhub.io/install.sh | shThe installer picks a method on its own: Docker, then Podman, then Snap, then WSL, then a native
binary under systemd. To choose yourself, see Choosing a Method.
$ curl -sSf https://cloud.shellhub.io/install.sh | INSTALL_METHOD=standalone shPair it
Given no credentials, the installer runs shellhub-agent login, which asks the server for a code,
prints it, and waits:
To accept this device, open the ShellHub console and enter this code:
4KQP-7M2X
Enter it at https://cloud.shellhub.io/accept-device, or open the link:
https://cloud.shellhub.io/accept-device?code=4KQP7M2X
Waiting for acceptance... (code expires in 10 minutes)Where xdg-open is available, it also opens the link in the browser for you.
- Type the code into the Pairing code field, or paste the whole link, and continue.
- Check the device it found against the machine you are expecting: hostname, operating system,
MAC address, agent version and the code itself. - Click Pair into your namespace. If it is not your machine, click Not this one.
Opening the link instead shows a page of its own with the code, hostname, operating system and MAC
address. Pick the namespace there and click Accept device:
<Shot id="accept-device" route="/accept-device?code=:paircode" of={{ testId: "auth-card" }} frame="band" edition="enterprise" viewport={{ width: 1440, height: 1200 }} alt="The accept page for a paired device, showing what it reported about itself and the namespace it will join" />
The agent stops waiting, joins the namespace and connects. There is no second command to run.
Open a shell in the browser
- Click Open terminal in the last step of the page. If you have left the page since, open
the device from Devices and click Connect. - Check the login. It starts as
root, and you can type any other. - Click Open in browser.
<Shot id="device-connect" route="/devices" of={{ role: "dialog" }} before={[{ click: { role: "button", name: "Connect" } }]} alt="The Connect panel for a device, with the login, the identity it connects as, the link to an external terminal and the button that opens the shell in the browser" />
The first time, the console asks to register the browser. It generates an SSH key that never
leaves the browser and adds it to your SSH Identities under the name you give it. After that,
the browser connects without asking, and you can revoke the key there whenever you like.
Nothing else had to be set up. Your namespace starts with one access policy, Owner access,
which lets you in as any login on every device.
Open it in your own terminal
The same panel has Open in external terminal. It is an ssh:// link, so your system hands it
to the SSH client it has registered for that kind of link. The command behind it is the device's
SSHID, which you can also run by hand:
$ ssh <login>@<namespace>.<device>@cloud.shellhub.ioThe namespace and the device are part of the hostname, so an ordinary client reaches the right
machine through the one server that is listening.
The first connection with a key stops rather than opening. The server has never seen the key and
has no signed-in session to tell it whose key it is, so it prints a link and waits:
ShellHub doesn't know this SSH key yet.
Open the link to add it to your identities:
https://cloud.shellhub.io/ssh-identities/new/2HP6S9DZ
Security code: 2HP6 S9DZ
Key: SHA256:vXV24Lb0Uiy6jRbJ2ns7uFAgrfzS6qT1KPvu6MwKqCQ
The console gives you a confirmation code once you add it.
Confirmation code:- Open the link in the browser you are signed in to, open Login details, check that its code
matches the security code in your terminal, and click Add key. - Type the confirmation code the console shows at the
Confirmation code:prompt.
<Shot id="login-approval" route="/ssh-identities/new/:approval" of={{ role: "dialog" }} alt="The approval screen for a key the namespace has not seen, showing the key, the account it would be added to and the namespace" />
The shell opens, and the key is now one of your SSH identities. Every later connection with it
goes straight through with stock OpenSSH. The link lasts 90 seconds. If it runs out, connect again
for a new one.
Where to go next
- More devices: open Devices → Add Device for the install command, and use
Provisioning Keys to enroll many without pairing each one. - Your team: invite members and grant them devices with
Access Policies. A new member reaches nothing until a
policy says so. - More than a shell: File Transfer and
Port Forwarding - Automation: API keys and their SSH access
Ui Apps Docs Src Pages Introduction (`ui/apps/docs/src/pages/introduction.mdx`)
layout: ../layouts/DocsLayout.astro title: Introduction to ShellHub
import Callout from "../components/Callout.astro";
Introduction to ShellHub
ShellHub is access infrastructure for connected devices. Reach any device from any network, and
everything you run on it.
You install a small agent on a device, the agent dials out to the ShellHub server, and from then
on that device — and the things running on it — are available to you through a single address.
Access is granted to an identity, a person or an automation, rather than to a credential
anyone could hold.
There is no inbound port to open and no public IP to arrange. The device makes the connection, so
it works from a customer's network, a factory floor, or a cellular link just as well as from a
rack you own. Once it is connected you can open a terminal, move files, forward a port, step into
a container, or publish an HTTP service it is running.
It takes a single command on the device to get there.
Guides
- Quickstart — Reach your first device, and let your team reach it.
- Self-Host ShellHub — Run your own server with Docker Compose.
- How Authorization Works — Decide who may reach which device, as which login.
- Add People to Your Namespace — Invite members and give them roles.
- Connect to a Device — The web terminal, native SSH, and the SSHID.
- Port Forwarding — Reach a service on the device or on its network.
- Provisioning Keys — Enroll devices at scale without a manual step.
- MCP Server — Let an AI assistant operate your fleet.
About ShellHub
- How ShellHub Works — Concepts, architecture, and the path a connection takes.
- ShellHub vs. Jump Hosts and VPNs — How this differs from what you have now.
- Self-Hosted vs. Cloud — Compare the deployments and decide which fits.
- Editions — What Community, Cloud, and Enterprise each include.
Ui Apps Docs Config Vale ShellHub Terms.Yml (`ui/apps/docs/config/vale/ShellHub/Terms.yml`)
extends: substitution
message: "Use '%s' instead of '%s'."
level: error
ignorecase: false
swap:
Docker[ -]?compose: Docker Compose
Freebsd: FreeBSD
Github: GitHub
Javascript: JavaScript
Nodejs: Node.js
Openapi: OpenAPI
Shellhub: ShellHub
Typescript: TypeScript
Websocket: WebSocket
Ui Apps Docs Src Pages About Editions (`ui/apps/docs/src/pages/about/editions.mdx`)
layout: ../../layouts/DocsLayout.astro title: Editions
Editions
ShellHub comes in three editions. They are built from the same core, and the differences are
narrower than you might expect: the whole access model, every way of reaching a device, and the
API are in all three.
Who runs it
| Edition | Runs on | Operated by |
|---|---|---|
| Community | Your infrastructure | You |
| Cloud | Ours, shared | ShellHub |
| Enterprise | Ours, dedicated to you | ShellHub |
Community is what you install and run. Cloud and Enterprise both run on ours: Cloud shared with
other customers, Enterprise on an instance of its own.
Deciding whether to run it yourself is Self-Hosted vs. Cloud.
What every edition has
Everything below is in Community, unmodified, with no license and no limit:
- The web terminal and native SSH from any client
- File transfer over SCP and SFTP
- Port forwarding
- Container access
- The full identity model: SSH identities, access policies, SSH access for API keys, login approvals
- Provisioning keys, including webhook and allowlist enrollment
- Tags, roles and members
- API keys and the HTTP API
- The MCP server
- Browser-local session recording
That last one is worth stating plainly because it is commonly misread: Community does record
sessions. The recording is made in the browser that opened the session and stays there. What
Cloud and Enterprise add is recording on the server, stored centrally and independent of
whoever's browser it was.
What Cloud and Enterprise add
| Capability | Community | Cloud | Enterprise |
|---|---|---|---|
| Multi-factor authentication | — | ✅ | ✅ |
| Server-side session recording | — | ✅ | ✅ |
| Web endpoints | — | ✅ | ✅ |
| Firewall rules (legacy) | — | ✅ | ✅ |
| Multiple namespaces | — | ✅ | ✅ |
| SAML single sign-on | — | — | ✅ |
| Password recovery, email confirmation | — | ✅ | — |
| Support | Community | Best effort | Dedicated |
| Device limit | Not enforced | 3 on the free plan | None |
Two rows deserve a sentence.
SAML is Enterprise only. It is not in Cloud. This is the one place where Cloud is not a
superset, and if SSO is the reason you are reading this page, Enterprise is the answer.
Password recovery is Cloud only. It depends on mail and Redis, which the Enterprise
deployment does not carry. On Enterprise, an administrator resets a password.
Can one Community server serve two customers?
No, and it is the boundary worth stating plainly. A Community instance is a single namespace,
created at setup, and the server will not create another.
Inside it you can segment: tags group devices, and an
access policy can grant one team a set of devices and
nothing else. But that is segmentation within one namespace, not isolated tenants — everyone who
is a member sees the same device list, and a policy governs who may connect, not who may look.
Two ways out. Cloud and Enterprise both hold as many namespaces as you create, one shared with
other customers and one on an instance of its own. Or run a Community deployment per customer,
which is the same isolation bought with your own operations instead.
Where to go next
- Self-Hosted vs. Cloud — The deployment question.
- Self-Hosting Quickstart — Run Community yourself.
Ui Apps Docs Src Pages About Faq (`ui/apps/docs/src/pages/about/faq.mdx`)
layout: ../../layouts/DocsLayout.astro title: FAQ
Frequently Asked Questions
Does the device need a public IP?
No. The agent connects outbound and holds that connection open. Nothing about the device's
network has to change. See Ports and Connectivity.
Do I have to open a port on the device?
No. The agent never listens.
Does the server need a public address?
Yes. The asymmetry is the point: one reachable server, any number of unreachable devices. If you
are self-hosting, the server is the thing that has to be reachable.
Which operating systems does the agent run on?
Linux, on amd64, armv6, armv7, arm64 and 386. FreeBSD is supported through the
ShellHub ports tree rather than the installer.
Do I need a special SSH client?
No. The SSHID is encoded as an ordinary SSH username, so OpenSSH, PuTTY, scp, sftp and
Ansible all work with nothing installed. See SSH Client Configuration.
Can I use ShellHub without installing anything on my own machine?
Yes — the web terminal runs in the browser. See Connecting to a Device.
Is ShellHub a VPN?
No. Nothing joins a network. A device is a destination, not a participant, and devices cannot
reach each other through ShellHub. See
ShellHub vs. Jump Hosts and VPNs.
Can two devices talk to each other through ShellHub?
No. There is no path between two devices.
What happens when the device loses its connection?
The agent retries on its own, waiting a random 5 to 65 seconds before each attempt. The device shows as offline
until it reconnects, and reconnects on its own once the network is back.
What happens if I delete the agent's private key?
The device comes back as a new one. The key at SHELLHUB_PRIVATE_KEY is what identifies the
device — keep it on persistent storage.
Is session recording available in Community?
Yes, recorded in the browser that opened the session and kept there. Recording on the server,
stored centrally, is Cloud and Enterprise. See Sessions.
Can I have more than one namespace?
Not on Community, which is single-namespace by design. Cloud and Enterprise allow many. See
Editions.
Is SAML SSO available on Cloud?
No — SAML is Enterprise only. It is the one capability Cloud does not include.
How many devices can I connect?
Self-hosted Community has no limit. The Cloud free plan is limited to 3 devices.
Does revoking someone's access kill their open session?
No. Authorization is evaluated when the connection is made. A session already open continues
until it is closed. See How Authorization Works.
Why was my connection refused?
There are four distinct reasons and they point at different fixes. See
Connection Denied.
Can an API key do everything a user can?
No. Routes that act for a person refuse an API key whatever its role. Managing keys, creating a
namespace, and anything to do with MFA are all refused; see
What a key cannot do for the full list.
Which database does ShellHub use?
PostgreSQL. Earlier releases used MongoDB; that is gone.
Where do I report a bug?
GitHub Issues. See
Getting Support.
Ui Apps Docs Src Pages About How Shellhub Works (`ui/apps/docs/src/pages/about/how-shellhub-works.mdx`)
layout: ../../layouts/DocsLayout.astro title: How ShellHub Works
import Callout from "../../components/Callout.astro";
import DiagramReverseConnectivity from '../../components/DiagramReverseConnectivity.astro';
import DiagramConnectionFlow from '../../components/DiagramConnectionFlow.astro';
How ShellHub Works
ShellHub has two moving parts. A server, which every device and every user talks to, and an
agent, which runs on each device you want to reach. Everything else in this documentation is
a consequence of how those two behave.
The connection is made by the device
Reaching a machine over the internet normally means the machine has to be reachable: a public
address, an open inbound port, or a tunnel you built in advance. That works when you own the
network. It stops working the moment the device sits behind carrier-grade NAT, on a customer's
LAN, or on a mobile link.
ShellHub turns the direction around. The agent opens the connection outbound, to the server, and
holds it open. When you want to reach the device, your session is routed through the connection
that already exists.
The practical consequence is that the device needs exactly one thing from its network: the
ability to reach the ShellHub server's HTTP(S) address. No inbound port, no public IP, no static
address, and no change to the network it happens to be plugged into. If the device can load a web
page, ShellHub can reach it.
The server
The server is where every agent's connection terminates and where every user session starts. It
registers devices, tracks which ones are online, works out who is connecting, decides whether
that person is allowed, bridges the session down the agent's connection, and records it when
recording is switched on.
You can run it yourself or use the managed ShellHub Cloud. Which
components it is made of and how to operate them belong to
Self-Hosted — from the outside it is one address.
The agent
The agent is a small daemon on the device. It does three things: it connects outbound to the
server and keeps that connection alive, it registers the device and reports its hostname and
identity, and it serves whatever you ask for over the connection — a shell, a file transfer, a
forwarded port, a container session.
It is the only piece that runs outside the server. It never listens for inbound connections.
A few of its behaviors are worth knowing because they show up in troubleshooting. It sends a
keepalive every 30 seconds by default (SHELLHUB_KEEPALIVE_INTERVAL). It re-authorizes with the
server about every 10 minutes, at a random point between 8 and 12 minutes, so devices that connected
together do not all re-authorize at once. When the connection drops
it reconnects on its own, waiting a random 5 to 65 seconds before each attempt and retrying
indefinitely, so a fleet losing its server does not come back in lockstep. It generates a private key on
first run if one is not already at SHELLHUB_PRIVATE_KEY, and that key is what identifies the
device from then on — delete it and the device comes back as a new one.
See Agent Configuration for the full set.
Namespaces
A namespace is the workspace everything else lives inside: devices, members, policies, settings.
It has a name you can change, and a Tenant ID — an immutable UUID that devices, API keys and
sessions actually reference.
A namespace is either legacy or new, which decides how connections are authorized and how
devices enroll. Namespaces created today are new; a legacy one predates identity-based access and
can be upgraded once. See How Authorization Works.
SSHID
One server address fronts every device, so the address you type has to say which device you mean.
ShellHub encodes that into the SSH username, the same way web servers used the Host header to
put many sites on one IP:
[email protected]@serverFor example:
$ ssh [email protected]@cloud.shellhub.io| Part | Value | Meaning |
|---|---|---|
root |
SSH username | The unix user on the device |
mycompany |
Namespace | Which workspace |
raspberrypi |
Device hostname | Which device in that namespace |
cloud.shellhub.io |
Server | The ShellHub server address |
The server splits the string on the first @ to get the username, then splits what remains on
the first . to get the namespace and hostname. Because the whole thing is just an SSH
username, every client understands it with no plugin: OpenSSH, PuTTY, scp, sftp, Ansible.
The path a connection takes
- You run
ssh [email protected]@cloud.shellhub.io. - The connection reaches the ShellHub server.
- The server parses the SSHID and resolves the namespace and the device.
- It works out who you are — from the key you presented, a password, or an approval you
confirm in the browser. - It authorizes the connection against the namespace's access policies.
- It finds the connection the agent already has open.
- Your terminal I/O is bridged down that connection to the agent.
- The agent opens a shell on the device and joins it to the session.
Step 5 happens before the server mints anything for the agent, so a refused connection never
reaches the device at all.
The web terminal takes a different route in — your browser opens a WebSocket to the server
instead of an SSH connection — and exactly the same route out.
Where to go next
- Ports and Connectivity — What has to be reachable, on both ends.
- Self-Hosted vs. Cloud — Which deployment fits you.
- Quickstart — Put the agent on a device.
Ui Apps Docs Src Pages About Ports And Connectivity (`ui/apps/docs/src/pages/about/ports-and-connectivity.mdx`)
layout: ../../layouts/DocsLayout.astro title: Ports and Connectivity
import Callout from "../../components/Callout.astro";
Ports and Connectivity
This page exists so you can answer the network team's questions in one go.
On the device
Outbound access to the ShellHub server's HTTP(S) address. That is the whole requirement.
The agent dials the address you gave it in SHELLHUB_SERVER_ADDRESS and holds that connection
open. It does not listen. Nothing has to be forwarded to it, published for it, or arranged in
advance.
That means all of the following are fine, with no configuration:
- Behind NAT, including carrier-grade NAT with no public address at all
- Behind a corporate firewall that only permits outbound web traffic
- Behind an HTTP proxy
- On a mobile or satellite link
- On a network you do not administer, such as a customer's
If a browser on that device could load a web page from your server, the agent can connect.
On the server
| Port | Variable | Default | What for |
|---|---|---|---|
| HTTP | SHELLHUB_HTTP_PORT |
80 |
Web console, API, and the agents' connections |
| HTTPS | SHELLHUB_HTTPS_PORT |
443 |
The same, with TLS. Only bound when SHELLHUB_AUTO_SSL=true |
| SSH | SHELLHUB_SSH_PORT |
22 |
Native SSH clients |
Two details that surprise people:
The HTTPS port is not bound unless auto-SSL is on. The port mapping lives in an overlay that
is only applied when SHELLHUB_AUTO_SSL=true. If you terminate TLS somewhere else, you are
publishing HTTP and your proxy is doing the rest.
SSH does not go through the gateway. The SSH port is published by the server process
directly. The gateway handles web traffic only. If you are putting a load balancer in front of
ShellHub, the SSH port needs its own path — it is not HTTP and it will not survive an HTTP proxy.
Behind a load balancer
A Layer 4 load balancer can pass the client address in a PROXY protocol header. The gateway and
the SSH port read that header under separate settings.
For web traffic, set SHELLHUB_PROXY=true on the gateway. SHELLHUB_PROXY_TRUSTED_IPS decides
which peers are allowed to declare a client address. It defaults to 0.0.0.0/0 ::/0, which
trusts everyone. That is fine when nothing but your balancer can reach the port, and wrong if
that is not true.
For SSH, set SHELLHUB_SSH_PROXY_TRUSTED_IPS to the space-separated ranges the header arrives
from, such as your balancer's or your ingress pods'. The SSH port always trusts loopback and
nothing else by default. It closes a connection whose header comes from any other peer, and logs
a warning with that peer's address. Under Docker Compose, do not list the Docker bridge range:
every external client appears to come from it.
This matters beyond cosmetics: access policy source IPs, firewall rules and the session audit trail
read the client address of SSH and web terminal sessions, and password lockout reads it at web
sign-in. Get it wrong and all of them operate on the balancer's address.
If you terminate TLS at a reverse proxy that speaks HTTP — nginx, Caddy, Traefik — you do not
need SHELLHUB_PROXY.
TLS
Three ways to get a certificate:
Automatic. Set SHELLHUB_AUTO_SSL=true and SHELLHUB_DOMAIN to a name that resolves to the
server. Certificates are obtained from Let's Encrypt and renewed for you. This requires the
server to be reachable for the challenge.
Your own certificate. Set SHELLHUB_TLS_CERT_FILE and SHELLHUB_TLS_KEY_FILE to paths
inside the gateway container. Set both — with only one, the gateway will not start.
Terminate elsewhere. Leave auto-SSL off, publish HTTP, and let your existing proxy or load
balancer hold the certificate.
SHELLHUB_ACME_CA_SERVER points at a different ACME directory if you are using an internal CA.
Empty means Let's Encrypt production.
Where to go next
- Environment Variables — Every variable, with defaults.
- Self-Hosting Quickstart — Bring a server up.
- Device Not Appearing — When the agent cannot get through.
Ui Apps Docs Src Pages About Self Hosted Vs Cloud (`ui/apps/docs/src/pages/about/self-hosted-vs-cloud.mdx`)
layout: ../../layouts/DocsLayout.astro title: Self-Hosted vs. Cloud
import EditionBanner from "../../components/EditionBanner.astro";
Self-Hosted vs. Cloud-Hosted ShellHub
ShellHub can be used in two ways: self-hosted and cloud-hosted. The self-hosted version runs on
your own servers, from the same source that is on GitHub. The cloud-hosted version is hosted by
us at cloud.shellhub.io.
This page is the short answer to what differs.
What self-hosting actually means
We built ShellHub to remove work, not add it. The whole point is that an admin stops arranging
public addresses, opening ports, maintaining a jump host and chasing SSH keys across machines,
and instead installs one agent and gets on with the job. As is usually the case with things that
feel simple from the outside, a fair amount happens behind them.
Self-hosting means you own all of it. A ShellHub server is not one process: there is a gateway
terminating TLS, a server process carrying both the API and the SSH endpoint, a web console, a
PostgreSQL database holding everything that matters, and a cache and task queue. You are
responsible for keeping those running, for the certificates, for the backups, and for being the
person who gets paged when a fleet cannot connect.
Two things make that easier than it sounds. It is a small stack — four services plus the
database — brought up with a single make start, and every variable it reads is documented in
Environment Variables.
Two things make it harder. The server has to be reachable, unlike your devices, so you need
somewhere with a stable public address. And you inherit the database: PostgreSQL holds your
devices, identities and policies, and nothing backs it up for you.
If you are running a homelab, an air-gapped network, or you have a compliance requirement that
rules out someone else holding your data, self-hosting is the right answer and the docs will get
you there. If you want to reach some devices and would rather not think about any of the above,
Cloud is the right answer and there is no shame in it.
Read How ShellHub Works before deciding — it is a short page and it
explains what you would be operating.
Installation and Maintenance
Self-hosted, you install and maintain every component, and you back up and secure the data. The
Self-Hosting Quickstart gets a working server up in a few commands; the
ongoing part is yours.
Cloud-hosted, you install the agent on your devices and log in. That is the entire operational
burden. Updates, certificates, database, and availability are ours.
In both cases the agent side is identical — the same binary, the same install command, the same
configuration.
Features
The two share the whole core: the web terminal, native SSH from any client, file transfer, port
forwarding, container access, tags, roles, API keys, the MCP server, and the full identity-based
access model with SSH identities, access policies and SSH access for API keys. None of that is held
back.
What Cloud adds is the set of things that either need infrastructure we run or only make sense
for an organization paying for a service:
- Multi-factor authentication on user login.
- Server-side session recording, stored centrally rather than in
the browser that opened the session. - Web endpoints, publishing an HTTP service from a device at a URL.
- Firewall rules, the legacy restriction layer.
- Multiple namespaces in one account. Community is limited to one.
- Password recovery and email confirmation, which need mail infrastructure.
The Cloud free plan is limited to 3 devices. Self-hosted Community has no device limit.
Where Your Data Lives
Self-hosted, everything is in your PostgreSQL instance. Nothing leaves your infrastructure.
Cloud-hosted, it is on ours.
This is usually the deciding factor for regulated environments, and it is a legitimate one.
Availability
Cloud runs with the redundancy you would expect from a service. Self-hosted, availability is
whatever you build: a single Docker Compose deployment on one host is a single point of failure,
and if that host is down, your fleet is unreachable until it comes back.
Devices survive the outage — the agent retries with backoff and reconnects on its own — but
nobody is opening a terminal in the meantime.
Quick Comparison
Self-hosting means running Community, so that is the column.
| Aspect | Self-Hosted (Community) | Cloud-Hosted |
|---|---|---|
| Setup time | A few commands, plus a host and a domain | Instant |
| Infrastructure | You manage | We manage |
| Public address | You provide one for the server | Provided |
| TLS certificates | Automatic via Let's Encrypt, or bring your own | Managed |
| Database | Your PostgreSQL, your backups | Managed |
| Namespaces | One | Many |
| Device limit | None | 3 on the free plan |
| MFA, web endpoints, server-side recording | Not in Community | Included |
| SAML SSO | Not in Community | Enterprise |
| Upgrades | You choose when | Continuous |
| Cost | Your infrastructure | Subscription |
| Best for | Homelabs, air-gapped networks, data residency and compliance requirements | Teams who want to reach devices without operating a server |
Get Started
- Self-Hosting Quickstart — Bring a server up.
- Editions — What each edition includes, feature by feature.
- Star the project on GitHub.
Ui Apps Docs Src Pages About Shellhub Vs Jump Hosts (`ui/apps/docs/src/pages/about/shellhub-vs-jump-hosts.mdx`)
layout: ../../layouts/DocsLayout.astro title: ShellHub vs. Jump Hosts and VPNs
ShellHub vs. Jump Hosts and VPNs
If you already reach your devices somehow, it is worth being precise about what changes. This
page compares ShellHub to the three things it usually replaces.
The jump host
A bastion is a machine with a public address that you SSH into first, and from there you SSH
onward to whatever you actually wanted. It is the oldest answer and it still works.
What it costs you: the bastion is a public inbound surface you have to keep patched, the target
machines still have to be reachable from the bastion, so you are still arranging network for
them, and access is expressed as SSH keys sitting in authorized_keys files across the fleet. To
remove one person you go find their key everywhere it landed. The audit trail says which key was
used, not who used it.
What ShellHub does differently: the target devices dial out, so there is nothing to arrange for
them and no second hop. Access is a policy naming a person, evaluated centrally at connection
time. Removing someone is one revocation, in one place.
What a bastion still does better: it is one box and it is yours. If your requirement is "as few
moving parts as possible and nothing phones home", a bastion is honestly hard to beat.
The VPN
A VPN puts your machine and the target on the same network, and then you connect as if you were
local.
What it costs you: the VPN concentrator is infrastructure to run and a public inbound surface of
its own. Client configuration drifts, and it breaks when networks change. Most importantly, being
on the VPN usually means being able to reach a lot more than the one machine you needed — the
grant is network-shaped, not access-shaped.
What ShellHub does differently: nothing joins a network. A device is a destination, not a
participant, so reaching one device grants nothing about any other. Access is per device and per
unix login, not per subnet.
What a VPN still does better: arbitrary protocols to arbitrary hosts, including things ShellHub
does not carry, and access to devices that cannot run an agent at all.
The mesh network
A mesh — WireGuard-based overlays and similar — gives every machine an address on a private
network and lets them talk to each other directly, usually peer to peer.
What it costs you: every peer is on the overlay, so a compromised laptop is adjacent to
everything else on it. Containing that is what posture checks and network policies exist for, and
those are real work to configure and keep correct.
What ShellHub does differently: there is no overlay and no peer-to-peer. Devices connect to the
server and nowhere else, so devices cannot reach each other through ShellHub at all. There is no
lateral movement to contain because there is no path between two devices to move along.
What a mesh still does better: machine-to-machine traffic, low latency by going direct, and
anything that genuinely needs an IP-level network rather than access to a specific device.
Choosing
| Jump host | VPN | Mesh | ShellHub | |
|---|---|---|---|---|
| Inbound port on targets | Needed | Needed on the concentrator | Not needed | Not needed |
| Devices can reach each other | Via the bastion | Yes, usually broadly | Yes, by design | No |
| Access granted as | A key in a file | Network membership | Network policy | A policy naming a person |
| Removing one person | Find every key | Revoke the profile | Revoke the peer | One revocation |
| Audit says | Which key | Which client | Which peer | Which person |
| Arbitrary protocols | Via forwarding | Yes | Yes | Shell, files, ports, HTTP |
| Best for | One team, few hosts, minimal infrastructure | Full network access to internal systems | Machines that must talk to each other | Reaching devices you do not own the network for |
They are not exclusive. Plenty of people run a mesh for their internal systems and ShellHub for
the fleet in the field, because the two problems are genuinely different.
Where to go next
- How ShellHub Works — The mechanism behind the middle column.
- How Authorization Works — What replaces the keys in files.
Ui Apps Docs Src Pages Api Ansible (`ui/apps/docs/src/pages/api/ansible.mdx`)
layout: ../../layouts/DocsLayout.astro title: Ansible
import Callout from "../../components/Callout.astro";
Ansible
ShellHub ships an Ansible collection, shellhub.core, whose dynamic inventory plugin lists the
devices in a namespace and connects to each one through the gateway. Ansible treats them like any
other SSH host.
Installing the collection
$ git clone https://github.com/shellhub-io/ansible-collection.git
$ ansible-galaxy collection install ./ansible-collectionThe inventory
The source file's name must end in shellhub.yml, which is how Ansible knows to hand it to the
plugin:
# inventory.shellhub.yml
plugin: shellhub.core.devices
api_url: https://cloud.shellhub.io
ssh_user: root
ssh_key: ~/.ssh/id_ed25519
keyed_groups:
- key: shellhub_tags
prefix: tagThe plugin authenticates with a namespace API key, which also tells the server
which namespace to list. Pass it in the environment rather than the file:
$ export SHELLHUB_API_KEY=...
$ ansible-inventory -i inventory.shellhub.yml --graph
$ ansible -i inventory.shellhub.yml tag_production -m pingEvery option can come from the file or the environment:
| Option | Environment | Default |
|---|---|---|
api_url |
SHELLHUB_API_URL |
https://cloud.shellhub.io |
api_key |
SHELLHUB_API_KEY |
(required) |
ssh_host |
SHELLHUB_SSH_HOST |
the host of api_url |
ssh_port |
SHELLHUB_SSH_PORT |
22 |
ssh_user |
SHELLHUB_SSH_USER |
root |
ssh_key |
SHELLHUB_SSH_KEY |
(none) |
What it puts in the inventory
Only accepted devices, paged through the API. For each one:
| Variable | |
|---|---|
ansible_host |
The gateway. The same for every device |
ansible_user |
The device's SSHID, <ssh_user>@<namespace>.<device> |
shellhub_tags |
The device's tags, which is what keyed_groups groups on |
shellhub_online |
Whether it is connected |
shellhub_namespace |
The namespace the key resolved to |
ansible_host being identical for every host is the whole point: one inventory reaches machines
on networks that have no route to each other, and the device is identified entirely byansible_user.
Because tags arrive as host facts, a device tagged production lands in the tag_production
group with the keyed_groups above — and a device tagged after the fact joins on the next run,
with no inventory to edit. See Tags.
Authentication
Ansible presents an SSH key like any client, and that key must be an
SSH identity.
Add the key ahead of time. If it is not already an identity, the first connection parks
waiting for somebody to approve it in the console — which for a playbook run means it hangs.
Nobody is watching for that prompt at 3am.
For scheduled runs, connect as an API key with its own SSH access rather than as
a person. The run is then attributed to ansible-runner in the session list, and it does not
break when somebody leaves.
Authorization
Every host in the play is authorized separately, as its own connection. A play across fifty
devices is fifty authorizations.
So the policy needs to cover the whole set — usually a policy granting the API key a tag,
with the unix login the playbook uses:
| Name | Effect | Subject | Devices | Logins |
|---|---|---|---|---|
| Ansible to fleet | allow | API key ansible-runner |
tag production |
root |
A device the policy misses fails in the play as unreachable, which is the correct behavior and
looks like a network problem. If a subset of hosts fails, check the policy before the network.
Performance
Every connection goes through the ShellHub server, so a play against a large fleet has a different
shape to one on a LAN. Pipelining helps by reducing round trips:
[ssh_connection]
pipelining = TrueControlMaster also works — see SSH Client Configuration — but note that
a persisted connection is authorized once, when it opens.
Where to go next
- SSH Access — The right principal for a runner.
- SSH Client Configuration — Aliases and multiplexing.
Ui Apps Docs Src Pages Api Ci Cd (`ui/apps/docs/src/pages/api/ci-cd.mdx`)
layout: ../../layouts/DocsLayout.astro title: CI/CD Pipelines
CI/CD Pipelines
A pipeline reaching devices through ShellHub is an SSH client like any other. What makes it worth
its own page is the credential question — get that wrong and the pipeline either breaks
unpredictably or is over-privileged.
Set it up correctly, once
1. Give the pipeline its own key. Not a person's. Create an API key named for the pipeline,ci-deploy, then open its SSH access on the Team page and add an SSH key for it.
Two things this buys you: the session list attributes deploys to ci-deploy rather than to
whoever set it up, and the pipeline does not break the week that person leaves. See
SSH Access.
2. Add the key ahead of time. A key that is not yet an identity parks the login waiting for a
browser approval. In a pipeline that is a hang, then a timeout, on a job nobody is watching.
3. Write a policy for it. An all-members allow does not cover an automation, which is
not a member:
| Name | Effect | Subject | Devices | Logins |
|---|---|---|---|---|
| Deploy to production | allow | API key ci-deploy |
tag production |
deploy |
Grant the unix login it actually needs. If the deploy runs as deploy, do not grant root.
4. Put the private key in the pipeline's secret store. Nowhere else.
Using it
GitHub Actions:
- name: Deploy
env:
SSH_KEY: ${{ secrets.SHELLHUB_DEPLOY_KEY }}
run: |
install -m 600 /dev/stdin ~/.ssh/id_ed25519 <<< "$SSH_KEY"
ssh -o StrictHostKeyChecking=accept-new \
[email protected]@cloud.shellhub.io \
'systemctl restart myservice'GitLab CI:
deploy:
script:
- install -m 600 /dev/stdin ~/.ssh/id_ed25519 <<< "$SHELLHUB_DEPLOY_KEY"
- ssh -o StrictHostKeyChecking=accept-new
[email protected]@cloud.shellhub.io
'systemctl restart myservice'StrictHostKeyChecking=accept-new accepts the ShellHub server's host key on first use. Pinning it
properly is better if your runner has persistent state — and be aware that regenerating the
server's SSH host key changes it.
Deploying to a fleet
Do not enumerate devices in the pipeline. Query them by tag and iterate, so a device added next
month is included without an edit:
$ curl -s -H "X-API-Key: $SHELLHUB_API_KEY" \
"https://cloud.shellhub.io/api/devices" | \
jq -r '.[] | select(.tags[]?.name == "production") | .name' | \
while read -r device; do
ssh "deploy@[email protected]" 'systemctl restart myservice'
doneThat needs both credentials, and they are different things: an
API key to list devices over HTTP, and the SSH key that key owns to
connect. Neither substitutes for the other, though both belong to the same principal.
For anything beyond a loop, use Ansible.
Short-lived credentials
For a genuinely one-shot job — a provisioning run, a migration — an automation's key can be
single use: spent after one connection. Combined with an expiry, that is the tightest
credential available.
What to watch for
A device the policy misses fails as unreachable, which looks like a network problem. Check the
policy before the network.
Ephemeral runners each connect fresh, so the key must already be an identity — a runner cannot
complete a browser approval.
Sessions are attributed and recorded like any other. If recording is on, pipeline sessions are
recorded too.
Where to go next
- SSH Access — The principal a pipeline should hold.
- Ansible — Beyond a shell loop.
- Provisioning Keys — Enrolling what the pipeline creates.
Ui Apps Docs Src Pages Api Ci Debug (`ui/apps/docs/src/pages/api/ci-debug.mdx`)
layout: ../../layouts/DocsLayout.astro title: Debugging a CI Run
import Callout from "../../components/Callout.astro";
Debugging a CI Run
A build that only fails in CI is the worst kind. ShellHub ships a GitHub Action that registers
the runner itself as a device, so you open a shell on the machine while the job is still standing.
The action installs the agent on the runner, registers it as an ephemeral device, accepts it, and
removes it when the job ends. The session goes through your own gateway, which is what separates
this from pasting a tunnel URL into a public log: access is governed by your namespace, the key
is one you manage, and the session is recorded.
The action authorizes you with a public key, which a new namespace does not consult, so it works
in a legacy namespace only.
The step
- uses: shellhub-io/ci-action@v1
with:
server: https://cloud.shellhub.io
tenant-id: ${{ secrets.SHELLHUB_TENANT_ID }}
api-key: ${{ secrets.SHELLHUB_API_KEY }}The job log prints an ssh command and a browser URL. The browser URL opens the runner's terminal
in the console, so you do not need a client at all.
The runner needs Docker, which the GitHub-hosted ubuntu-* images have. The API key needs
permission to accept and remove devices, and to remove public keys when the action manages one for
you.
Only when something broke
Waiting on every green build is not the point. Gate the step:
- name: build
run: make
- uses: shellhub-io/ci-action@v1
if: failure()
with:
server: https://cloud.shellhub.io
tenant-id: ${{ secrets.SHELLHUB_TENANT_ID }}
api-key: ${{ secrets.SHELLHUB_API_KEY }}
timeout: 1800Blocking or detached
By default the step blocks: the job stops there and waits for you. Once you connect it holds
until you disconnect, and sudo touch /continue inside the session releases it early. Connection
state is read from the runner's /dev/pts, so it releases a few seconds after a dropped
connection rather than hanging on it.
detached: true inverts that — the job carries on and the runner stays reachable, withidle-timeout deciding how long the teardown waits at the end for somebody to connect.
Authorizing yourself
Your public key has to be authorized in ShellHub, scoped to the device's tags — github unless
you set tags. Three ways, and they differ in what is left behind:
| What you do | Afterwards | |
|---|---|---|
| Register it yourself | Add your key in ShellHub once, scoped to the github tag |
Persists. Every runner the action registers is reachable |
public-key |
Pass the key to the action | Removed on teardown |
authorize-actor |
The action fetches the triggering user's keys from GitHub | Removed on teardown |
authorize-actor: true requires the actor to have keys and warns when they have none; auto is
best effort and stays quiet.
Which user you land as
The agent runs as root with host access, so the shell is on the runner itself rather than in a
container, and you can connect as any user on it:
ssh <user>@<namespace>.ci-<run_id>-<attempt>@cloud.shellhub.iossh-username pins one user — it is a regular expression, defaulting to .* — and the pinned
name is filled into the command the action prints.
Where to go next
- CI/CD Pipelines — The other direction: a pipeline reaching your devices.
- API keys — What the action authenticates with.
- Tags — What scopes the access.
Ui Apps Docs Src Pages Api Mcp (`ui/apps/docs/src/pages/api/mcp.mdx`)
layout: ../../layouts/DocsLayout.astro title: MCP Server
MCP Server
ShellHub ships a Model Context Protocol server, so an AI assistant can work with your namespace
directly — list devices, look at sessions, and act on them — instead of you relaying output into a
chat window.
It is mounted at /mcp as an MCP Streamable HTTP server.
The MCP server is in Community and is registered unconditionally, on every edition.
Connecting
Point an MCP-capable client at your server's /mcp endpoint and give it an API key.
https://cloud.shellhub.io/mcpAuthentication is API-key only
This is the part to understand before wiring anything up.
MCP authenticates with an API key and nothing else. The user-identity headers that the rest of
the API accepts do not apply here and are deliberately omitted. There is no way to connect an
assistant "as yourself" with your session — it connects as an API key.
The consequence: the assistant can do whatever the key's role can do. Give it an observer key
if it should look and not touch. Give it an operator key only if you have decided you want it
acting.
See API keys and User Roles.
How authorization is enforced
The MCP tools make in-process API calls, replaying the captured authentication headers. That means
the same middleware the HTTP API uses runs for an MCP request: the same permission checks, the
same tenant scoping, and the same refusals for routes that block API keys.
So an assistant cannot reach anything through MCP that the key could not reach through the HTTP
API. There is no separate, looser path — which is exactly the property you want from a tool an
assistant drives.
The routes that refuse API keys refuse MCP too. See
What a key cannot do.
What it exposes
Tools covering the namespace's resources — devices among them. The set grows with releases; ask
your client to enumerate the available tools, since MCP is self-describing.
Being deliberate about it
An assistant with an operator key can change things in your namespace. That is the point, and it
is worth a moment's thought:
- Start with an observer key. Most of the value is in asking questions.
- Use a separate key for the assistant, named for it, so you can revoke it without touching
anything else. - Give it an expiry.
- The key is in your assistant's configuration. Treat that file as holding a credential.
Where to go next
- API keys — Creating a scoped key.
- API Authentication — The underlying model.
Ui Apps Docs Src Pages Api Provisioning Key Webhook (`ui/apps/docs/src/pages/api/provisioning-key-webhook.mdx`)
layout: ../../layouts/DocsLayout.astro title: Provisioning Key Webhook
import Callout from "../../components/Callout.astro";
Provisioning Key Webhook
A provisioning key in webhook mode asks your service whether to admit
each device that enrolls with it. This page is the contract: what ShellHub sends, what it accepts
back, and how to answer later instead of immediately.
The request
ShellHub sends a POST with a JSON body and two headers.
| Header | Value |
|---|---|
Content-Type |
application/json |
X-ShellHub-Signature |
HMAC-SHA256 of the raw body, hex encoded, under the key's webhook secret |
{
"tenant_id": "00000000-0000-4000-0000-000000000000",
"provisioning_key_id": "b1946ac92492d2347c6235b4d2611184",
"provisioning_key_name": "factory-line-2",
"device_uid": "3c9e2f1a7b4d8e6f0a1c5d9b2e7f4a83",
"identity": "00:1b:44:11:3a:b7",
"hostname": "gateway-01",
"info": { "id": "debian", "pretty_name": "Debian GNU/Linux 12", "arch": "arm64" },
"source_ip": "203.0.113.9",
"timestamp": "2026-08-26T03:41:00Z",
"callback_url": "https://cloud.shellhub.io/api/devices/enroll/callback/<token>"
}The secret itself is never sent, not even a fragment. Authenticity comes from the signature:
compute the same HMAC over the raw body you received and compare.
callback_url is absent when the server cannot work out its own public address.
The answer
Reply 2xx with a JSON body:
{ "decision": "accept", "reason": "serial in manufacturing batch 44" }decision |
Result |
|---|---|
accept |
The device is accepted into the namespace |
reject |
The device is rejected |
defer |
Decide later, through the callback URL |
pending |
Leave it for a human in the console |
reason is optional and is recorded with the decision.
Anything else fails closed to pending: a transport error, a non-2xx status, a body that does
not parse, an unrecognized decision, or no answer inside the timeout. The device waits for a
human rather than walking in.
The request times out in 5 seconds by default, 15 at most. Answer defer rather than
holding the connection open.
Deciding later
POST to the callback_url from the request:
$ curl -X POST "$CALLBACK_URL" \
-H 'Content-Type: application/json' \
-d '{"decision":"accept"}'Only accept and reject are valid here — defer has already happened.
The URL carries a signed, expiring token scoped to that one device. It is valid for 1 hour by
default, 24 hours at most, and it is single use: a second POST with the same URL is
refused, so a replay cannot flip a decision.
An accept arriving through the callback is checked again on the way in. The key is re-resolved
and revalidated, because it may have been revoked, disabled, expired or exhausted since the token
was minted, and a use is reserved against its limit. A deferred accept is bound by the same rules
as an immediate one.
A device left pending this way is re-evaluated the next time the agent authenticates, so a
decision that was deferred resolves on its own once your service answers.
Reachability
The webhook must be reachable at a public address. The destination is checked after DNS
resolution, so a hostname that resolves into a private network is refused too.
Where to go next
- Provisioning Keys — The modes, and everything a key carries.
- API Authentication — The credentials the rest of the API takes.
Ui Apps Docs Src Pages Api Ssh Access (`ui/apps/docs/src/pages/api/ssh-access.mdx`)
layout: ../../layouts/DocsLayout.astro title: SSH Access
SSH Access
An automation reaches devices as an API key. The key's token never opens an SSH session, and an SSH
identity never calls the API. Over SSH the API key connects as its own principal, with a public key
enrolled for it. An access policy naming the API key decides where it may reach.
| API key token | The key's SSH identity | |
|---|---|---|
| Used for | The HTTP API and MCP | SSH connections |
| Looks like | A token in a header | A public key |
| Governed by | Roles and permissions | Access policies |
Enrolling the key's SSH key
Enrolling needs permission to manage SSH identities, which an administrator and an owner hold.
Permission to add your own key is not enough. Without permission to manage them, the SSH
access column on Team → API Keys shows only a dash.
The column shows how many SSH keys each API key holds, or Not set up when it holds none. Open
the key's cell and choose Add SSH key, or Add once it holds one. Paste the public key the
automation will connect with.
A key's credentials live with the key. The SSH Identities page is about people and never lists
them.
Naming the key in a policy
A key holds no membership and no role over SSH, so an all-members allow never covers it, and
neither does a role subject. An all-members deny does not cover it either, so a namespace-wide
"no root anywhere" has to name the key too.
| Name | Effect | Subject | Devices | Logins |
|---|---|---|---|---|
| Pipeline deploys | allow | API key ci-deploy |
tag production |
deploy |
Policy Reference has the full subject rules.
Limiting the key's lifetime
Give the SSH key an expiry, so an automation nobody touched in a year does not still hold live
credentials. For a one-shot job, a provisioning run or a migration, make it single use, so
it authorizes one session and is then consumed.
Deleting the API key removes the SSH identities it owns and the policies naming it. An expired API
key opens nothing, even while its SSH key is still enrolled.
No re-authentication
A policy's require_reauth never applies to an API key. Re-auth is an interactive browser step
and there is nobody to complete it, so demanding one would hang the connection. Freshness comes
from the key's expiry and single use instead. If a path needs re-auth, it needs a person on it.
Where to go next
- API keys — Creating the key itself, and what it may do over HTTP.
- Access Policies — Granting the key something.
- CI/CD Pipelines — A pipeline set up this way, end to end.
Ui Apps Docs Src Pages Api Terraform (`ui/apps/docs/src/pages/api/terraform.mdx`)
layout: ../../layouts/DocsLayout.astro title: Terraform
import Callout from "../../components/Callout.astro";
Terraform
Two different things people mean by "Terraform and ShellHub", and they are worth separating.
Reaching a device from a provisioner
Terraform's remote-exec and file provisioners connect over SSH, and ShellHub's SSHID is an
ordinary SSH username:
connection {
type = "ssh"
host = "cloud.shellhub.io"
user = "[email protected]"
private_key = file("~/.ssh/id_ed25519")
}
provisioner "remote-exec" {
inline = [
"systemctl restart myservice",
]
}The host is always the ShellHub server; the device is identified by user.
The key must already be enrolled. If it is not, the connection parks waiting for a browser
approval and Terraform hangs until it times out. Enroll it ahead of time through the API key's
SSH access.
Managing ShellHub itself
ShellHub's HTTP API is what you would target to manage devices, tags or access policies as code.
An API key cannot create a namespace or manage API keys, so those stay a person's job in the
console. See What a key cannot do.
There is no official ShellHub Terraform provider. The options are the generic HTTP provider for
simple cases, or null_resource with a local-exec calling the API:
resource "null_resource" "shellhub_tag" {
provisioner "local-exec" {
command = <<-EOT
curl -sf -X POST https://cloud.shellhub.io/api/... \
-H "X-API-Key: ${var.shellhub_api_key}" \
-H "Content-Type: application/json" \
-d '{ ... }'
EOT
}
}Be honest with yourself about that pattern: it has no real state tracking, no drift detection and
no meaningful destroy. For anything beyond a couple of resources, a script against the API is
clearer than pretending it is declarative.
See API Authentication and the
the specification for the endpoints.
Enrolling devices Terraform creates
If Terraform is creating machines that should appear in ShellHub, put the agent in at creation
with a provisioning key, through cloud-init or user data:
user_data = <<-EOT
#!/bin/sh
curl -sSf https://cloud.shellhub.io/install.sh | \
PROVISIONING_KEY=${var.shellhub_provisioning_key} \
sh
EOTA provisioning key in automatic mode means instances arrive accepted and tagged, with nobody
watching a console. That is the pattern worth adopting — it is the one that scales.
Where to go next
- Provisioning Keys — Automatic enrollment.
- Ansible — Usually the better tool for configuring a device.
Ui Apps Docs Src Pages Connect Container Access (`ui/apps/docs/src/pages/connect/container-access.mdx`)
layout: ../../layouts/DocsLayout.astro title: Container Access
import Shot from '../../components/Shot.astro';
Container Access
The Connector exposes containers running on a host as ShellHub devices in their own right. Each
container gets its own entry, its own SSHID, and its own place in your access policies.
What it changes
Without it, you reach a container by SSHing to the host and then docker exec-ing in — which
means everyone who needs a container needs the host, and the host is a much larger grant.
With it, the container is a device. You can write a policy that grants somebody a container and
not the machine underneath it.
Setting it up
From the console's Containers page, click Add Docker Host:
<Shot id="add-docker-host" route="/containers" of={{ role: "dialog" }} before={[{ click: { role: "button", name: "Add Docker Host" } }]} alt="ShellHub Containers page with the Add Docker Host dialog open" />
In a new namespace the dialog sends you to Fleet. Open a provisioning key there and pick its
Docker connector method: the one-line command it shows enrolls every container with that key.
In a legacy namespace the dialog gives you the
one-line command with the namespace's tenant ID.
Run the command on the host. The Connector then watches the Docker daemon and registers containers
as they appear.
Configuration
The Connector takes its own variables, which differ from the agent's in one important way:
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_SERVER_ADDRESS |
— | Required |
SHELLHUB_PRIVATE_KEYS |
— | Required. Plural — a directory holding one key per container |
SHELLHUB_PROVISIONING_KEY |
— | The provisioning key every container enrolls with. Required unless SHELLHUB_TENANT_ID is set |
SHELLHUB_TENANT_ID |
— | The namespace to enroll into. Deprecated: every container lands pending until somebody accepts it. The Connector does not support pairing mode |
SHELLHUB_KEEPALIVE_INTERVAL |
30 |
Seconds |
SHELLHUB_CONNECTOR_LABEL |
"" |
Only manage containers carrying this label |
SHELLHUB_PRIVATE_KEYS being a directory follows from each container being a separate device:
separate devices need separate identities. Put that directory on a volume — if the keys are lost,
every container comes back as a new device.
SHELLHUB_CONNECTOR_LABEL is how you avoid registering every container on a busy host. Set it,
label the containers you actually want reachable, and the rest are ignored.
Connecting
A registered container behaves like any device:
$ ssh [email protected]@cloud.shellhub.ioAuthentication inside a container
Containers usually have no real user accounts, and this is where it gets awkward.
With the Connector enabled, password authentication is refused for a user with no password set in
the container's /etc/shadow — which is most container users. Two ways forward: set one withpasswd inside the container, or use public-key authentication, which works regardless.
Public keys are the better answer in every case where you have a choice.
Availability
Container access is in Community. It is switched on by SHELLHUB_CONNECTOR, which defaults totrue.
Lifecycle
Containers come and go, and a device list that accumulates one entry per container that ever
existed becomes unusable. Prune stopped containers' entries, and consider whether the containers
you actually need reachable are a small labelled subset — which is what SHELLHUB_CONNECTOR_LABEL
is for.
Where to go next
- Access Policies — Granting a container and not its host.
- Tags — Selecting containers as a group.
Ui Apps Docs Src Pages Connect File Transfer (`ui/apps/docs/src/pages/connect/file-transfer.mdx`)
layout: ../../layouts/DocsLayout.astro title: File Transfer
File Transfer
scp and sftp work through ShellHub with no special handling. The agent serves SFTP over the
same connection that carries a shell.
scp
$ scp firmware.bin [email protected]@cloud.shellhub.io:/tmp/And back:
$ scp [email protected]@cloud.shellhub.io:/var/log/syslog ./sftp
For an interactive session:
$ sftp [email protected]@cloud.shellhub.io
sftp> cd /var/log
sftp> get syslog
sftp> put patch.tar.gz /tmp/
sftp> quitAny SFTP client works — FileZilla, WinSCP, Cyberduck, your editor's remote support. Point them at
the server as the host and the SSHID as the username.
rsync
rsync runs over SSH, so it works too, and is what you want for anything large or repeated:
$ rsync -avz ./build/ [email protected]@cloud.shellhub.io:/opt/app/The -z matters more here than on a LAN — the path goes out to the server and back down to the
device.
Permissions
Transfers happen as the unix user in the SSHID, with exactly that user's permissions. If you
cannot write to /opt as deploy, ShellHub will not help — that is the device's decision, not
ShellHub's.
Which unix users you may connect as is decided by access policies. A policy granting onlydeploy means you transfer as deploy and nothing else, even on a device where root exists.
See Access Policies.
What recording does and does not capture
Session recording captures the terminal — what was typed and what was displayed. File transfer
payloads are not recorded. A recording will show that somebody was connected; it will not show
you the bytes of a file they copied.
If that gap matters for your audit requirements, know about it now rather than during an incident.
See what a recording holds.
Large transfers
Everything goes through the server, so a multi-gigabyte transfer occupies that path for its
duration. For images and firmware being pushed to many devices, a release artifact somewhere the
devices can fetch from — and a shell command to fetch it — is usually kinder than pushing the same
bytes through the gateway once per device.
Where to go next
- Connecting to a Device — The SSHID this all relies on.
- Port Forwarding — Reaching a service instead of a file.
- SSH Client Configuration — Aliases, if the full address gets tedious.
Ui Apps Docs Src Pages Connect Port Forwarding (`ui/apps/docs/src/pages/connect/port-forwarding.mdx`)
layout: ../../layouts/DocsLayout.astro title: Port Forwarding
import Callout from "../../components/Callout.astro";
Port Forwarding
Port forwarding is what turns "I can get a shell" into "I can reach the thing that shell can
reach". Standard SSH local forwarding works through ShellHub.
Reaching a service on the device
A database bound to localhost on the device, reachable at localhost:5432 on your machine:
$ ssh -L 5432:localhost:5432 [email protected]@cloud.shellhub.ioThen connect your client to localhost:5432 as usual. The service does not need to be listening
on anything but loopback — the agent is on the device, so loopback is local to it.
That is the common case and it is genuinely useful: a web admin interface, a database, a metrics
endpoint, all of them bound to 127.0.0.1 on a device you cannot otherwise reach.
Reaching something else on the device's network
The forward target is resolved by the device, so it does not have to be the device itself:
$ ssh -L 8080:192.168.1.50:80 [email protected]@cloud.shellhub.ioNow localhost:8080 on your machine reaches a web interface on 192.168.1.50 — a PLC, a camera,
a switch — on a LAN that has no route to you at all.
This is worth dwelling on, because it is the widest thing ShellHub does. One device with an agent
becomes a way in to everything that device can see. A device on a factory network is a way to
reach the factory network.
Keeping the forward without a shell
-N opens the forward and no terminal:
$ ssh -N -L 5432:localhost:5432 [email protected]@cloud.shellhub.ioAdd -f to background it. To close it, kill the process — or use a control socket, which is
tidier:
$ ssh -M -S ~/.ssh/cm-pi -fNT -L 5432:localhost:5432 pi
$ ssh -S ~/.ssh/cm-pi -O exit piSeveral at once
Repeat -L:
$ ssh -N \
-L 5432:localhost:5432 \
-L 8080:localhost:80 \
-L 9090:localhost:9090 \
piReverse forwarding is refused
ssh -R does not work. The server declines every reverse forwarding request, and there is no
setting that enables it. To reach a service on the device from outside, publish a
web endpoint instead.
Authorization
A forward rides on a session, so it is governed by whatever authorized that session. Policies
grant a device and a set of unix logins; there is no separate per-port permission.
The practical consequence: granting somebody shell on a device grants them forwarding from it
too. If you need someone to reach one service and nothing else, ShellHub's port forwarding is not
the mechanism — a web endpoint is closer, since it publishes exactly
one service.
Authorization is checked when the session is established. A long-lived -N forward was authorized
once, at the start, and revoking access does not tear it down. See
How Authorization Works.
Where to go next
- Web Endpoints — Publishing one HTTP service at a URL instead.
Ui Apps Docs Src Pages Connect Ssh Clients (`ui/apps/docs/src/pages/connect/ssh-clients.mdx`)
layout: ../../layouts/DocsLayout.astro title: SSH Client Configuration
SSH Client Configuration
An SSHID is correct but long. Nothing here changes how ShellHub works — it is about typing less.
OpenSSH
Add an entry to ~/.ssh/config:
Host pi
HostName cloud.shellhub.io
User [email protected]Then:
$ ssh piThe whole SSHID goes in User, and HostName is just the server. That split is what makes the
rest of the OpenSSH ecosystem work — scp, sftp, rsync and anything reading ssh_config all
honor the alias.
For a fleet, a wildcard saves writing one block per device:
Host *.shellhub
HostName cloud.shellhub.io
User root@mycompany.%hThat is imperfect — %h includes the .shellhub suffix — so most people end up with explicit
blocks for the devices they touch often and the full SSHID for the rest.
Keys
Point at a specific key the normal way:
Host pi
HostName cloud.shellhub.io
User [email protected]
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yesIdentitiesOnly yes is worth setting. Without it, OpenSSH offers every key it has, and the
first one ShellHub recognizes is the identity your session is attributed to — which may not be
the one you meant.
PuTTY
Put the SSHID in Host Name as [email protected]@server, or set the server as the host
and the SSHID's user part under Connection → Data → Auto-login username.
Save it as a session so you do not retype it.
Ansible
The SSHID goes in the inventory as the connection user:
[edge]
raspberrypi ansible_host=cloud.shellhub.io [email protected]Or in YAML:
all:
hosts:
raspberrypi:
ansible_host: cloud.shellhub.io
ansible_user: [email protected]See Ansible for generating the inventory from your device list rather than
maintaining it by hand.
rsync, scp and sftp
They inherit ~/.ssh/config, so an alias covers them:
$ scp file.tar.gz pi:/tmp/
$ rsync -av ./build/ pi:/opt/app/Without an alias, quote carefully — the SSHID contains @ twice:
$ scp file.tar.gz [email protected]@cloud.shellhub.io:/tmp/See File Transfer.
Multiplexing
Reusing one connection for several sessions makes repeated commands noticeably faster:
Host pi
HostName cloud.shellhub.io
User [email protected]
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10mBe aware of the interaction with authorization: ShellHub authorizes when the connection is made.
A multiplexed connection held open for ten minutes was authorized once, at the start.
Where to go next
- File Transfer — Moving files over the same path.
- Port Forwarding —
-Land-Rthrough ShellHub.
Ui Apps Docs Src Pages Connect Web Endpoints (`ui/apps/docs/src/pages/connect/web-endpoints.mdx`)
layout: ../../layouts/DocsLayout.astro title: Web Endpoints
import EditionBanner from "../../components/EditionBanner.astro";
import Shot from '../../components/Shot.astro';
Web Endpoints
A web endpoint takes an HTTP service running on a device and gives it a URL. Somebody opens the
URL in a browser and reaches the service, with nothing installed and no SSH involved.
What it is for
A device is running a web interface — a dashboard, an admin panel, a camera's UI — on a network
you cannot reach. A port forward solves that for you, on your
machine, for as long as the forward is open.
A web endpoint solves it for anyone you give the URL to, with no client at all. That is the
difference, and it is the reason to choose one over the other.
Creating one
From the console's Web Endpoints page, pick the device and the port the service is listening
on. You get back a URL.
<Shot id="web-endpoints" route="/web-endpoints" of={{ role: "main" }} edition="enterprise" alt="The Web Endpoints page listing active endpoints with their public URLs, targets, and time remaining" />
The endpoint carries an expiry, shown in the list as time remaining.
How access works
This is the part to be clear-eyed about. A web endpoint is reached by knowing its URL. The
address contains a random component that is both its name and its secret. There is no login in
front of it, and access policies do not apply to it.
The consequences:
- Anyone with the URL reaches the service, from anywhere.
- The URL in a chat message, a ticket, or a browser history is a working credential.
- Revoking access means deleting the endpoint, not changing a policy.
That is a reasonable trade for showing somebody a dashboard for an afternoon. It is the wrong
mechanism for standing access to something sensitive — use SSH and policies for that.
Set expiries deliberately, and delete endpoints when the reason for them has passed.
What it does not expose
Publishing an endpoint publishes one service on one port. It does not give anyone a shell, a
file transfer, or access to anything else on the device. The device itself stays unreachable.
Self-hosted configuration
On Enterprise, web endpoints need a DNS provider so certificates can be issued for the generated
hostnames:
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_WEB_ENDPOINTS |
false |
Switches the feature on |
SHELLHUB_WEB_ENDPOINTS_DOMAIN |
falls back to SHELLHUB_DOMAIN |
The domain endpoints are published under |
SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER |
digitalocean |
DigitalOcean and Cloudflare are supported; acme-dns is also handled |
SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER_TOKEN |
— | Required when auto-SSL is on |
Endpoints get their own hostnames under the endpoints domain, so that domain needs a wildcard DNS
record pointing at the server.
Where to go next
- Port Forwarding — The alternative, for yourself rather than others.
- Environment Variables — The self-hosted settings above.
Ui Apps Docs Src Pages Get Started Agent Configuration (`ui/apps/docs/src/pages/get-started/agent-configuration.mdx`)
layout: ../../layouts/DocsLayout.astro title: Agent Configuration
Agent Configuration
The agent is configured entirely through environment variables. Where you put them depends on the
install method — the container's environment, a systemd drop-in, snap set, or /etc/rc.conf on
FreeBSD.
All names take the SHELLHUB_ prefix. The required ones are also accepted without it, for
compatibility with older setups.
Required
| Variable | Meaning |
|---|---|
SHELLHUB_SERVER_ADDRESS |
The server the agent connects to, e.g. https://cloud.shellhub.io |
SHELLHUB_PRIVATE_KEY |
Path to the device's private key. Generated on first run if absent |
SHELLHUB_PRIVATE_KEY is the device's identity. Whatever else you get wrong, get this one
right: it must be on storage that survives restarts, reflashes and container recreation, and it
must be unique per device. A key shipped inside an image makes every unit built from that image
claim to be the same device.
Enrollment
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_TENANT_ID |
— | Which namespace to join. Deprecated: the device lands pending until somebody accepts it. When empty, and nothing was persisted from an earlier pairing, the agent boots into pairing mode and waits to be accepted |
SHELLHUB_PROVISIONING_KEY |
— | A reusable key from Add Device → Fleet, which names the namespace on its own and applies the key's tags and ephemeral flag |
See How a device enrols for how these interact.
Connection
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_KEEPALIVE_INTERVAL |
30 |
Seconds between keepalives |
SHELLHUB_TRANSPORT_VERSION |
2 |
Transport. 1 uses HTTP-based revdial; 2 uses yamux multiplexing with multistream |
Leave the transport at its default unless you are working around something specific. Version 1
exists for compatibility with older servers.
Identification
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_PREFERRED_HOSTNAME |
— | A hint for the device's name, instead of the system hostname |
SHELLHUB_PREFERRED_IDENTITY |
"" |
A hint for the device's identity |
SHELLHUB_PREFERRED_HOSTNAME is worth setting on anything whose system hostname is not
meaningful — a container with a random hostname, or a fleet where every unit reports the same
one. The device name is what you type in an SSHID and what a legacy public key's or firewall
rule's hostname filter matches against.
Access
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_SINGLE_USER_PASSWORD |
— | Password hash for single-user mode |
SHELLHUB_SIMPLE_USER_PASSWORD |
— | Legacy alias for the above, a preserved typo. Use SINGLE in new configuration |
Generate the hash with openssl passwd. Single-user mode is for devices with no real user
accounts to authenticate against.
Where to go next
- Managing Devices — How a device enrols, and what identifies one.
- Device Not Appearing — When configuration is the cause.
Ui Apps Docs Src Pages Get Started Install (`ui/apps/docs/src/pages/get-started/install.mdx`)
layout: ../../layouts/DocsLayout.astro title: Install the Agent
import ActionLink from "../../components/ActionLink.astro";
import Callout from "../../components/Callout.astro";
Install the Agent
The agent is what a device runs. It opens one outbound connection to the server and keeps it,
which is what makes the machine reachable without anything listening on it.
$ curl -sSf https://cloud.shellhub.io/install.sh | shNothing else is needed. Given no credential the agent pairs: it prints a code, you enter it in the
console, and the device joins the namespace you pick. Quickstart walks that path
end to end.
The script is served by the server it will register with, which writes its own address into it on
the way out — port included. Fetch it from your own server and you get one that registers there.
What the script does
- Picks a method by looking at the machine: Docker, then Podman, then Snap, then WSL, then a
native binary. The first one that works is used. - Detects the architecture —
amd64,armv6,armv7,arm64,386. - Installs the agent and registers it to start at boot.
Force a method with INSTALL_METHOD when the choice is wrong for you:
$ curl -sSf https://cloud.shellhub.io/install.sh | INSTALL_METHOD=standalone shThe methods
Docker
Picked when a rootful daemon is usable. Leaves a container running the agent, plus ashellhub-agent wrapper on the host.
The wrapper is there because shellhub-agent login opens a browser during pairing, which an
agent inside a container cannot do. The container itself runs privileged, with the host's
filesystem and process namespace: the agent opens shells as real unix users and forwards ports on
their behalf, and a confined container can do neither.
$ docker ps | grep shellhub # is it running
$ docker logs -f <container> # what it is doing
$ docker restart <container> # restart itPodman
Picked when Podman is present and rootful. The same image and the same wrapper as Docker.
Rootful is a requirement, not a preference: the installer declines rootless, because an agent
installed that way would appear to work and fail at the moment somebody tried to use it.
$ podman ps | grep shellhub # is it running
$ podman logs -f <container> # what it is doing
$ podman restart <container> # restart itSnap
Picked when the snap command is present. Leaves the shellhub snap. The snap needs the tenant
named up front and cannot pair or take a provisioning key, so its device always lands pending until
somebody accepts it.
That snap is classic rather than strict, for the same reason the containers are privileged,
and classic snaps cannot be installed on Ubuntu Core — run the agent as a container under the
Docker snap there instead. Being a snap also means snapd refreshes it on its own schedule, so
the agent updates itself; sudo snap refresh --hold shellhub pins it.
$ snap services shellhub # is it running
$ sudo snap logs -f shellhub # what it is doing
$ sudo snap restart shellhub # restart itWSL
Picked on WSL 2 with Ubuntu. Leaves a native binary under systemd, inside the distribution.
Each of those is a requirement rather than a description: version 2, Ubuntu, systemd enabled, and
mirrored networking. The installer checks all four and stops on any of them.
$ systemctl status shellhub-agent # is it running
$ journalctl -u shellhub-agent -f # what it is doingStandalone
Picked when nothing above matched. Leaves a binary and a systemd unit that starts it at boot.
That unit is the whole of it: no daemon, no container runtime, no snapd, which is what makes this
the leanest way to run the agent.
$ systemctl status shellhub-agent # is it running
$ journalctl -u shellhub-agent -f # what it is doing
$ sudo systemctl restart shellhub-agent # restart itWhat it configures
| Variable | Default | Meaning |
|---|---|---|
SERVER_ADDRESS |
the instance that served the script | Which server the agent talks to |
INSTALL_METHOD |
detected | Force a method rather than letting the script pick |
AGENT_VERSION |
fetched from $SERVER_ADDRESS/info |
Which agent version to install |
AGENT_IMAGE |
docker.io/shellhubio/agent:$AGENT_VERSION |
Container image, for the container methods |
INSTALL_DIR |
/usr/local/bin |
Where the binary goes, for standalone |
PROVISIONING_KEY |
— | Enrol without anybody accepting the device. See below |
TENANT_ID |
— | Which namespace to join. Deprecated: the device lands pending until somebody accepts it |
SKIP_BOOT_RESTART_CHECK |
— | Set to 1 to install under Podman without podman-restart.service |
SERVER_ADDRESS is filled in for you. Set it only to point somewhere other than where the script
came from.
Enrolling without accepting each device
Pairing asks somebody to approve the device, which is right for the machines in front of you and
wrong for a hundred of them. A provisioning key carries the namespace
and the rule for what happens on arrival, so devices enrol on their own, tagged:
$ curl -sSf https://cloud.shellhub.io/install.sh | PROVISIONING_KEY=<your-provisioning-key> shUninstalling
$ curl -sSf https://cloud.shellhub.io/install.sh | sh -s uninstallStandalone, WSL, Docker and Podman are supported. Other methods refuse rather than half-remove
themselves; a Snap install goes with snap remove shellhub.
Machines the script does not cover
The script assumes a machine that already exists, with somebody — or a provisioning script —
running a command on it. These are not that command with a different flag.
Yocto and Buildroot are embedded build systems: the agent goes into the product image while the
image is built, before there is a machine to run anything on. FreeBSD has its own packaging.
Building from source is you producing the binary.
- Raspberry Pi — A card image that enrols itself on first
boot. - Yocto Project — The
meta-shellhubOpenEmbedded layer. - Buildroot — The ShellHub external tree, same idea.
- FreeBSD — The
ShellHub ports tree. - Building from Source — For a platform nothing above
covers, or to run an agent you built yourself.
A unit coming off a line has no console to open and no link to click, so an image carries a
provisioning key rather than waiting to be paired. And the key that identifies a device is generated by
the agent on first start: one baked into an image makes every unit the same device, and one the
unit cannot keep makes it a new device on every boot. See
Agent Configuration for where that file lives.
Where to go next
- Quickstart — Accept the device and open a terminal on it.
- Agent Configuration — Every agent variable.
- Device Not Appearing — If it never shows up.
Ui Apps Docs Src Pages Help Support (`ui/apps/docs/src/pages/help/support.mdx`)
layout: ../../layouts/DocsLayout.astro title: Getting Support
Getting Support
Where to go
GitHub Issues — bugs and feature requests.
Search first; a lot of things are already there.
GitHub Discussions — questions, and
"is this supposed to work like this".
Cloud and Enterprise customers have their support channel through their account.
Before you ask
Two pages answer most of what comes up, and checking them first usually saves a round trip:
- Connection Denied — a connection is refused
- Device Not Appearing — a device is missing or offline
What to include
The difference between a fast answer and three days of clarifying questions:
Which edition and version. Community, Cloud or Enterprise, and the version:
$ grep SHELLHUB_VERSION .env # self-hostedSelf-hosted or Cloud. They fail differently.
Whether the namespace is legacy. A legacy namespace and a new one authorize SSH differently.
See How Authorization Works.
How the agent was installed. Docker, Podman, standalone, WSL, or built into an image.
What you did, what happened, what you expected. Concretely. "It does not work" cannot be
acted on; "ssh [email protected]@server returns permission denied, and the device shows online in
the console" can.
Logs, if the server is yours:
$ ./bin/docker-compose logs --tail 200Agent logs, from the device:
$ docker logs <container>
$ journalctl -u shellhub-agent -n 200
$ sudo snap logs shellhubWhat not to include
Redact before pasting:
- The contents of
.env.override— it has your database password - API keys and provisioning keys
- Private keys, always
- Tenant IDs, if you would rather not have them public
Log output can carry addresses and hostnames too. A quick read before pasting is worth it.
Reporting a security issue
Do not open a public issue. There are two private routes: a
GitHub Security Advisory,
which is the preferred one and is visible only to maintainers, or [email protected]. Receipt
is confirmed within two business days.
ShellHub follows coordinated disclosure, assigns a CVE to every confirmed vulnerability, and
publishes an advisory once a fix exists. The
security policy carries the
patch targets by severity and what an advisory contains.
Contributing a fix
ShellHub is open source and the fix may well be yours. Open an issue first for anything larger
than a small change, so the discussion happens before the work; for a small one, the pull request
is the discussion. Branch, push, open the PR against the relevant repository.
CONTRIBUTING.md has the
rest, including the code of conduct every interaction is expected to follow, and the repository's
README covers getting the stack running.
Where to go next
- FAQ — Short answers to common questions.
- Troubleshooting — Self-hosted server problems.
Ui Apps Docs Src Pages Help Troubleshooting (`ui/apps/docs/src/pages/help/troubleshooting.mdx`)
layout: ../../layouts/DocsLayout.astro title: Troubleshooting
Troubleshooting
Pick the area closest to what is failing. Every guide below lives beside the feature it covers,
so none of this is a second copy that drifts from the first.
Connecting
| A connection is refused | Connection Denied |
| A login hangs instead of refusing | Login Approvals — a key waiting to be approved |
| Sessions from a legacy namespace behave differently | Upgrading a Legacy Namespace |
Devices
| A device never showed up | Device Not Appearing |
| It arrived pending instead of accepted | Provisioning Keys — the key's mode decides |
| A device came back that you deleted | Device Not Appearing |
Signing in
| SAML fails | SAML Sign-In Fails — check the license first |
| Somebody cannot see a device | User Roles |
Your own server
| The stack will not come up, or a service is unhealthy | Troubleshooting |
| Certificates, the database, the gateway | Troubleshooting |
| It broke after an upgrade | Upgrade |
Automation
| A playbook or pipeline hangs at connect | The API key has no SSH key enrolled yet. SSH Access |
| A generated client returns 404 | The spec was the wrong edition. the specification |
Still stuck
Getting Support has where to ask, what to include so the first answer is useful,
and what to redact before pasting.
Ui Apps Docs Src Pages Manage Access Control (`ui/apps/docs/src/pages/manage/access-control.mdx`)
layout: ../../layouts/DocsLayout.astro title: How Authorization Works
How Authorization Works
Every connection is authorized before the device is contacted. ShellHub refuses at the server,
before it mints anything for the agent, so a denied login never reaches the device at all.
How the decision is made depends on which model your namespace uses.
Two models
| Identity | Legacy | |
|---|---|---|
| What proves who you are | An SSH identity bound to a person or an API key | A public key registered in the namespace |
| What decides what you reach | Access policies | The key's device ACL, plus firewall rules |
| An unlisted device is | Denied | Allowed, unless a rule blocks it |
| The audit trail names | The principal | The key |
Namespaces created today are new and stay that way. Namespaces that predate identities
are legacy, and can be upgraded once, when you are ready; there is no way back.
The rest of this page describes a new namespace. For a legacy one see
Public Keys and
Firewall Rules; to move, see
Upgrading a Legacy Namespace.
Identities prove who
An SSH identity binds a public key to a principal: a person on your team, or an
API key an automation connects as. The key is the credential; the identity is
who that credential belongs to.
This inversion is what makes everything downstream work. In a legacy namespace a key is the
access: whoever holds it gets in, and the record can only name the key. With identities, a
connection whose key fingerprint resolves to an identity is recognized as that principal, and
policy evaluation, the session record and revocation are all about the person.
See SSH Identities.
Policies decide what
An identity proves who. A policy decides what they may reach: a subject, a set of devices, a set
of unix logins, and whether the effect is to allow or deny.
See Access Policies for writing them, and
Policy Reference for every field.
How a decision is made
When a connection arrives, ShellHub resolves the presented key to an identity, then evaluates the
namespace's policies against that identity, the target device, and the requested unix login.
The order:
- Membership. Not a member of the namespace → refused, before policies are consulted.
- Every deny. A matching deny refuses, and nothing later overrides it. A deny that cannot be
evaluated also refuses. - Every allow. One match is enough. The strictest re-authentication requirement across all
matching allows is taken. - Nothing matched → refused.
Three properties fall out of that, and they are the ones worth remembering:
Default deny. A device is unreachable until some policy says otherwise. A namespace with no
policies at all refuses every connection — and ShellHub short-circuits that case, refusing
immediately rather than proceeding toward a login that could never be authorized.
Deny wins. Deny is a subtractive blocklist carved out of broad grants, not a base layer.
Default deny already blocks everything a policy does not grant, so deny exists to carve exceptions
out of your allows.
Deny fails closed. If a deny's filter or source IP cannot be evaluated, ShellHub assumes it
applies. An allow that cannot be evaluated is treated as a non-match instead — the asymmetry is
deliberate, and it always errs toward refusing.
Where the network does not fit
Network position is not part of the decision. There is no "inside": a device on your office LAN
and one on a customer's cellular link are evaluated identically, against the same policies.
A policy can carry a source IP restriction, but it only ever narrows a grant that already
matched. An empty list matches any address, and no source IP ever grants access on its own.
In a new namespace the legacy firewall rules, which match on source IP, hostname and username,
are bypassed entirely. They remain stored and manageable, and are never consulted.
The starter policy
A new namespace is seeded with one policy, Owner access, granting the namespace
owner every login on every device. It is created when a namespace is born, and
also when a legacy namespace is upgraded with no policies of its own.
Without it, default deny would lock the owner out of their own namespace at the moment the mode
changed. Every other member starts with no access at all.
What authorization does not do
It runs once, at the handshake. Revoking an identity or deleting a policy does not tear down a
session that is already open — that session was authorized when it started, and it continues
until it is closed.
The reason is not surfaced. A refusal carries a specific reason internally, but the connecting
client receives a generic access-denied. Connection Denied works
through the four possibilities.
Where to go next
- SSH Identities — Binding keys to people.
- Access Policies — Writing the rules.
- SSH Access — Giving a pipeline its own way in.
- Login Approvals — When a login pauses for confirmation.
Ui Apps Docs Src Pages Manage Devices (`ui/apps/docs/src/pages/manage/devices.mdx`)
layout: ../../layouts/DocsLayout.astro title: Managing Devices
import Shot from '../../components/Shot.astro';
Managing Devices
Every machine running an agent appears under Devices.
<Shot id="device-list" route="/devices" of={{ role: "main" }} before={[{ click: { role: "button", name: "Sort by Hostname" } }]} alt="ShellHub Devices page listing accepted devices" />
How a device enrols
Installing the agent is half of it. The other half is the device joining a namespace, and what
the agent was given at install decides both how it joins and whether anybody has to act on it.
| Given | Lands | Good for |
|---|---|---|
| Nothing | Accepted, once somebody opens the link it prints | A device in front of you |
| A provisioning key | Whatever the key's mode says | Fleets, images, production |
| A tenant ID | Pending, for review | Deprecated; what a legacy namespace accepts |
Given nothing, the agent pairs: it asks the server for a code, prints it with a link, and waits
for somebody to accept it in the browser. That is the path the
Quickstart walks, and the one to use for a machine you are standing at.
A provisioning key is the answer at any volume. It names the namespace on its own and its mode
decides acceptance, so a fleet arrives accepted and tagged instead of arriving as a queue.
A tenant ID also names the namespace, and is how a legacy namespace enrolls every device: it
always leaves the device pending, in the queue behind the namespace's built-in key. A new namespace
still accepts it the same way, but it is deprecated, so nothing new should use it.
Statuses
| Status | Meaning |
|---|---|
accepted |
In the namespace and reachable |
pending |
Enrolled, waiting to be accepted. Cannot be connected to |
rejected |
Explicitly refused |
removed |
Deleted from the namespace |
unused |
Registered but never used |
Only accepted devices can be connected to. A device sitting in pending is not broken — it is
waiting for somebody, on the activity of the key it enrolled with — see
Provisioning Keys.
Online and offline
Separate from status. A device is online while its agent holds a connection to the server, and
offline when it does not.
The agent sends a keepalive every 30 seconds by default and reconnects on its own, waiting a
random 5 to 65 seconds before each attempt, so a brief network problem resolves itself without anybody doing anything. A device
that stays offline has a real cause — see
Device Not Appearing.
What identifies a device
The agent's private key, at SHELLHUB_PRIVATE_KEY. Not the hostname, not the MAC, not the IP.
The consequences are worth internalizing:
- Rename the machine and it is the same device.
- Move it to another network and it is the same device.
- Lose the key — reflash, recreate the container, wipe the volume — and it is a new device,
and the old entry stays behind.
Most confusing device lists are the result of that last one.
Names
A device's name defaults to its hostname and is what you type in an SSHID:
$ ssh [email protected]@cloud.shellhub.ioYou can rename a device in the console. Set SHELLHUB_PREFERRED_HOSTNAME on the agent when the
system hostname is not meaningful — a container with a random name, or a fleet where every unit
reports the same one.
Names matter beyond typing: the legacy public key ACL and firewall rules can select devices by a
hostname pattern, so a naming convention is something you can write rules against there. Access
policies select by tag instead — see Tags.
Details
Opening a device shows its SSHID with a copy button, plus what the agent reports about it — OS,
architecture, agent version, when it was last seen.
The agent version is the first thing to check when something behaves oddly on one device and not
another.
Rejecting and removing
Rejected refuses the device and keeps the record, so the refusal is still visible later.
Removed deletes it from the namespace.
Neither stops the agent. If it is still running with the same key and configuration, it
re-enrolls — and depending on how it enrolls, may come straight back.
To retire a device properly: stop the agent on it, then remove it here.
Where to go next
- Tags — Grouping devices so policies can select them.
- Provisioning Keys — Enrolling without a manual step.
Ui Apps Docs Src Pages Manage Namespaces (`ui/apps/docs/src/pages/manage/namespaces.mdx`)
layout: ../../layouts/DocsLayout.astro title: Namespaces
import EditionBanner from "../../components/EditionBanner.astro";
import Shot from '../../components/Shot.astro';
Namespaces
A namespace is the boundary everything lives inside: devices, members, identities, policies,
tags, settings. Two namespaces share nothing.
Name and Tenant ID
Every namespace has both, and they do different jobs.
The name is human-facing and can be changed. It must be a valid hostname — lowercase, no dots
— because it appears in an SSHID:
$ ssh [email protected]@cloud.shellhub.io
# ^^^^^^^^^ the namespace nameThe Tenant ID is an immutable UUID generated when the namespace is created. It is what devices,
API keys and sessions actually reference.
<Shot id="tenant-id" route="/settings/general" of={{ role: "group", name: "Tenant ID" }} alt="The Tenant ID setting, with the control that copies it" />
Renaming a namespace changes every SSHID for it. Anything with a stored SSHID — an SSH config
alias, an Ansible inventory, a script — breaks. The tenant ID does not change, so agent
configuration is unaffected.
Creating one
<Shot id="namespace-create" route="/devices" of={{ role: "dialog" }} edition="enterprise" before={[{ click: { role: "button", name: "Open a device, session or namespace" } }, { click: { text: "Create namespace" } }]} alt="The Create a Namespace dialog, asking for a name" />
Reasons to have more than one: separating customers, separating environments where the separation
must be absolute, or separating teams that should not see each other's devices. Within a single
namespace, tags and
access policies handle most separation without the
overhead of a second workspace.
Switching
Each namespace you open is a tab at the top of the console. To open another, click + next to
the tabs, or press Ctrl+K, and pick it from the list. On macOS the shortcut
is ⌘+K.
<Shot id="namespace-switch" route="/devices" edition="enterprise" before={[{ click: { role: "button", name: "Open a device, session or namespace" } }]} alt="The command palette open, listing the namespaces this account belongs to" />
Membership is per namespace, and so is your role — you can be an administrator in one and an
observer in another.
Legacy or new
A namespace is either legacy or new, which decides how connections are authorized and how devices
enroll. Namespaces created today are new. Namespaces predating identity-based access are legacy,
and can be upgraded once.
A new namespace never becomes legacy. See How Authorization Works and
Upgrading a Legacy Namespace.
Deleting
<Shot id="namespace-delete" route="/settings/general" of={{ role: "dialog" }} before={[{ click: { role: "button", name: "Delete namespace" } }]} alt="The Delete Namespace dialog, which asks for the namespace name before it will proceed" />
Deleting takes the devices, identities, policies and history with it. Only the owner can do it —
an administrator cannot.
Agents keep running on the devices. They no longer have a namespace to enroll into, so stop them
if the machines are staying in service.
Device limits
Self-hosted Community has no limit. The Cloud free plan is limited to 3 devices.
Where to go next
Ui Apps Docs Src Pages Manage Sessions (`ui/apps/docs/src/pages/manage/sessions.mdx`)
layout: ../../layouts/DocsLayout.astro title: Sessions
import Replay from '../../components/Replay.astro';
import Shot from '../../components/Shot.astro';
Sessions
Every connection through ShellHub is a session, and every session is recorded in the list —
whether or not the contents were recorded.
<Shot id="session-list" route="/sessions" of={{ role: "main" }} alt="The Sessions page listing active and historical SSH sessions" />
What a session tells you
| Who | The authenticated principal — a person or an automation |
| Which device | The device they reached |
| Which unix login | The user they connected as |
| When | Start, and end for a closed session |
| From where | The client address, resolved through SHELLHUB_SSH_PROXY_TRUSTED_IPS for SSH clients and SHELLHUB_PROXY_TRUSTED_IPS for the web terminal |
That "who" is the payoff of the identity model. In a legacy namespace the record could only name the
key that was used; here it names the principal the key belongs to. See
How Authorization Works.
A red username is worth stopping on
A username shown in red, with a warning triangle, marks a session that never
authenticated. A record is written when a connection arrives and flipped to authenticated
only once the SSH authentication succeeds, so a red row is an attempt that reached the server
and got no further.
One is ordinary — a wrong key, a client that gave up. A run of them against one device, or
from one address, is the thing this list exists to show you.
Session details
A session's own page adds the flow of the connection, the device it reached, and — when there is a
recording — the button that replays it. Active sessions can be inspected while they are still
running.
Sessions and authorization
Worth understanding together, because it explains something that otherwise looks like a bug.
Authorization happens once, when the connection is made. Revoking an identity, deleting a
policy or removing a member does not end a session already running. It was authorized when it
started, and it continues until it is closed.
So the sessions list is not only history — for live sessions it is the answer to "who is connected
right now", and the place to look after revoking somebody's access if you need to be sure they are
actually gone.
Recording
ShellHub can capture what happened in a terminal and play it back. It does this two ways, and
they answer different questions.
| Browser-local | Server-side | |
|---|---|---|
| Answers | What did I do | What did anyone do |
| Edition | Community, Cloud, Enterprise | Cloud and Enterprise |
| Where the recording lives | In the browser that opened the session | On the server |
| Covers | Web terminal sessions from that browser | Every session, including native SSH clients |
| Survives clearing site data | No | Yes |
| Available to other members | No | Yes |
Browser-local recording is in Community, and it is yours. The web terminal records the session
in the browser and keeps it there: no server storage and nobody else's eyes. Replay the work you
did on a machine last week, or find the step that broke it. It never leaves the browser you made
it in, which is the point.
Server-side recording is Cloud and Enterprise, and it is the namespace's. The server makes it,
so it covers every session from every client — native ssh included, not only the web terminal —
and keeps it where the people responsible for the fleet can replay it. That is the one that
answers "who touched this device, and what did they do". It is on from the moment the namespace
exists, and Settings turns it off; either way the change applies to new
sessions, and sessions already running are unaffected.
Playing one
Every row in the session list carries a Play button, and a recorded session repeats it as
Play Recording on its detail page. Either one opens the player, which replays the terminal in
real time — what was typed, what was displayed, and the timing between them:
From there it behaves like a video: play, pause, scrub, and slow it down or speed it up.
What a recording holds
Captured: everything the terminal displayed, everything typed, and terminal resizes.
Not captured: file transfer payloads, and forwarded port traffic. A recording will show
somebody ran scp; it will not show the file's contents. It will show they opened a forward; it
will not show what went through it.
Know that before relying on recordings for an audit requirement.
Deciding what gets recorded
A recording is a replay of a terminal, so it holds whatever that terminal showed. Two controls
decide how much of that exists and who reaches it.
Recording is a namespace setting, and a new namespace starts with it on. Turn it off in
Settings and the server records nothing from then on. Nothing obliges a
namespace to record, and a namespace that does not need an audit trail should not.
Playback is administrator and owner only. An operator can open a session and read the session
list, and still cannot replay one. A recording is not something everybody in the namespace can
watch — see User Roles. This is about recordings the server holds: a
browser-local one is your own data, and plays for you regardless.
What is not here
Denial reasons. A refused connection does not become a session — it was refused before the
device was contacted. The reason is not surfaced to the client either. See
Connection Denied.
Web endpoint traffic. A web endpoint is reached by URL, not by an
authenticated session, so requests to it do not appear here.
Where to go next
- Settings — Turning recording off.
- User Roles — Who may replay a recording.
- Connection Denied — When a session never happened.
Ui Apps Docs Src Pages Manage Settings (`ui/apps/docs/src/pages/manage/settings.mdx`)
layout: ../../layouts/DocsLayout.astro title: Settings
import Shot from '../../components/Shot.astro';
Settings
Namespace settings apply to everything inside the namespace. They are split into sections:
General (name, tenant ID, leaving or deleting the namespace), SSH (the settings below)
and, on Cloud, Billing. Provisioning keys live in Add Device → Fleet.
Session recording
Whether the server records new sessions in this namespace. A new namespace starts with it on.
Community records the web terminal in the browser regardless — this setting is about the
server-side recording available on Cloud and Enterprise. See
Sessions.
Connection announcement
A message shown in the terminal when somebody connects, labelled Login banner in the SSH
section and edited in a dialog from its Edit button.
Its most useful job is telling people recording is on. Somewhere between courtesy and a legal
requirement depending on where you operate, and it costs one sentence:
Sessions on this namespace are recorded.It is also a reasonable place for an environment warning — anything that makes somebody pause
before typing on the wrong machine.
Device signature enforcement
Whether the namespace refuses an agent that cannot prove it holds its device key. Since 0.27 the
agent signs a challenge from the server with its device key on every connection. Older agents
cannot sign, so with this on they stop connecting, devices already registered included, until
you update the agent.
A namespace created after this setting was introduced starts with it on, unless the instance setsSHELLHUB_NAMESPACE_REQUIRE_VERIFIED_DEVICE_KEY=false (see
Environment Variables). A namespace that already existed
when the server was updated starts with it off, so the update itself cuts no agent off.
While it is off, the setting warns how many accepted or pending devices have never signed.
Turning it on applies at once when there are none, and otherwise asks you to confirm that those
devices stop connecting. Turning it off applies at once, and a refused agent lands pending on its
next attempt.
The device list API reports whether each device has signed as key_verified, and filters on it.
A device that has signed once is never accepted unsigned again, whatever this setting says, until
it is removed.
An agent refused for not signing shows up at Pending devices with the reason when its device
is not in the namespace, or was removed from it. A device already in the list is refused without
a line there. See Device Not Appearing.
Legacy or new
Shows whether the namespace is legacy or new, and offers a legacy namespace a one-way upgrade to
the new model.
Namespaces created today are new and stay that way. A namespace that predates identity-based
access is legacy, and this page offers it one Upgrade to the new model. The upgrade asks for
the namespace's name, because it cannot be undone.
Upgrading with no policies seeds the Owner access policy so default deny does not lock the owner
out. Everyone else starts with no access, password and public-key logins stop, and new devices
enroll by pairing or a provisioning key.
Do not upgrade without preparing. See
Upgrading a Legacy Namespace.
MFA requirements
On Cloud and Enterprise, whether members must have MFA enabled. See
Multi-Factor Authentication.
Instance-wide settings
Some things are not namespace settings but deployment configuration, set on the server rather
than here. See Environment Variables.
Where to go next
- Namespaces — Name, tenant ID, deletion.
- Sessions — What recording captures.
Ui Apps Docs Src Pages Manage Team (`ui/apps/docs/src/pages/manage/team.mdx`)
layout: ../../layouts/DocsLayout.astro title: Members
import Callout from "../../components/Callout.astro";
import Shot from '../../components/Shot.astro';
Members
A namespace's members are the people who can sign in and see it. Manage them under Team.
<Shot id="members" route="/team" of={{ role: "main" }} alt="The Team page showing the Members tab with members and their roles" />
Adding someone
Invite them from the Team page and assign a role. The role decides what they can do in the
console — see User Roles.
On Community the invitation email is never sent, because the instance has no mail. The invitation
is still created and the console gives you its link to copy — send it however you like. Or skip
the person entirely: user create and namespace member add on the host put them in. See
Administration.
Membership and device access are different things
This is the distinction that matters most on this page, and the one people get wrong.
Being a member does not grant access to any device. In a new namespace, device access comes
entirely from access policies, and a new member matches
none of them.
So adding somebody is two steps:
- Add them as a member, with a role.
- Write or extend a policy that grants them something.
A member with no policy can sign in, see the namespace, and connect to nothing. That is deliberate
— default deny means access is something you grant on purpose.
The exception is the namespace owner, who is covered by the starter Owner access policy.
Roles as policy subjects
The cleanest way to handle step 2 is to grant the role, not the person:
| Name | Effect | Subject | Devices | Logins |
|---|---|---|---|---|
| Operators to fleet | allow | Role: operator | tag production |
deploy |
Now adding somebody as an operator grants them the fleet, and removing them takes it away, with no
policy edited either time. Per-person policies are for genuine exceptions.
Removing someone
Removing a member ends their access to the namespace.
Two things to do alongside it:
Revoke their SSH identities. Membership and identities are separate records. Revoke the keys
bound to them on the SSH Identities page.
Check for automation on their key. If a pipeline was configured with a key of theirs, it stops
working. That is why automation should connect as an API key with
its own SSH access instead.
Where to go next
- User Roles — What each role can do.
- Access Policies — Granting device access.
- Single Sign-On (SAML) — Bringing members from an IdP.
Ui Apps Docs Src Pages Selfhosted Administration (`ui/apps/docs/src/pages/selfhosted/administration.mdx`)
layout: ../../layouts/DocsLayout.astro title: Administration
import Callout from "../../components/Callout.astro";
import EditionBanner from "../../components/EditionBanner.astro";
Administration
Most of ShellHub is scoped to a namespace. Administering the instance — every account, every
namespace — is done from the host it runs on, with the admin commands built into the server
binary. They write to the database directly, without going through the API.
Running a command
$ ./bin/cli user listbin/cli runs the command inside the server container, so there is nothing separate to install.
It refuses to run when the stack is down, with ShellHub is not running.
Users
| Command | Purpose |
|---|---|
user create |
Create a user |
user password |
Change a user's password |
user delete |
Delete a user |
user list |
List all users |
$ ./bin/cli user create john_doe Secret123!- [email protected]Add --admin to create the user as a superadmin. The flag applies at creation: there is no
command that promotes an existing account. Usernames and emails are lowercased before they are
stored, so two accounts cannot differ by case alone.
A superadmin administers the instance. It does not reach a device: an
access policy still decides that, exactly as for
anybody else.
Resetting a password
$ ./bin/cli user password john_doe NewSecret123!-Password recovery by email is a Cloud capability. On a self-hosted instance this command is the
recovery path, and it is the reason an operator needs shell access to the host.
Namespaces
| Command | Purpose |
|---|---|
namespace create <namespace> <owner> [tenant] |
Create a namespace |
namespace delete <namespace> |
Delete a namespace |
namespace list |
List namespaces |
namespace inspect <namespace> |
Show one namespace in full |
$ ./bin/cli namespace create engineering john_doeThe tenant identifier is optional; one is generated when it is omitted.
| Flag | Default | Meaning |
|---|---|---|
--type |
team |
The namespace type |
Every namespace the CLI creates is new: identities and access policies decide SSH access, and
devices enroll by pairing or a provisioning key. Only a namespace that predates identity-based
access is legacy. See Upgrading a Legacy Namespace.
Deleting a namespace removes its devices, identities, policies and session records.
Members
| Command | Purpose |
|---|---|
namespace member add <namespace> <role> |
Add a member |
namespace member remove <namespace> |
Remove a member |
$ ./bin/cli namespace member add myuser mynamespace observerThe role is one of observer, operator, administrator or owner. Adding a member grants
access to no device — a member reaches nothing until an
access policy grants it. On Community this is also how
the first member joins the one namespace, since invitations need mail the instance does not have.
Where to go next
- User Roles — What each role permits inside a namespace.
- Environment Variables — Everything the composition reads.
Ui Apps Docs Src Pages Selfhosted Backup (`ui/apps/docs/src/pages/selfhosted/backup.mdx`)
layout: ../../layouts/DocsLayout.astro title: Backup
import Callout from "../../components/Callout.astro";
Backup
Nothing backs itself up. This page is short and you should act on it before you need it.
What has to be backed up
PostgreSQL. Devices, identities, access policies, members, namespaces, sessions. This is the
deployment — without it, the agents are still running out there and nothing knows what they are.
The secrets from make keygen. The API key pair and the SSH host key. Losing the SSH host key
means every client that has connected before refuses to connect, because the server's host key
changed.
Object storage, on Enterprise. Session recordings live in MinIO
or whatever S3-compatible storage you configured, and that storage grows with session activity.
A database dump does not include them.
Your .env.override. Small, easily recreated, easily forgotten at the worst moment.
Dumping the database
Using the default credentials — admin / main, or whatever you set inSHELLHUB_POSTGRES_USERNAME and SHELLHUB_POSTGRES_DATABASE:
$ ./bin/docker-compose exec postgres pg_dump -U admin main | gzip > backup.gzRestoring:
$ gunzip -c backup.gz | ./bin/docker-compose exec -T postgres psql -U admin mainTest the restore
A backup nobody has restored is a hypothesis.
Restore into a scratch instance and check that devices, members and policies are all there. Do
that once, now, rather than during an incident.
What a dump does not cover
- Session recordings — separate storage, separate backup.
- Docker volumes generally — nothing backs them up automatically.
- The secrets — they are files, and
pg_dumphas never heard of them.
Automating it
Whatever you already use for scheduled jobs is the right answer. What matters is that the output
lands somewhere that is not the host running ShellHub — a backup on the machine you are protecting
against losing is not a backup.
Keep more than one, so a corrupt dump taken last night is not your only copy.
Where to go next
- Environment Variables — Where the credentials above come from.
- Upgrade — Back up first.
Ui Apps Docs Src Pages Selfhosted Environment Variables (`ui/apps/docs/src/pages/selfhosted/environment-variables.mdx`)
layout: ../../layouts/DocsLayout.astro title: Environment Variables
import Callout from "../../components/Callout.astro";
Environment Variables
Configuration for a self-hosted server. For the agent's variables, see
Agent Configuration.
Where to put them
Files are read in order, and the last one wins:
.env— the defaults. Do not edit this; an upgrade overwrites it..env.enterprise— Enterprise defaults, when the edition is not community.../cloud/.env— when present, for Cloud deployments..env.override— yours, and it wins over everything above.
Put every change in .env.override.
# .env.override
SHELLHUB_DOMAIN=shellhub.example.com
SHELLHUB_AUTO_SSL=true
SHELLHUB_POSTGRES_PASSWORD=<not-admin>Network
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_BIND_ADDRESS |
0.0.0.0 |
Address the gateway binds |
SHELLHUB_HTTP_PORT |
80 |
HTTP port |
SHELLHUB_HTTPS_PORT |
443 |
HTTPS port. Only bound when SHELLHUB_AUTO_SSL=true |
SHELLHUB_SSH_PORT |
22 |
SSH port. Published by the server, not the gateway |
SHELLHUB_DOMAIN |
localhost |
Public domain. Required for auto-SSL |
SHELLHUB_NETWORK |
shellhub_network |
Docker network name. Change to run several instances on one host |
SHELLHUB_NETWORK_EXTERNAL |
false |
Join SHELLHUB_NETWORK as an existing network instead of creating it. It must exist before the stack starts, and down leaves it in place |
TLS
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_AUTO_SSL |
false |
Obtain certificates from Let's Encrypt |
SHELLHUB_TLS_CERT_FILE |
(empty) | Path to your own certificate, inside the gateway container |
SHELLHUB_TLS_KEY_FILE |
(empty) | Its key. Set both or the gateway will not start |
SHELLHUB_ACME_CA_SERVER |
(empty) | Alternative ACME directory. Empty means Let's Encrypt production |
Behind a proxy
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_PROXY |
false |
Accept the PROXY protocol from a Layer 4 balancer |
SHELLHUB_PROXY_TRUSTED_IPS |
0.0.0.0/0 ::/0 |
Space-separated CIDRs allowed to declare a client address |
SHELLHUB_SSH_PROXY_TRUSTED_IPS |
(empty) | Space-separated CIDRs allowed to send a PROXY protocol header to the SSH port, besides loopback |
SHELLHUB_PUBLIC_URL |
built from SHELLHUB_DOMAIN and SHELLHUB_AUTO_SSL |
URL users reach ShellHub at, such as https://shellhub.example.com. An http or https origin with no path |
SHELLHUB_TUNNEL_PING_INTERVAL |
(empty) | How often the server pings each device's tunnel, as a duration such as 15s. Empty keeps each transport's default, 30s on v1 tunnels and 35s on v2 tunnels |
ShellHub builds the links it hands out, such as the SSH approval links in the terminal and the SAML
URLs it sends an identity provider, from SHELLHUB_DOMAIN, with https:// only whenSHELLHUB_AUTO_SSL=true. When TLS ends at an ingress or load balancer in front of the gateway, setSHELLHUB_PUBLIC_URL so those links use https:// without the gateway requesting certificates.
SHELLHUB_PROXY and SHELLHUB_PROXY_TRUSTED_IPS apply to the gateway, which serves HTTP and
HTTPS. SHELLHUB_PROXY_TRUSTED_IPS decides the client address of web sign-in and of web terminal
sessions, so their lockout, firewall rules and access policy source IPs depend on it. The default
trusts everyone, which is fine only if nothing but your balancer can reach the port.
The SSH port reads only SHELLHUB_SSH_PROXY_TRUSTED_IPS, which decides the client address of SSH
clients that connect to it. It closes a connection whose PROXY header comes from a peer outside
those ranges and loopback, and logs a warning with that peer's
address. List your balancer's range. Under Docker Compose, do not list the Docker bridge range:
the userland proxy makes every external client appear to come from it, so any client could forge
its address.
A NAT, load balancer or proxy that drops idle connections in under 30 seconds closes device tunnels
between two pings. Set SHELLHUB_TUNNEL_PING_INTERVAL below its idle timeout. Each answered ping
also marks the device as seen, and a device reads offline two minutes after the last one, so an
interval above 40 seconds tolerates fewer than three missed pings. The server refuses to start on a
value that is not a positive duration under one minute.
Database
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_DATABASE |
postgres |
Anything else warns and falls back to postgres |
SHELLHUB_POSTGRES_HOST |
postgres |
|
SHELLHUB_POSTGRES_EXTERNAL |
false |
Use a PostgreSQL outside this Compose project, at SHELLHUB_POSTGRES_HOST. The bundled postgres service does not start |
SHELLHUB_POSTGRES_PORT |
5432 |
|
SHELLHUB_POSTGRES_EXTRA_ARGS |
(empty) | Extra flags appended to the postgres command, for sizing the database to the host |
SHELLHUB_POSTGRES_USERNAME |
admin |
Note: _USERNAME, not _USER |
SHELLHUB_POSTGRES_PASSWORD |
admin |
Change this |
SHELLHUB_POSTGRES_DATABASE |
main |
Note: _DATABASE, not _DB |
SHELLHUB_POSTGRES_LOG_LEVEL |
INFO |
|
SHELLHUB_POSTGRES_LOG_VERBOSE |
false |
Logging
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_LOG_LEVEL |
info |
trace, debug, info, warn, error, fatal |
SHELLHUB_LOG_FORMAT |
text |
text or json |
SHELLHUB_GATEWAY_ACCESS_LOGS |
true |
|
SHELLHUB_SENTRY_DSN |
(empty) |
Features and behavior
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_EDITION |
community |
community, enterprise, or cloud |
SHELLHUB_VERSION |
the release you checked out | Which image tag to run |
SHELLHUB_METRICS |
false |
Metrics endpoint |
SHELLHUB_PAYWALL |
true |
Show Cloud/Enterprise features to Community users |
SHELLHUB_MAXIMUM_ACCOUNT_LOCKOUT |
60 |
Maximum lockout minutes after repeated failed sign-ins, 0 disables |
SHELLHUB_ACCOUNT_LOCKOUT_BASE |
1m |
First lockout's length as a Go duration, at least 1s; each later one is four times longer |
SHELLHUB_PERMIT_EMPTY_PASSWORDS |
false |
Agent-side |
SHELLHUB_REDIS_CACHE_POOL_SIZE |
0 |
0 means the driver default |
SHELLHUB_WORKER_SCHEDULE |
@daily |
Cron format |
SHELLHUB_ASYNQ_UNIQUENESS_TIMEOUT |
24 |
Hours; 0 or less disables uniqueness |
SHELLHUB_ONBOARDING_URL |
the ShellHub survey workspace | Client API root of the survey workspace (https://<host>/api/v1/client/<workspaceId>); empty disables the survey |
SHELLHUB_PROVISIONING_KEY_WEBHOOK_ALLOWED_CIDRS |
(empty) | Empty blocks all private, loopback and metadata addresses |
SHELLHUB_ALLOW_PUBLIC_KEY_ACCESS_BELLOW_0_6_0 |
false |
Permit public-key auth from agents older than 0.6.0 |
SHELLHUB_NAMESPACE_REQUIRE_VERIFIED_DEVICE_KEY |
true |
Whether a namespace created from now on starts with Device signature enforcement on. Namespaces that already exist keep their own setting |
SHELLHUB_TELEMETRY |
true |
false turns telemetry off and overrides the admin console setting. The server then opens no connection to the collector |
Enterprise
From .env.enterprise, and only meaningful when the edition is not community.
| Variable | Default | Meaning |
|---|---|---|
SHELLHUB_ENTERPRISE_ADMIN_USERNAME |
(empty) | Enterprise Admin Console |
SHELLHUB_ENTERPRISE_ADMIN_PASSWORD |
(empty) | |
SHELLHUB_SAML_SECRET |
(empty) | Encrypts the SP's X.509 private key |
SHELLHUB_WEB_ENDPOINTS |
false |
Enable web endpoints |
SHELLHUB_WEB_ENDPOINTS_DOMAIN |
falls back to SHELLHUB_DOMAIN |
Domain endpoints are published under |
SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER |
digitalocean |
DigitalOcean, Cloudflare, or acme-dns |
SHELLHUB_WEB_ENDPOINTS_DNS_PROVIDER_TOKEN |
(empty) | Required with auto-SSL |
SHELLHUB_OBJECT_STORAGE_ENDPOINT |
http://minio:9000 |
Session recording storage |
SHELLHUB_OBJECT_STORAGE_REGION |
default |
|
SHELLHUB_OBJECT_STORAGE_BUCKET |
shellhub |
|
SHELLHUB_OBJECT_STORAGE_ACCESS_KEY |
username |
Change this |
SHELLHUB_OBJECT_STORAGE_SECRET_KEY |
password |
Change this |
SHELLHUB_MAXMIND_MIRROR |
(empty) | GeoIP source |
SHELLHUB_MAXMIND_LICENSE |
(empty) |
Applying changes
$ make restartWhere to go next
- Ports and Connectivity — What the network variables mean in practice.
- Backup — Using the database credentials above.
Ui Apps Docs Src Pages Selfhosted Telemetry (`ui/apps/docs/src/pages/selfhosted/telemetry.mdx`)
layout: ../../layouts/DocsLayout.astro title: Telemetry
Telemetry
A ShellHub server sends a few usage counts to telemetry.shellhub.io. We use them to learn how many
instances run, which versions and editions they run, and how large they get. Telemetry is on by
default, and one setting turns it off.
What is sent
Every edition sends two events, and ShellHub Cloud sends a third. All of them are identified by a
random instance ID that the database generates once. Nothing in them names a person, a namespace, a
device or a host.
instance started, each time the server starts:
| Property | Example | Meaning |
|---|---|---|
edition |
community |
community, enterprise or cloud |
version |
v0.27.0 |
The server's SHELLHUB_VERSION |
os |
linux |
Operating system the server runs on |
arch |
amd64 |
CPU architecture |
database |
postgres |
Database type |
instance heartbeat, once a day at 05:30 UTC, with the properties above plus:
| Property | Meaning |
|---|---|
namespaces |
Namespaces on the instance |
users |
User accounts on the instance |
devices_accepted |
Accepted devices |
devices_pending |
Devices waiting for acceptance |
devices_rejected |
Rejected devices |
sessions |
Sessions started in the last 24 hours |
sessions_interactive |
Of those, sessions that opened a shell with a terminal |
sessions_shell_without_pty |
Sessions that opened a shell without a terminal, as a heredoc does |
sessions_exec |
Sessions that ran a command other than scp |
sessions_scp |
Sessions that ran scp as a command. An scp that speaks SFTP counts as SFTP |
sessions_sftp |
Sessions that opened the SFTP subsystem |
sessions_port_forwarding |
Sessions that forwarded a port to the device |
sessions_agent_forwarding |
Sessions that forwarded the SSH agent |
sessions_web |
Sessions opened from the console's web terminal |
sessions_recorded |
Sessions that were recorded |
sessions_api_key |
Sessions opened with an API key |
sessions_unauthenticated |
Sessions that never got past authentication |
sessions_active |
Sessions open now, whenever they started |
sessions_devices |
Distinct devices the authenticated sessions reached |
sessions_events |
Events the sessions recorded, such as a shell, a command or a port forward |
sessions_duration_seconds |
Total time the authenticated sessions lasted, in seconds |
sessions_duration_median_seconds |
Median time an authenticated session lasted, in seconds |
sessions_duration_p95_seconds |
Time 95% of the authenticated sessions lasted at most, in seconds |
namespaces_legacy |
Namespaces still on public keys and firewall rules rather than SSH identities and access policies |
namespaces_personal |
Personal namespaces |
namespaces_team |
Team namespaces |
namespaces_recording |
Namespaces that record sessions |
namespaces_require_verified_device_key |
Namespaces that require a verified device key |
devices_online |
Accepted devices online now |
devices_ephemeral |
Accepted ephemeral devices |
devices_owned |
Accepted devices with an owner |
devices_custom_fields |
Accepted devices with custom fields |
devices_tagged |
Accepted devices with at least one tag |
devices_key_verified |
Accepted devices whose key is verified |
devices_platforms |
Accepted devices by agent platform: docker, bundle, native or connector, such as {"docker": 7, "native": 3} |
devices_versions |
Accepted devices by agent release, cut to major and minor, such as v0.27 |
devices_architectures |
Accepted devices by CPU architecture, as Go names it, such as amd64 |
devices_oses |
Accepted devices by operating system ID, such as ubuntu, from a fixed list of distributions |
devices_enrolled_with |
Accepted devices by the kind of key they enrolled with: user, legacy or pairing |
access_policies |
Access policies |
public_keys |
Public keys |
api_keys |
Namespace API keys |
instance_api_keys |
Instance API keys |
tags |
Tags |
ssh_identities |
SSH identities by source: manual, browser or approval |
provisioning_keys |
User provisioning keys not revoked, by mode |
enrollments |
Enrollment attempts with a provisioning key in the last 24 hours |
pending_invitations |
Namespace invitations not yet answered |
authentication_local |
Whether login with a password is on |
authentication_saml |
Whether SAML login is on |
users_active |
Users who logged in during the last 24 hours |
users_by_origin |
Users by where their account comes from: local or saml |
A session that did several things counts under each of them, and one still open counts its time up to
the last moment the server saw it. In the counts by value, a device that reported no value counts asunknown, and one that reported a value outside the known set counts as other, so a custom
operating system ID or build version never leaves the instance.
Enterprise and ShellHub Cloud add:
| Property | Meaning |
|---|---|
firewall_rules |
Firewall rules |
users_mfa |
Users with multi-factor authentication on |
web_endpoints |
Web endpoints |
web_endpoints_created |
Web endpoints created or renewed in the last 24 hours |
web_endpoints_tls |
Web endpoints that reach the device over TLS |
namespace heartbeat, ShellHub Cloud only, one per namespace with each instance heartbeat:
| Property | Meaning |
|---|---|
$groups |
{"namespace": "<hash>"}: an HMAC-SHA256 of the namespace's tenant ID under a key the collector does not hold, so it cannot recover the tenant ID |
members |
Members of the namespace |
devices_accepted |
Accepted devices |
devices_pending |
Devices waiting for acceptance |
devices_rejected |
Rejected devices |
sessions |
Sessions started in the last 24 hours |
Every event also carries schema_version, which changes when a property changes meaning. The
server sends events in PostHog's capture v1 format, asks the collector to create no person profile,
and the PostHog client adds an event ID, a timestamp and these properties:
| Property | Example | Meaning |
|---|---|---|
$os |
Linux |
Operating system the server runs on |
$os_version |
3.24.0 |
Its version, when the host reports one |
$os_distro |
alpine |
Its distribution, when the host reports one. In the official image this is the container's distribution, not the host's |
$go_version |
go1.26.9 |
Go version the server was built with |
$is_server |
true |
Marks the event as sent by a server |
$geoip_disable |
true |
Asks the collector not to look up a location |
What is not sent
No IP addresses, hostnames, domains, email addresses, usernames, namespace names, device names,
session contents or command lines. The collector drops the address the request came from before it
stores anything.
How long it is kept
The collector keeps each event for 365 days. After that only monthly totals remain, with no
instance ID. Cloud and Enterprise customers can have an instance's events deleted sooner through
their support channel.
The instance ID, the distinct_id in the payload preview, lets whoever holds it send events as
your instance. The server shows it nowhere else. Do not post it in a public issue.
Seeing the exact payload
The Admin Console on Enterprise and Cloud shows the events the next heartbeat will send, under
Settings > Telemetry.
Turning it off
On any edition, set SHELLHUB_TELEMETRY=false in .env.override and restart:
# .env.override
SHELLHUB_TELEMETRY=false$ make restartThe server then opens no connection to the collector. The startup log says which way it is set:
level=info msg="telemetry is off: SHELLHUB_TELEMETRY is false"On Enterprise and Cloud, an administrator can also turn it off in the Admin Console under
Settings > Telemetry, without a restart. The change applies to the next event. WhenSHELLHUB_TELEMETRY=false is set, or the server could not set up telemetry, the console shows the
switch as disabled by the environment and the server log says why.
Turning telemetry off changes nothing else. The server sends no final event when you do.
Ui Apps Docs Src Pages Selfhosted Troubleshooting (`ui/apps/docs/src/pages/selfhosted/troubleshooting.mdx`)
layout: ../../layouts/DocsLayout.astro title: Troubleshooting
Troubleshooting a Self-Hosted Server
Problems with the server. For a device that will not appear, see
Device Not Appearing; for a connection that is refused, see
Connection Denied.
Start here
$ ./bin/docker-compose ps
$ ./bin/docker-compose logs -fNote the wrapper. ./bin/docker-compose, not bare docker compose — the wrapper assembles the
compose files and the environment chain, and running the plain command gives you a different and
misleading picture.
The server starts with no database
Symptom: the server cannot reach PostgreSQL, and ps shows no postgres container at all.
Cause: you ran bare docker compose. PostgreSQL comes from an overlay the wrapper appends;
without it, the service is simply not in the composition.
Fix: use ./bin/docker-compose, or make start.
Cannot connect to the database
Check the variable names, because two of them are commonly written wrong:
| Right | Wrong |
|---|---|
SHELLHUB_POSTGRES_USERNAME |
SHELLHUB_POSTGRES_USER |
SHELLHUB_POSTGRES_DATABASE |
SHELLHUB_POSTGRES_DB |
Defaults are admin / admin / main. A variable with the wrong name is not an error — it is
simply ignored, and the default is used, which is why this fails in a confusing way.
The gateway will not start
With SHELLHUB_TLS_CERT_FILE or SHELLHUB_TLS_KEY_FILE set: you must set both. One
alone stops the gateway from starting.
With SHELLHUB_AUTO_SSL=true: the certificate challenge requires the server to be reachable
at SHELLHUB_DOMAIN. Check the domain resolves to this host and that nothing is blocking the
port.
HTTPS is not listening
The HTTPS port is only bound when SHELLHUB_AUTO_SSL=true — the port mapping comes from an
overlay applied only in that case.
If you terminate TLS elsewhere, this is correct: publish HTTP and let your proxy hold the
certificate.
SSH connections do not arrive, but the console works
The SSH port is published by the server service, not the gateway. A load balancer or firewall
configured only for the gateway's HTTP ports will pass web traffic and drop SSH.
SSH is not HTTP and will not survive an HTTP proxy — it needs its own path through.
Every client appears to come from the same address
You are behind a proxy or load balancer and ShellHub is not reading the client address from its
PROXY protocol header. For web traffic, SHELLHUB_PROXY is not set on the gateway, orSHELLHUB_PROXY_TRUSTED_IPS does not include the balancer. For SSH, the header does not reach the
server: the balancer sends none, or decodes it itself. Enable PROXY protocol on the SSH path, pass
the header through unread, and list the balancer in SHELLHUB_SSH_PROXY_TRUSTED_IPS.
This is worth fixing even if it looks cosmetic: access policy source IPs, firewall rules and the
session audit trail read the client address of SSH and web terminal sessions, and password lockout
reads it at web sign-in. Left wrong, a lockout on an account applies to everyone signing in to it.
SSH closes before the server banner behind a load balancer
The client prints kex_exchange_identification: Connection closed by remote host, and the server
logs closed an ssh connection whose PROXY protocol header came from an untrusted peer. The
balancer sends a PROXY protocol header from an address the SSH port does not trust. Add the IP
from the peer field of that log line, without its port, or a range that contains it, toSHELLHUB_SSH_PROXY_TRUSTED_IPS.
Configuration changes have no effect
Check which file. .env is overwritten by upgrades and read before .env.override. Put
changes in .env.override, which is read last and wins.
Check the name. A misspelled variable is ignored silently.
Restart:
$ make restartTurning up the logs
# .env.override
SHELLHUB_LOG_LEVEL=debug
SHELLHUB_LOG_FORMAT=jsonThen make restart. Put it back when you are done — debug is noisy.
Where to go next
- Environment Variables — Real names and defaults.
- Ports and Connectivity — What has to be reachable.
- Getting Support — When it is not any of this.
Ui Apps Docs Src Pages Selfhosted Upgrade (`ui/apps/docs/src/pages/selfhosted/upgrade.mdx`)
layout: ../../layouts/DocsLayout.astro title: Upgrade
import { Code } from "astro:components";
import Callout from "../../components/Callout.astro";
import { releaseTag } from "../../data/version";
Upgrade
SHELLHUB_VERSION decides which image tag every service runs. Changing it and recreating the
containers is the upgrade; everything else on this page is what to do around that.
Before you start
Back up. The database and the secrets, and verify the dump is readable. See
Backup.
Read the release notes for every version between where you are and where you are going, not
just the target. Migrations and breaking changes live there.
Know your current version:
$ grep SHELLHUB_VERSION .envThe shape of it
Get the new sources:
<Code lang="bash" code={$ git fetch --tags $ git checkout ${releaseTag}} />
Every tag is on the releases page, and a
release two or three versions ahead is a fine target as long as you read what changed in between.
Your settings survive, because they are in .env.override and not in .env. If you edited .env
directly, the checkout has just overwritten your changes — which is why the quickstart says not
to.
Then pull and recreate:
$ ./bin/docker-compose pull
$ make stop
$ make startAfterwards
Check the services are up and the logs are clean:
$ ./bin/docker-compose ps
$ ./bin/docker-compose logs -fThen check a device actually connects. Services being up is not the same as the product working —
open a session before you call it done.
Agent versions
Agents do not have to match the server, and updating the server does not update them. Snap
installs refresh themselves; container and standalone installs stay where they are until you move
them.
One hard boundary: the server refuses public-key authentication from agents older than 0.6.0
unless SHELLHUB_ALLOW_PUBLIC_KEY_ACCESS_BELLOW_0_6_0 is set — note the spelling.
If it goes wrong
Check the tag back out, pull, and restart. If a migration ran, restore the database from the
backup you took — which is why the first instruction on this page is to take one.
Where to go next
- Backup — Do this first.
- Troubleshooting — When it does not come back up.
Ui Apps Docs Src Pages Get Started Install Build From Source (`ui/apps/docs/src/pages/get-started/install/build-from-source.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Building from Source
Building the Agent from Source
The agent is a single Go program in the ShellHub repository. Build it when you need an
architecture we do not publish, a patched build, or reproducibility you control.
Requirements
Go, at the version pinned in agent/go.mod. Check it rather than trusting this page:
$ head -3 agent/go.modBuild
$ git clone https://github.com/shellhub-io/shellhub.git
$ cd shellhub/agent
$ CGO_ENABLED=0 go build \
-ldflags "-X main.AgentVersion=$(git describe --tags --abbrev=0)" \
-o shellhub-agent .The version has to be compiled in: the agent validates it at startup, and a binary built without
it stops immediately with invalid semantic version. CGO_ENABLED=0 is what makes the result a
static binary with no runtime dependencies.
Cross-compiling
Go cross-compiles without a toolchain per target:
$ export CGO_ENABLED=0 LDFLAGS="-X main.AgentVersion=$(git describe --tags --abbrev=0)"
$ GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o shellhub-agent-arm64 .
$ GOOS=linux GOARCH=arm GOARM=7 go build -ldflags "$LDFLAGS" -o shellhub-agent-armv7 .
$ GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o shellhub-agent-armv6 .The published builds cover amd64, armv6, armv7, arm64 and 386.
Running it
The binary reads its configuration from the environment:
$ SHELLHUB_SERVER_ADDRESS=https://shellhub.example.com \
SHELLHUB_PROVISIONING_KEY=<your-provisioning-key> \
SHELLHUB_PRIVATE_KEY=/etc/shellhub.key \
sudo -E ./shellhub-agentFor anything permanent, put it under a service manager. The
standalone install shows the systemd shape.
Matching the server version
The server reports the agent version it expects at /info:
$ curl -s https://cloud.shellhub.io/infoBuilding from a tag matching your server avoids surprises. The agent is generally tolerant across
versions, but the server does refuse public-key authentication from agents older than 0.6.0
unless SHELLHUB_ALLOW_PUBLIC_KEY_ACCESS_BELLOW_0_6_0 is set — note the spelling of that
variable, the typo is real.
Where to go next
- Agent Configuration — Every variable.
Ui Apps Docs Src Pages Get Started Install Buildroot (`ui/apps/docs/src/pages/get-started/install/buildroot.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Buildroot
import Callout from "../../../components/Callout.astro";
Buildroot
ShellHub maintains a Buildroot external tree, so a manufactured unit arrives with the
agent already on it. Use it rather than writing a package yourself.
Prerequisites
- A Buildroot tree
- A provisioning key, so units register themselves with nobody
present
Adding the external tree
$ git clone git://git.buildroot.net/buildroot
$ git clone https://github.com/shellhub-io/buildroot.git shellhubPoint Buildroot at it with BR2_EXTERNAL, here against the emulated target:
$ cd buildroot
$ make BR2_EXTERNAL=../shellhub qemu_x86_64_defconfigSelecting the package
$ make BR2_EXTERNAL=../shellhub menuconfigSelect shellhub under External options, then build.
The package needs a toolchain with threads and Go support for your target
architecture, and pulls in ca-certificates and libxcrypt. Buildroot selects both for
you.
The external tree pins the agent version it builds. Check that pin against the version
your server expects, which it publishes at /info.
Configuring it
Configuration is a root filesystem overlay. Write the file the service reads:
$ cat > rootfs_overlay/etc/default/shellhub-agent <<-EOF
SERVER_ADDRESS="https://cloud.shellhub.io"
PRIVATE_KEY="/etc/shellhub-agent.key"
PROVISIONING_KEY="<your-provisioning-key>"
EOFTENANT_ID is the deprecated alternative to PROVISIONING_KEY, and the two do not go together.
Set the provisioning key.
Why the tenant is the wrong one. A device registering with the tenant alone goes
through the namespace's legacy registration path, which holds it for manual review —
so a production run arrives as a queue of pending devices rather than a working fleet. A
provisioning key also carries tags, so a policy can cover the batch before anybody opens the
console. See Using provisioning keys.
The key must be writable and must not be in the overlay
The agent generates its device key when the file is missing. Two consequences, and both
of them bite in production:
PRIVATE_KEY has to point somewhere writable that survives a reboot. On a read-only
root filesystem, or a path on a volatile partition, every boot produces a new identity and
a new device in the console.
Never put a generated key in the overlay. Every unit written from that image claims
the same identity, and you get a single device that appears to move between machines
instead of a fleet. The overlay carries the configuration; the key is created on the
device, once.
Init systems
The package installs a systemd unit or an S42shellhub sysvinit script, following the
init system your configuration selects. Neither needs configuration beyond the overlay
above.
Verifying
Write the image to two devices and start both.
Expected result: Two devices appear, each carrying the provisioning key's tags, and each with
its own identity.
Troubleshooting
| Problem | Solution |
|---|---|
| A new device appears at every boot | PRIVATE_KEY is not writable, or does not survive a reboot |
| One device that keeps changing machine | A key was baked into the overlay. Remove it |
| Devices arrive pending instead of accepted | The overlay sets TENANT_ID rather than PROVISIONING_KEY, or the key's mode is manual |
Where to go next
Ui Apps Docs Src Pages Get Started Install Freebsd (`ui/apps/docs/src/pages/get-started/install/freebsd.mdx`)
layout: ../../../layouts/DocsLayout.astro title: FreeBSD
Installing on FreeBSD
FreeBSD is supported, but not by the install script. If you run it there, the installer stops and
points you here:
Automatic installation is not supported on FreeBSD. Please refer to the ShellHub port at
https://github.com/shellhub-io/ports
The port
The port lives in github.com/shellhub-io/ports, not in
the official FreeBSD ports tree. There is no binary package — you build from source.
$ git clone https://github.com/shellhub-io/ports.git /usr/local/shellhub-ports
$ cd /usr/local/shellhub-ports/shellhub
$ make install cleanThe port pulls Go as a build dependency and installs the agent as an rc.d service.
Configuration
In /etc/rc.conf:
shellhub_enable="YES"
shellhub_server_address="https://cloud.shellhub.io"
shellhub_tenant_id="<your-tenant-id>"Both the server address and the tenant ID are required — the rc.d script validates them at
startup and exits with an error if either is missing.
The script names the namespace by its tenant ID and has no setting for a provisioning key, so a
FreeBSD device always lands pending until somebody accepts it.
The agent generates a private key on first run and stores it at shellhub_private_key, which
defaults to /usr/local/etc/shellhub.key. That key is the device's identity: delete it and the
device re-registers as a new one, so it belongs on persistent storage.
Managing the service
$ service shellhub start
$ service shellhub stop
$ service shellhub restart
$ service shellhub statusUpdating
The port has no package manager behind it, so an update is a rebuild:
$ cd /usr/local/shellhub-ports && git pull
$ cd shellhub
$ make deinstall
$ make install clean
$ service shellhub restartWhere to go next
- Quickstart — Accepting it into a namespace.
- Agent Configuration — The variables behind the rc.conf names.
Ui Apps Docs Src Pages Get Started Install Raspberry Pi (`ui/apps/docs/src/pages/get-started/install/raspberry-pi.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Raspberry Pi
Raspberry Pi
Prepare a card image that registers itself on first boot, for units you will not have
hands on again.
Prerequisites
- A provisioning key. Pairing waits for someone to accept each
device in the browser, so it does not suit an image - A reference card you will re-image
Installing the agent
$ curl -sSf https://cloud.shellhub.io/install.sh | PROVISIONING_KEY=<your-provisioning-key> INSTALL_METHOD=standalone shRemoving the device key before imaging
$ sudo systemctl stop shellhub-agent
$ sudo rm -f /etc/shellhub.keyThe device key is the identity of the device. An image containing one assigns the same
identity to every card written from it.
Verifying
Write two cards and start both.
Expected result: Two devices appear in the console, each carrying the tags configured
on the provisioning key.
Where to go next
Ui Apps Docs Src Pages Get Started Install Yocto (`ui/apps/docs/src/pages/get-started/install/yocto.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Yocto Project
import Callout from "../../../components/Callout.astro";
Yocto Project
ShellHub maintains an OpenEmbedded layer, so the agent ships with your firmware without
you writing a recipe. Use it rather than packaging the agent yourself.
Prerequisites
- A Yocto build environment on a supported release. The layer declares compatibility with
styhead,whinlatterandwrynose - A provisioning key, so units register themselves with nobody
present
Adding the layer
$ git clone https://github.com/shellhub-io/meta-shellhub.git
$ bitbake-layers add-layer ../meta-shellhubThe layer depends only on meta from openembedded-core, so it drops into an existing
build without pulling anything else in.
Adding the agent to the image
IMAGE_INSTALL:append = " shellhub-agent"There is also packagegroup-shellhub-runtime, which pulls the agent and its runtime
dependencies. Either works; the packagegroup is the one to prefer when your image is
assembled from packagegroups already.
The agent's own dependencies come with it — openssh-scp so file transfer works, andshadow so the login you connect as can be resolved.
Configuring it
Set these in local.conf or your distro configuration:
SHELLHUB_SERVER_ADDRESS = "https://cloud.shellhub.io"
SHELLHUB_PROVISIONING_KEY = "<your-provisioning-key>"| Variable | Default |
|---|---|
SHELLHUB_SERVER_ADDRESS |
https://cloud.shellhub.io |
SHELLHUB_PRIVATE_KEY |
/etc/shellhub-agent.key |
SHELLHUB_PROVISIONING_KEY |
(none) |
SHELLHUB_TENANT_ID |
(none, deprecated) |
These are written into /etc/default/shellhub-agent, which the service reads at start.
One of the two registration variables is required — the configuration recipe stops the
build rather than producing an image that registers nowhere.
SHELLHUB_TENANT_ID is deprecated. It remains for integrations that already ship with it; use the
provisioning key for anything new.
Why the tenant is the wrong one. A device registering with the tenant alone goes
through the namespace's legacy registration path, which holds it for manual review —
so a production run arrives as a queue of pending devices rather than a working fleet. A
provisioning key also carries tags, so a policy can cover the batch before anybody opens the
console. See Using provisioning keys.
The key must be writable and must not be in the image
The agent generates its device key when the file is missing. Two consequences, and both
of them bite in production:
The key path has to be writable and to survive a reboot. On a read-only root
filesystem, or a path on a volatile partition, every boot produces a new identity and a
new device in the console.
No key may be baked into the image. Every unit written from an image containing one
claims the same identity, and you get a single device that appears to move between
machines instead of a fleet.
Init systems
The recipe installs a systemd unit or a sysvinit script, following your image'sDISTRO_FEATURES. Neither needs configuration beyond the variables above.
Overriding the tenant per image
The layer ships shellhub-rootfs-postcommand.bbclass for the case where one build feeds
several namespaces. Inherit it and set SHELLHUB_ROOTFS_TENANT_ID, and the tenant in the
generated configuration is rewritten as the rootfs is assembled, without a rebuild of the
agent.
It rewrites the tenant only. A build that registers by provisioning key does not need it.
Verifying
Write the image to two devices and start both.
Expected result: Two devices appear, each carrying the provisioning key's tags, and each with
its own identity.
Where to go next
Ui Apps Docs Src Pages Manage Access Control Access Policies (`ui/apps/docs/src/pages/manage/access-control/access-policies.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Access Policies
import Callout from "../../../components/Callout.astro";
import Shot from '../../../components/Shot.astro';
Access Policies
An identity proves who is connecting. A policy decides what they may reach.
Policies are namespace-scoped and evaluated on every connection. Evaluation is default deny:
until a policy grants access, nothing is reachable, and a namespace with no policies refuses
everything.
Manage them under Access Policies in the console.
<Shot id="access-policies" route="/access-policies" of={{ role: "main" }} alt="The Access Policies page listing policies with their effect, subject, and targets" />
Anatomy
Name — how you will recognize it. It appears verbatim in the denial when a deny refuses
somebody, so a descriptive name is what makes a refusal diagnosable. Operators to prod beatspolicy-2.
Effect — allow grants the access described; deny blocks it. A matching deny wins over any
allow, however specific.
Subject — who it applies to:
| Subject | Applies to |
|---|---|
| All members | Every member of the namespace |
| Role | Everyone holding that role, so the grant follows the role rather than the person |
| User | One person |
| API key | An automation connecting on its own identity |
Devices — which devices are covered:
| Selector | Behavior |
|---|---|
| All devices | Every device in the namespace, including ones enrolled later |
| Tags | Devices carrying the selected tags |
Logins — which unix logins are allowed on those devices: a specific list like deploy androot, or any. This is the difference between reaching a device and reaching it as root.
Source IP — optionally, only accept connections from these addresses or ranges. Empty matches
any address. It only ever narrows — it never grants.
Re-authentication — optionally, require the identity to confirm in the browser before
connecting. Either every login, or once per window. See
Login Approvals.
How policies combine
Every deny is evaluated first. If one matches, the connection is refused and no allow overrides
it.
If no deny matches, the allows are evaluated, and one match is enough.
If nothing matches, refused.
A deny whose selector cannot be evaluated fails closed. An allow that cannot be evaluated is
treated as a non-match.
That order makes a working policy set predictable in shape: broad allows for what people
normally need, then narrow denies to carve out exceptions. Fighting a deny by adding more allows
does not work.
A worked example
A team where operators handle the fleet, one contractor touches only lab devices, and a pipeline
deploys.
| Name | Effect | Subject | Devices | Logins |
|---|---|---|---|---|
| Operators to fleet | allow | Role: operator | tag production |
deploy |
| Contractor to lab | allow | User: contractor | tag lab |
any |
| No root anywhere | deny | All members | All devices | root |
| Pipeline deploys | allow | API key ci-deploy |
tag production |
deploy |
What this buys you:
- The deny spans the namespace and needs no exception list. It beats all three allows without any
of them being rewritten. - Tagging a new device
productiongrants operators and the pipeline at once. No policy is
edited. - Adding somebody to the operator role grants them the fleet. No policy is edited.
ci-botneeds its own allow — theall-membersdeny still applies to it, but noall-members
allow would have covered it.
Starting from scratch
A new namespace has one policy: Owner access, granting the owner every login on every device.
Everybody else has nothing.
A reasonable second and third policy:
- An allow for the role your team mostly holds, scoped to a tag, with the logins they actually
need — not*. - A namespace-wide deny for
root, if root is not something people should be reaching for
routinely.
Then add narrower allows as real needs appear, rather than trying to model the whole organization
up front.
Where to go next
- Policy Reference — Every field, and what empty means.
- Tags — Selecting devices well.
- User Roles — What a role subject resolves to.
Ui Apps Docs Src Pages Manage Access Control Connection Denied (`ui/apps/docs/src/pages/manage/access-control/connection-denied.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Connection Denied
import Callout from "../../../components/Callout.astro";
Connection Denied
Authorization runs before ShellHub contacts the device, so a refused connection fails at the
server and never reaches the agent. In the web terminal you get an access-denied banner; a native
client sees the raw error.
First: is it actually a denial?
Two things look like failures and are not.
The login is waiting, not refused. A key that is not yet an identity parks the login until
somebody approves it in the console. So does a policy demanding re-authentication. Check for a
pending approval before assuming a denial. See
Login Approvals.
The device is not there. If the device is offline or was never accepted, there is nothing to
connect to and the failure is not about authorization. See
Device Not Appearing.
1. Are you a member of the namespace?
Checked before any policy is consulted. If the account is not a member, nothing else matters.
Check the namespace in the SSHID is the one you mean — root@**mycompany**.device@server — and
that the account was actually invited and accepted.
2. Is a deny policy matching?
Deny is evaluated before allow and beats it, however specific your allow is.
The denial reason names the responsible policy internally, but you will not see it, so look at
your denies directly. They are usually few, and usually broad — a namespace-wide "no root
anywhere" is a common shape and it catches cases nobody was thinking about when the allow was
written.
Fix it by narrowing the deny, not by adding allows. No allow can win against a deny.
Also check for a deny that cannot be evaluated — an invalid CIDR in a source IP. A deny that
cannot be evaluated fails closed, which means a typo in a deny policy refuses connections it
was never meant to match.
3. Does any allow actually cover this?
The most common case, and it usually means what it says: nothing grants it.
Work through it in this order:
Is there any allow for this principal at all? A new member starts with nothing. Only the owner
is covered by the starter policy. See Members.
Is this an automation? An all-members allow does not cover an API key, which is not a
member. It needs a policy naming it. See
SSH Access.
Does the filter cover this device? A tag filter matches only devices actually carrying the
tag; check the device has it.
Does it cover this login? A policy granting deploy does not permit ssh root@…. Check the
username in the SSHID, not just the device.
Is the connection attributed to the identity you think? If you hold several keys, the session
is attributed to whichever your client offered first. IdentitiesOnly yes in your SSH config
removes the ambiguity. See SSH Client Configuration.
4. Is a source IP restriction excluding you?
A policy with a source IP list only matches connections from those ranges. Connecting from
somewhere new — home instead of the office, a different VPN — silently stops matching.
On a self-hosted server behind a load balancer, check SHELLHUB_SSH_PROXY_TRUSTED_IPS for SSH
clients and SHELLHUB_PROXY_TRUSTED_IPS for the web terminal. Set wrong, every connection appears
to come from the balancer and source IP matching is operating on the wrong address entirely. See
Ports and Connectivity.
The namespace has no policies at all
A new namespace with zero policies refuses everything, and ShellHub short-circuits it
— refusing immediately rather than proceeding toward a login that could never succeed.
This happens after upgrading a legacy namespace without writing policies first. The
owner keeps working, through the seeded Owner access policy; nobody else does. See
Upgrading a Legacy Namespace.
Legacy namespaces
If the namespace is still legacy, none of the above applies. Access is decided by the
key's device ACL and by firewall rules — see
Public Keys and
Firewall Rules.
The four reasons, for reference
| Reason | What it means |
|---|---|
user is not a member of the namespace |
Step 1 above |
denied by policy "<name>" |
Step 2 — a deny matched |
denied: policy "<name>" could not be evaluated |
Step 2 — a deny failed closed on a source IP it could not parse |
no policy grants "<login>" on this device |
Step 3 — nothing allowed it |
Where to go next
- How Authorization Works — The evaluation order.
- Access Policies — Writing rules that grant what you meant.
Ui Apps Docs Src Pages Manage Access Control Firewall Rules (`ui/apps/docs/src/pages/manage/access-control/firewall-rules.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Firewall Rules
import LegacyBanner from "../../../components/LegacyBanner.astro";
import EditionBanner from "../../../components/EditionBanner.astro";
import Callout from "../../../components/Callout.astro";
import Shot from "../../../components/Shot.astro";
Firewall Rules
Firewall rules restrict what public keys otherwise permit. Where a key says "this key may reach
these devices as these users", a firewall rule says "and not from there, not as that, not to
that one".
<Shot id="firewall-rules" route="/firewall-rules" of={{ role: "main" }} accessMode="legacy" edition="enterprise" alt="The Firewall Rules page showing a table of rules with Priority, Action, Source IP, Username, Device Filter, and Status columns" />
Anatomy
<Shot id="firewall-add-rule" route="/firewall-rules" of={{ role: "dialog" }} accessMode="legacy" edition="enterprise" before={[{ click: { role: "button", name: "Add Rule" } }]} alt="The New Firewall Rule drawer with Status, Priority, Policy, Source IP, Username, and Device filter fields" />
| Field | Meaning |
|---|---|
| Status | Whether the rule is active |
| Priority | Evaluation order — rules are considered in priority order |
| Policy | Allow or deny |
| Source IP | Which client addresses the rule matches |
| Username | Which unix login the rule matches |
| Device filter | Which devices — all, a hostname pattern, or tags |
Priority, and why it is the hard part
Rules are evaluated in priority order and the first match decides. That makes the rule set
order-dependent, and order-dependence is where firewall rule sets go wrong: inserting a rule at
the wrong priority silently changes what the rules below it do.
Access policies are not ordered this way — deny is always evaluated before allow, regardless of
what order the policies were created in. That is one of the reasons for the change.
The source IP difference
This is the one to understand before migrating.
A firewall rule can grant on source IP: "allow from this range". An access policy's source IP
only narrows a grant that already matched — an empty list matches everything, and no source
IP ever grants on its own.
So a firewall rule that grants access to a range has no direct equivalent. The replacement policy
needs a real subject — a user, a role, an API key — with the addresses as a restriction on
top. See
Upgrading a Legacy Namespace.
Where to go next
- Public Keys — What these rules restrict.
- Upgrading a Legacy Namespace — Translating rules to policies.
Ui Apps Docs Src Pages Manage Access Control Login Approvals (`ui/apps/docs/src/pages/manage/access-control/login-approvals.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Login Approvals
import Callout from "../../../components/Callout.astro";
import Shot from "../../../components/Shot.astro";
Login Approvals
Most connections go straight through: the key resolves to an identity, policies grant it, the
session opens.
Two situations pause a login and ask for a confirmation in the console instead.
First use of a key
When a client offers a key the namespace has never seen, ShellHub does not refuse it. It holds the
login open and asks a member to approve.
Approving binds that key to the approving account. From then on the key is an identity, and
every later connection with it is recognized immediately — no approval, no wrapper, a stock
OpenSSH client.
This is the approve-once model. The alternative — approving every login — is whatrequire_reauth is for, and it is off by default.
The server asks in the terminal, as a keyboard-interactive prompt, which is how it reaches a
stock client with nothing installed, including one run with -q:
ShellHub doesn't know this SSH key yet.
Open the link to add it to your identities:
https://cloud.shellhub.io/ssh-identities/new/2TV899JA
Security code: 2TV8 99JA
Key: SHA256:2Ur4mPvXqE9V78JBIR26G4zpPW0IWs9ci690kNbQ
The console gives you a confirmation code once you add it.
Confirmation code:Open the link and approve. The console then shows a confirmation code; type it at the prompt and
the handshake finishes into an ordinary session.
The code is what proves you reached the console, which following a link does not. It is also why
a client with nobody in front of it (a CI job, a script, anything using an SSH library) is
refused straight away instead of hanging: it has no way to answer, and the server does not wait
for an answer that is not coming.
<Shot id="login-approval" route="/ssh-identities/new/:approval" of={{ role: "dialog" }} alt="The approval screen for a key the namespace has not seen, showing the key, the account it would be added to and the namespace, with the login details folded" />
The identity is created for whoever approves, not for whoever was connecting. Approve a
stranger's key and you have handed them a key that connects as you, with session records in
your name. Approve only a login you started yourself; if somebody sends you their link, tell them
to open it themselves.
Check the security code and the fingerprint before approving. The code is the tail of the
link, printed in pairs so it can be read aloud; the fingerprint is the key that was offered.
Both appear again in the browser, and they are what tells you the page you are approving belongs
to the connection in front of you rather than to somebody else's attempt at the same moment.
The approval is valid for 90 seconds. Miss it and the connection fails; connect again and a
fresh link appears. What waits is the prompt in your own terminal, so a client that cannot answer
one is refused rather than parked, and no connection to the device is opened until the login is
through. The connection you are logging in from is held until you answer, for at most three
minutes.
Rejecting refuses the connection and binds nothing.
You can skip this step by adding the key ahead of time on the SSH Identities page, the usual move
for a server key, where nobody is watching for a prompt. See
SSH Identities. A CI runner connects as an API key
instead; see SSH Access.
Re-authentication required by a policy
A policy can carry require_reauth. When it grants a connection, the identity must confirm out of
band before the session opens, even though the key is already known.
Use it on the paths that deserve a deliberate step: production, a privileged login, a device
nobody should reach absent-mindedly.
The freshness window
reauth_period decides how often the confirmation is actually demanded. It is a window in
seconds, and the freshness belongs to the identity, not to a connection.
That distinction is the whole point:
- Window set — one confirmation covers every login with that key until the window lapses.
Confirm once in the morning, work all day. - Window unset, or
0— confirm on every single login. The only genuinely per-connection
setting.
An identity that has never re-authenticated is always challenged.
<Shot id="login-reauth" route="/ssh-identities/confirm/:reauth" of={{ role: "dialog" }} alt="The re-authentication screen for a key already known, confirming a login rather than creating an identity" />
The window is measured against the identity's last re-authenticated timestamp, which moves
only on a successful re-authentication — not on ordinary use.
When several policies match
The strictest requirement wins: if any matching allow requires re-authentication, the
connection requires it, at the shortest window among them.
This is deliberate. Without it, a broad "all members, all devices" grant with no re-auth would
silently shadow the narrow policy you wrote specifically to protect production.
The two side by side
| First use of a key | Re-authentication | |
|---|---|---|
| Why it appeared | The key is not an identity yet | A policy requires fresh confirmation |
| What confirming does | Binds the key to the approver | Refreshes the identity's freshness window |
| Creates anything | Yes, a new identity | No |
| Happens again | No — the key is now an identity | When the window lapses |
An approval is pending, then confirmed or rejected. There is no stored expired state — a
pending approval past its deadline simply reads as unknown, and is pruned later.
Where to go next
- SSH Identities — Adding keys ahead of time.
- Policy Reference —
require_reauthandreauth_period. - Connection Denied — When a pause is actually a refusal.
Ui Apps Docs Src Pages Manage Access Control Migrating From Legacy (`ui/apps/docs/src/pages/manage/access-control/migrating-from-legacy.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Upgrading a Legacy Namespace
import Shot from "../../../components/Shot.astro";
Upgrading a Legacy Namespace
A namespace created before identity-based access is legacy: a public key grants access through
its device ACL, firewall rules restrict it, and a device enrolls with the namespace's tenant ID
alone. A namespace created today is new: identities and access policies decide access, and a
device enrolls by pairing or with a provisioning key.
Moving across is an upgrade you make once, when you are ready, and it cannot be undone. This page
is what to do before you click it.
What changes
| Legacy | Identity | |
|---|---|---|
| Proves who you are | A public key registered in the namespace | An identity bound to a person or an automation |
| Decides what you reach | The key's device ACL, plus firewall rules | Access policies |
| An unlisted device | Reachable unless a rule blocks it | Unreachable until a policy grants it |
| Source IP | A firewall rule can grant on it | Only narrows a grant; never grants |
| Revoking one person | Find and remove their key everywhere it applies | Revoke the identity, in one place |
| Audit trail names | The key | The person |
| A device enrolls by | TENANT_ID alone, then someone accepts it |
Pairing or a provisioning key, and the deprecated TENANT_ID |
| Password login | Allowed | Refused, in the web terminal too |
After the upgrade the firewall checks are bypassed entirely. The rules stay stored and manageable,
and are never consulted. Devices already enrolled, accepted or still pending, keep authenticating
with the tenant they persisted, and a device that enrolls with the tenant alone still lands
pending.
Before you upgrade
The upgrade takes effect on the next connection, so do the mapping first.
1. Inventory who actually connects. List the public keys in the namespace and work out who
holds each one. The keys nobody can account for are the reason this migration is worth doing —
do not carry them across.
2. Turn each key into an identity. Add the ones you are keeping on the SSH Identities page,
each bound to the right member. An automation's key goes on the API key instead, from its SSH
access on the Team page; see SSH Access. Doing this before the upgrade
means nobody sits through a first-use approval.
3. Translate your firewall rules. A deny rule usually becomes a deny policy. An allow rule
that grants on source IP has no direct equivalent — source IP in a policy only narrows, so the
policy needs a real subject, with the addresses as a restriction on top. This is the sharpest
semantic difference in the migration, and anyone relying on IP-based grants will feel it.
4. Write the allows. Start from roles rather than individuals, so the grant keeps working as
people join and leave. Access policies select devices by tag only, not by
hostname pattern — a firewall rule that filtered by hostname needs the matching devices tagged
before you can carry it across.
5. Decide where re-authentication belongs. Production and privileged logins are the usual
candidates. Off is the default and the right choice nearly everywhere else.
6. Plan how new devices will enroll. A device presenting only TENANT_ID still enrolls, and
lands pending for somebody to accept. Images and scripts that bake the tenant in keep working, but
a provisioning key accepts and tags a fleet without that
queue, and a Docker connector takes it as SHELLHUB_PROVISIONING_KEY.
7. Check the agent versions. The upgrade does not change which agents may connect. That is
the separate Device signature enforcement
setting, off in a namespace that predates it and on in one created after. If you move a fleet
with agents older than 0.27 into a new namespace instead of upgrading this one, turn the setting
off there first, or update the agents: an old agent cannot sign the server's challenge and is
refused. A refused agent whose device the namespace does not have shows up at Pending devices
with the reason.
Upgrading
Click Upgrade on the namespace's SSH settings. The confirmation spells out what
changes and asks for the namespace's name, because there is no way back.
<Shot id="namespace-upgrade" route="/settings/ssh" of={{ role: "group", name: "Namespace" }} accessMode="legacy" alt="The namespace card of a legacy namespace, with the button that upgrades it" />
If the namespace has no policies at the moment you upgrade, ShellHub seeds one, Owner access,
which grants the owner every login on every device, so default deny cannot lock the owner out.
Every other member starts with no access. That is why the mapping above matters: anyone you
did not write a policy for loses access the moment you upgrade.
After the upgrade
Public keys and firewall rules remain visible but decide nothing. Leave them until you are
confident, then remove them — a stale key that grants nothing here is still a key somebody might
try elsewhere.
Watch for refusals in the first days. no policy grants "<login>" on this device means a mapping
was missed. See Connection Denied.
Where to go next
- How Authorization Works — The model you are moving to.
- Access Policies — Writing the rules.
- Public Keys — The model you are leaving.
- Provisioning Keys — How new devices enroll from here on.
Ui Apps Docs Src Pages Manage Access Control Policy Reference (`ui/apps/docs/src/pages/manage/access-control/policy-reference.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Policy Reference
import Callout from "../../../components/Callout.astro";
Access Policy Reference
Field reference. For how to write a policy, see
Access Policies.
Fields
`name`
How the policy is identified in the console and in denial reasons. When a deny refuses a
connection, the reason names this policy.
`action`
allow or deny. Defaults to allow.
Deny is evaluated first and wins over any allow, however specific.
`subject`
A type and a value:
| Type | Value holds | Matches |
|---|---|---|
user |
The user's id | That one user |
role |
The role name | Every member holding that role |
api-key |
The API key's id | That one automation |
all-members |
Empty | Every member of the namespace |
An automation is an API key, not a member, so all-members and role never reach one. That is
structural rather than a carve-out: there is no membership to match against. Grant an automation
deliberately, with an api-key subject.
A connection from someone who is not a member of the namespace is refused before policies are
consulted at all.
`filter`
Which devices the policy covers. Give exactly one of the two forms:
| Form | Behavior |
|---|---|
"all": true |
Matches every device, including ones enrolled later |
"tags": [...] |
Matches a device carrying any of the listed tags, one to three |
A filter with neither, or with both, is refused with 400. "All devices" is always written
out, so a policy never reaches every device because its tag list ended up empty.
`logins`
Which unix logins the policy covers. Exact names, or ["*"] for any login.
A policy granting ["deploy"] does not permit ssh root@…, even on a device the filter matches.
`source_ip`
A list of CIDRs. A connection matches when the client address falls inside any of them. A bare
address is normalized to a host route — /32, or /128 for IPv6.
Empty matches any address. Source IP only narrows a policy that already matched. It never
grants on its own, and it is not a substitute for a subject.
An unparsable CIDR or client address causes a deny to fail closed.
`require_reauth`
Off by default — the identity alone is the norm.
When set, an identity granted access by this policy must confirm out of band, in the browser,
before the connection proceeds, even though the key it presented is already an identity.
When several allows match one connection, the strictest requirement wins: required if any
matching allow requires it, at the shortest window. Without that, a broad grant with no re-auth
would shadow the narrow policy you wrote specifically to protect production.
`reauth_period`
The freshness window for require_reauth, in seconds. Only meaningful when require_reauth is
set.
Re-authentication is demanded only when the identity has not re-authenticated within the window.
The freshness belongs to the identity, not to a connection: inside the window, every login
with that key goes straight through.
0 or unset means always — the only setting that is genuinely per login.
Between two matching policies, the shorter window wins, and "always" beats any number.
Evaluation order
- Not a member of the namespace → refuse.
- Every deny: a match refuses; one that cannot be evaluated also refuses.
- Every allow: one match is enough. An allow that cannot be evaluated is a non-match. Collect
the strictest re-auth across all that matched. - No allow matched → refuse.
Denial reasons
| Reason | Meaning |
|---|---|
user is not a member of the namespace |
The connecting account does not belong to this namespace |
denied by policy "<name>" |
A deny matched |
denied: policy "<name>" could not be evaluated |
A deny had a source IP it could not parse, and failed closed |
no policy grants "<login>" on this device |
No allow covered this device and login |
The starter policy
Every new namespace is seeded with:
| Field | Value |
|---|---|
name |
Owner access |
action |
allow |
subject |
user = the namespace owner |
filter |
"all": true — all devices |
logins |
["*"] |
source_ip |
empty — any address |
Created when a namespace is born, or when a legacy one is upgraded with no policies.
Where to go next
- Access Policies — Writing them, with an example.
- How Authorization Works — The model behind the fields.
Ui Apps Docs Src Pages Manage Access Control Public Keys (`ui/apps/docs/src/pages/manage/access-control/public-keys.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Public Keys
import LegacyBanner from "../../../components/LegacyBanner.astro";
import Shot from "../../../components/Shot.astro";
Public Keys
In a legacy namespace, a public key registered in the namespace is the access. Whoever holds the
matching private key connects.
<Shot id="public-keys" route="/sshkeys/public-keys" of={{ role: "main" }} accessMode="legacy" alt="The Public Keys page listing registered SSH keys with their scope, fingerprint, and management actions" />
Registering a key
On the Public Keys page, paste an OpenSSH public key and give it a name.
Each key carries a scope:
- Which devices — all of them, a hostname pattern, or a tag set. Access policies use the same
filter, minus the hostname pattern — tags or all devices only. - Which unix logins — a specific username, or any.
A connection is permitted when the presented key is registered and its scope covers the device and
the login being requested.
Why this model was replaced
Not because it does not work — it does. Because of what it cannot tell you and cannot do.
A key is not a person. The key proves somebody holds the file. It does not say who, and it
does not stop them handing it on. The audit trail names the key.
Revocation is a search. Removing one person's access means finding every key that is theirs
and removing each one, and being sure you found them all.
Unlisted devices are reachable. A legacy namespace permits by default within a key's scope, and
firewall rules subtract from that. Get the scope slightly too wide and access exists that nobody
decided to grant.
The identity model inverts the first — a key is a credential belonging to a principal — and
inverts the last, since access policies are default deny.
Firewall rules
On Cloud and Enterprise, firewall rules restrict what
keys otherwise permit, matching on source IP, hostname and username.
They are part of how a legacy namespace works and are bypassed entirely in a new namespace.
Managing keys
Keys can be renamed, rescoped and removed. Removing one takes effect on the next connection — a
session already open is not interrupted.
Where to go next
- Upgrading a Legacy Namespace — Moving to identities.
- Firewall Rules — The restriction layer.
Ui Apps Docs Src Pages Manage Access Control Ssh Identities (`ui/apps/docs/src/pages/manage/access-control/ssh-identities.mdx`)
layout: ../../../layouts/DocsLayout.astro title: SSH Identities
import Callout from "../../../components/Callout.astro";
import Shot from '../../../components/Shot.astro';
SSH Identities
An SSH identity binds a public key to a principal — a person, or an API key acting as an
automation. The key is
the credential; the identity is who it belongs to.
Identities are scoped to a namespace. The same key can be an identity in more than one namespace,
and one principal can hold several keys, such as a laptop, a workstation and a server. Within a
namespace, a fingerprint maps to exactly one identity.
Manage them under SSH Identities in the console.
<Shot id="ssh-identities" route="/ssh-identities" of={{ role: "main" }} alt="The SSH Identities page listing each key, the principal it belongs to, and where it came from" />
Where identities come from
| Source | What it is | What revoking does |
|---|---|---|
| Manual | Somebody pasted or uploaded the public key on the SSH Identities page | The next login with it needs approval again |
| At login | A member approved the key when a client offered one the namespace had not seen | The next login with it needs approval again |
| Browser | The web terminal generated the key inside a browser | That browser registers a new key next time it opens a terminal |
A browser key is generated in the browser and held non-extractably: it cannot be exported, works
only there, and dies with that browser's site data.
Approve once, then connect normally
The first time a native client offers a key the namespace does not know, a member approves it in
the console, and that approval binds the key to the approving account. Every later connection
with that key is recognized immediately.
A stock OpenSSH client is all you need — no wrapper, and no approval on every login. See
Login Approvals.
Adding a key ahead of time
You do not have to wait for a first connection. Add a Key takes an OpenSSH public key that
already exists, such as a server key, so its first connection works with no approval step. The key
becomes another of your own identities; name it for where it lives, like laptop.
A key an automation connects with belongs to an API key instead. You add it from that API key's
SSH access on the Team page; see SSH Access.
Two things are rejected: a string that is not a valid public key, and a key that is already an
identity in this namespace.
Adding keys ahead of time is the right move for anything unattended, because nobody is watching a
console to approve a server's first connection at 3am.
Lifecycle
By default a key is durable: no deadline, reusable, valid until revoked.
Two optional limits:
Expiry. A key can carry an expiry, after which it stops being an identity. Nil means never.
Single use. Offered only for an API key's SSH key, where one key serves one run. Once
consumed, it is spent.
The console marks a key approaching its deadline, so a stale identity is visible in the list
rather than discovered at connection time.
An expired or consumed key is rejected outright — it does not fall back into the approval flow and
quietly re-enroll itself.
Revoking
Revoking removes the binding, and takes effect on the next connection. What that means depends on
where the key came from — see the table above.
Revocation is per key, in one place. You do not touch the devices, and other keys held by the same
principal keep working.
Two timestamps
Worth knowing because they answer different questions:
- Last used moves every time the key is resolved to its identity — the answer to "is this key
still in use". - Last re-authenticated moves only on a successful re-authentication, and is what a policy's
re-auth window is measured against.
Where to go next
- SSH Access — Identities with no human behind them.
- Access Policies — Deciding what an identity reaches.
- Login Approvals — The approve-once flow.
Ui Apps Docs Src Pages Manage Devices Device Not Appearing (`ui/apps/docs/src/pages/manage/devices/device-not-appearing.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Device Not Appearing
import Callout from "../../../components/Callout.astro";
Device Not Appearing
The agent is installed and the device is not where you expect. Work down this page in order — it
goes from most to least common.
Is it waiting to be accepted?
Check the pending link on the Devices page before anything else. It opens Pending devices,
the history of every device that registered with the tenant ID, where anyone who may accept
devices accepts or rejects one that waits and accepts one rejected before. A device enrolled with a
provisioning key waits on that key's row in Add Device → Fleet instead. It is not broken; it
is queued.
Accept it and it comes online. To stop needing to, use a
provisioning key. See
Provisioning Keys.
Was it refused for not proving its key?
In a namespace that has Device signature enforcement
on, an agent older than 0.27 is refused: it cannot sign the server's challenge with its device
key. The refusal shows up in Pending devices as one line per device, with the hostname,
address, agent version, last attempt and how many times it tried. The agent keeps retrying and
the line stays one line.
There is nothing to accept on that line. Two ways out:
- Update the agent to 0.27 or later. Its next attempt signs and the device lands pending as
usual. - Turn Device signature enforcement off in Settings → SSH. The agent's next attempt
lands pending. Anyone who knows the device's hostname, MAC address, public key and the tenant ID
can then authenticate as a device that never signed, so prefer updating the agent.
The agent's own log shows the refusal as status_code=403.
Is the agent running?
Depends on how it was installed:
$ docker ps | grep shellhub # Docker
$ podman ps | grep shellhub # Podman
$ snap services shellhub # Snap
$ systemctl status shellhub-agent # standalone
$ service shellhub status # FreeBSDIf it is not running, the logs say why:
$ docker logs <container>
$ sudo snap logs shellhub
$ journalctl -u shellhub-agent -n 50Is the configuration right?
Two variables are required, and a mistake in either produces a device that never appears.
SHELLHUB_SERVER_ADDRESS — must include the scheme. https://shellhub.example.com, notshellhub.example.com. Self-hosted deployments often have this pointing at the default Cloud
address by accident.
SHELLHUB_TENANT_ID is deprecated. A device that enrolls with it lands in Pending and
stays out of the device list until somebody accepts it, so look there first. Snap needs it.
Docker, Podman and standalone fall back to pairing mode when it is absent, so an install with no
tenant is not misconfigured: it is waiting to be paired. Run shellhub-agent login on the host. See
How a device enrols.
A typo'd variable name is ignored silently, which is a particularly quiet way to fail. Check the
spelling against Agent Configuration.
Can the device reach the server?
From the device:
$ curl -sS -o /dev/null -w '%{http_code}\n' https://shellhub.example.com/infoA 200 means the path is fine and the problem is elsewhere. Anything else is network or DNS, and
that is what to fix.
The agent needs only outbound access — no inbound port, no public address. If a browser on that
device could load a page from your server, so can the agent. See
Ports and Connectivity.
Does it show up under a name you did not expect?
The device name defaults to the system hostname. On a fleet where every unit reports the same
hostname, or a container with a random one, the device is there but not called what you are
looking for.
Set SHELLHUB_PREFERRED_HOSTNAME to something meaningful.
Did an old device come back instead?
If a device you expected to be new appears as an existing entry — or vice versa — the private key
is the reason.
SHELLHUB_PRIVATE_KEY is the device's identity. The consequences:
- Same key, new machine → it is the same device to ShellHub.
- Key lost — reflashed, container recreated, volume wiped — → a new device, with the old
entry left behind.
The second is by far the most common cause of a device list that does not match reality. Put the
key on persistent storage.
If you are building images, be sure the key is not baked in — every unit flashed from an image
containing a key claims the same identity. See
Yocto and Buildroot.
It appeared, then went offline
Normal, if brief. The agent keepalives every 30 seconds and reconnects on its own, waiting a random 5 to 65 seconds before each attempt, so a short network problem resolves itself.
Staying offline has a real cause. Check the agent is still running, then the network.
On WSL specifically: the distribution shuts down when the last terminal closes, and the agent
goes with it. See Install the Agent.
After a reboot, on Podman: the container is started by podman-restart.service. If it is
not running, enable the unit:
$ sudo systemctl enable --now podman-restart.serviceAn install made with SKIP_BOOT_RESTART_CHECK=1 is yours to bring back.
A provisioning key was involved
If the device enrolled with a provisioning key, the key's mode decides where it lands: automatic
accepts it, manual puts it in Pending, webhook asks your endpoint, allowlist accepts it only
if the identity it reports is listed.
A device rejected by an allowlist or a webhook did enroll — it was refused. Check the key's
history. See Provisioning Keys.
Where to go next
- Managing Devices — The three ways a device enrols.
- Agent Configuration — Every variable and its real name.
- Connection Denied — The device is there but refuses you.
Ui Apps Docs Src Pages Manage Devices Provisioning Keys (`ui/apps/docs/src/pages/manage/devices/provisioning-keys.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Provisioning Keys
import Callout from "../../../components/Callout.astro";
import Shot from '../../../components/Shot.astro';
Provisioning Keys
A provisioning key is a reusable credential you put in an image or a provisioning script. Devices
carrying it enroll, get accepted according to the key's mode, and arrive with tags already
applied.
This is what turns enrollment from an afternoon of clicking into something that happens on its
own.
<Shot id="provisioning-keys" route="/devices/add/fleet" of={{ role: "main" }} alt="The Fleet tab of Add Device, listing provisioning keys with their mode, usage, and expiration" />
Creating one
In Add Device, open Fleet and click New key. A key has:
| Field | Meaning |
|---|---|
| Name | Unique within the namespace. How you identify it later |
| Mode | How an enrollment is decided |
| Expiration | A date after which the key stops enrolling, or never |
| Usage limit | Single-use, a number of devices, or unlimited |
| Ephemeral | Whether devices enrolled with it are removed after a spell offline |
| Tags | Applied to every device enrolling with the key |
The key is not copy-once. It is stored encrypted and can be read again: open a key in the Fleet
list and its install command carries it, or open the key's page and click Show under
Details. Reading it needs the provisioningKey:reveal permission, so a role
that can use the console is not automatically a role that can retrieve a credential.
Using one
$ curl -sSf https://cloud.shellhub.io/install.sh | PROVISIONING_KEY=<your-provisioning-key> shOr as an agent variable, SHELLHUB_PROVISIONING_KEY, which is how it goes into a
Yocto or Buildroot image.
The key only matters while the server does not know the device. On first start the agent
generates its own private key, and the server recognizes the device by that key together with its
hostname and MAC address. The agent keeps presenting the provisioning key, so a removed or
ephemeral device can enroll again, but for a device the server already knows the key decides
nothing. A device whose hostname or MAC address changes is a new device to the server, and
enrolling it counts against the key's usage limit.
Modes
The mode decides what happens when a device presents the key.
| Mode | Device lands | Use it for |
|---|---|---|
automatic |
Accepted | Fleets you trust by construction — your own images, your own provisioning |
manual |
Pending | Keeping the human check while still getting tags |
webhook |
Whatever your endpoint says | Deciding from your own inventory |
allowlist |
Accepted if the identity it reports is listed, rejected otherwise | A known, fixed set of hardware |
Automatic
The mode for images that should join with nobody watching. A device presents the key and joins
the namespace, with the key's tags applied.
The trade is real: anything holding the key can enroll and be accepted. Scope keys narrowly — one
per product line, per site, per batch — so revoking one does not strand everything.
Manual
Tags without automatic acceptance. Useful when you want the fleet sorted but still want to look at
each arrival.
Webhook
The decision goes to an endpoint of yours, called at enrollment. Your service answers, and its
answer decides.
This is how you enroll against your own source of truth: an ERP, a manufacturing database,
whatever knows which serial numbers are genuinely yours.
Bounds worth knowing: the call times out after 5 seconds by default, 15 at most. A deferred
decision has a callback window, 1 hour by default and 24 hours at most. The request is signed with
HMAC-SHA256. See Provisioning Key Webhook for the contract.
What matters without the contract is how it fails. A webhook that does not answer in time fails
closed to pending, not to accepted — the device waits for a human rather than walking in. And a
device left pending by a webhook or an allowlist key has its enrollment re-evaluated the next time
the agent authenticates, so a decision that was deferred or blocked resolves on its own once the
condition clears.
The webhook may only reach a public address. The destination is checked after DNS resolution, so a
hostname that resolves into a private network is refused too.
Allowlist
Accepted if the identity the device reports is in the key's list, rejected otherwise. For a fixed
set of hardware you can enumerate in advance.
The identity is the primary interface's MAC address, unless the agent was started withSHELLHUB_PREFERRED_IDENTITY, which replaces it with any string you choose and is commonly set to
the equipment's serial number. List what your fleet actually reports, not what the hardware's MAC
happens to be. Matching ignores case and surrounding whitespace.
Tags
A key applies its tags to every device that enrolls with it. This is the highest-leverage part of
the whole feature, because tags are what policies select on.
Put the tag on the key and the fleet is governable the moment it arrives — a policy grantingtag:production covers every device that ever enrolls with the production key, including ones
that do not exist yet. See Tags and
Access Policies.
Bounding a key
A provisioning key outlives the session you created it in. Three settings bound it without you having
to remember to.
Expiration is a date. Four presets are offered — 30, 60 and 90 days, and a year — and any
other date can be picked from the calendar beside them. A key with no expiry keeps enrolling
devices for as long as the namespace exists.
Usage limit is how many devices may ever enroll with it: single-use, a count, or unlimited. A
key cut for one production batch should be capped at that batch.
Ephemeral marks the devices it enrolls for automatic removal once they have been offline past
a timeout, between 1 and 10 minutes. Use it for machines that come and go by design — a CI runner,
a short-lived container — so the device list reflects what exists rather than everything that ever
ran.
Reviewing a pending enrollment
A key in manual mode, and a key whose webhook deferred, leaves devices waiting. They are reviewed
on the key's page rather than in the device list, and the Fleet list marks every key that has
devices waiting. Devices that registered with the tenant ID have their own page, Pending
devices, linked from the Devices list and the dashboard, which anyone who may accept devices can
work.
<Shot id="provisioning-key-activity" route="/devices/add/fleet/:provisioningkey/activity" of={{ role: "main" }} alt="A provisioning key's activity, listing each registration with what the agent reported and the accept or reject" />
The page lists each enrollment attempt by day, newest first. A device still waiting stands out with
Accept and Reject beside it; every other attempt is one line with how it was resolved, and
opens to what the agent reported: the identity, the source address, the architecture, the agent
version and the device key. A device that was rejected can still be accepted later.
What you have to decide on is what the agent said about itself. A hostname you did not choose, or
an architecture that does not match anything you deploy, is worth understanding before accepting.
Rejecting costs little — a device that should be there can enroll again.
Accepting from a device's own page does the same thing, and is how you decide on one you arrived
at from somewhere else.
This is the audit trail for enrollment. When a device turns up that you did not expect, this is
where you find out which key let it in.
On Pending devices, an agent the namespace refused because it could not prove it holds its device
key is a line marked refused, with the agent version and how many times it tried, and nothing
to accept. It stays one line while the agent retries. Update the agent, or turn off Device
signature enforcement, and its next attempt lands
pending.
Pausing and revoking
The actions menu on a key offers Edit, Disable and Revoke, and the difference between
the last two matters.
Disable is a pause. The key stops enrolling devices and can be enabled again — the right move
while you work out whether a key is being misused.
Revoke is permanent and cannot be undone. A revoked key never enrolls another device. There is
no delete: the key stays in the list, which is what keeps the devices it admitted attributable to
it on their device pages.
Neither affects devices already enrolled. They stay accepted and connected.
The two keys you did not create
Every namespace has two keys it manages itself.
Pairing code covers devices that pair by the code the agent prints during install, or by the
link printed with it. There is nothing to reveal and no mode worth setting — the code itself is
the acceptance.
Tenant-only registration is the deprecated path a device enrolls through with the namespace
identifier and no key at all, and the only one a legacy
namespace has. It is fixed on manual mode and
cannot be edited or disabled, so every tenant-only enrollment lands in Pending, on the Pending
devices page the Devices list links to. A new namespace lists the row only while devices are
pending under it.
Treating a key as a secret
A key in an image is a credential you shipped. Anyone with the image has it, and on automatic
a leaked key puts the leaker's machine in your namespace already accepted.
- Scope narrowly — per product line or batch, not one key for everything.
- Cap each batch's key with a usage limit and an expiration.
- Rotate when a batch is done.
- Prefer
webhookorallowlistoverautomaticwhen the images go somewhere you do not control. - Check the key's page when the device list grows in a way you did not expect.
Where to go next
- Provisioning Key Webhook — The contract, if your endpoint decides.
- Tags — What the key applies, and why it matters.
Ui Apps Docs Src Pages Manage Devices Tags (`ui/apps/docs/src/pages/manage/devices/tags.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Tags
import Callout from "../../../components/Callout.astro";
Tags
A tag is a namespace-scoped label on a device. Tags are how you stop managing devices one at a
time.
Why they matter more than they look
A tag is not just a filter for the device list. Access policies select devices by tag, which
makes a tag the unit of governance.
A policy granting tag:production to the operator role covers every device carrying that tag —
including devices that do not exist yet. Tag a new machine production and the grant applies. No
policy is edited, nobody is added to a list.
Tags are the only device selector an access policy has, besides matching every device. The
legacy public key ACL and firewall rules can still select by a hostname pattern, but that is a
bet that names stay consistent — a tag is a decision recorded at enrollment.
Applying them
Three ways:
In the console — on a device, or to a selection of devices at once.
Through a provisioning key — the key applies its tags to everything that enrolls with it. This is
the one that scales: a device arrives already tagged, without anyone touching it. See
Provisioning Keys.
Through the API — for reconciling tags from your own inventory. See
API Authentication.
A scheme that works
Tags are free-form, which means the scheme is your problem. What holds up in practice is tagging
along the axes you will actually write rules about:
- Environment —
production,staging,lab - Role —
gateway,sensor,kiosk - Location or customer —
site-north,customer-acme - Hardware —
rpi4,imx8
Then a policy is legible: operators get production, the field team gets customer-acme, the
firmware team gets lab.
What does not hold up is tagging things you already know from elsewhere — the OS and architecture
are reported by the agent, so a tag repeating them is one more thing to keep correct.
Filtering
The device list filters by tag, which is how you find things in a fleet of any size.
Renaming and deleting
Tags are namespace-scoped, so renaming one changes it everywhere at once — including inside
policies that select on it. Check what selects a tag before renaming it; a policy pointing at a
tag that no longer exists grants nothing, silently.
Deleting a tag detaches it from every device carrying it, then removes it. No device is deleted
with it.
Where to go next
- Access Policies — Granting access by tag.
- Provisioning Keys — Tagging at enrollment.
Ui Apps Docs Src Pages Manage Team Multi Factor Authentication (`ui/apps/docs/src/pages/manage/team/multi-factor-authentication.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Multi-Factor Authentication
import EditionBanner from "../../../components/EditionBanner.astro";
import Shot from "../../../components/Shot.astro";
Multi-Factor Authentication
MFA adds a time-based one-time code to signing in to the console.
Set the recovery email first
The profile carries a recovery email, separate from the address you sign in with, and
enabling MFA fails without it: the request is refused before the code you typed is even checked,
so an empty recovery email looks like a wrong code unless you know to look. It is also half of the
reset path — a reset sends one code to your sign-in address and another to the recovery one, and
needs both — and it cannot be set from a locked-out account, which is the moment you would think
of it.
Enabling it
From Account › Security.
<Shot id="mfa" route="/account/security" of={{ role: "group", name: "Multi-factor authentication" }} alt="The Multi-factor authentication setting in the account security section, with the control that enables it" />
Scan the QR code with an authenticator app:
Then confirm with a code from the app. From then on, sign-in asks for one:
Recovery codes
Setup produces a set of recovery codes. Save them somewhere that is not the phone holding the
authenticator.
Each is single use, and they are the way back in when the authenticator is lost. Lose both and
recovery becomes a support matter.
What MFA covers
Console sign-in. That is its scope.
It does not gate SSH connections. An SSH connection is authorized by
access policies against an
SSH identity, and MFA is not part of that path.
If you want a second, deliberate step in front of connections rather than sign-in, that isrequire_reauth on a policy — see Login Approvals. The
two are complementary: MFA protects the account, re-auth protects a connection.
MFA and API keys
Every MFA endpoint refuses API keys. It is a human-only flow by construction — an API key holder
has no browser and no authenticator, and allowing one to manipulate MFA state would undermine the
point.
An automation that needs to reach devices should connect as an API key, which never has MFA
because it never signs in. See SSH Access.
Account lockout
Wrong passwords and wrong MFA codes each lock an account out for a while. The third failure
starts a lockout. Attempts sent during it are refused without being checked and do not count. A
failure shortly after a lockout ends makes the next one four times longer. The first lastsSHELLHUB_ACCOUNT_LOCKOUT_BASE, so by default they run 1, 4, 16 and then 60 minutes, the cap set
by SHELLHUB_MAXIMUM_ACCOUNT_LOCKOUT. After a lockout at the cap the count
starts over. Setting SHELLHUB_MAXIMUM_ACCOUNT_LOCKOUT to 0 disables both lockouts.
Password lockout is keyed on the account and the client address, resolved throughSHELLHUB_PROXY_TRUSTED_IPS. A self-hosted deployment behind a load balancer without that set
correctly will lock out on the balancer's address, so a lockout on an account applies to everyone
signing in to it, wherever they are. See
Ports and Connectivity.
MFA lockout has its own counter, keyed on the account alone, whatever the address. Wrong codes from
every sign-in add to it, and a correct password does not reset it. Someone who knows your password
can therefore keep you out of the MFA step by sending wrong codes. A recovery code still signs you
in during the MFA lockout, and clears it. Change your password once you are in, so nobody can
start a new sign-in with the old one.
A sign-in already past the password step can still send wrong codes for 30 minutes after it
started. The last of those can start one more lockout, so with the default cap the MFA lockout
can last up to about 90 minutes after the password change.
Where to go next
- Single Sign-On (SAML) — Delegating sign-in to your IdP.
- Login Approvals — A second factor on connections.
Ui Apps Docs Src Pages Manage Team Single Sign On (`ui/apps/docs/src/pages/manage/team/single-sign-on.mdx`)
layout: ../../../layouts/DocsLayout.astro title: Single Sign-On (SAML)
import EditionBanner from "../../../components/EditionBanner.astro";
import Callout from "../../../components/Callout.astro";
import Shot from '../../../components/Shot.astro';
Single Sign-On (SAML)
SAML lets people sign in to ShellHub with your organization's identity provider instead of a
ShellHub password.
What it changes
Members authenticate against your IdP, so account lifecycle follows the IdP: somebody who leaves
loses their ShellHub sign-in when their IdP account goes, without anybody remembering to remove
them here.
What it does not change
SSO controls sign-in, not device access. A member who signs in through SAML still needs an
access policy to reach anything, exactly like a member
who signs in with a password.
SSH identities are separate. SSO does not distribute or bind SSH keys. Somebody who has signed
in through your IdP still enrolls a key the usual way, through
an approval at first use or by adding it ahead of time.
That surprises people, so it is worth being direct: SAML is a sign-in mechanism, not an
authorization one. Everything about who reaches which device stays in policies.
Configuring it
SAML is configured from the Enterprise Admin Console rather than the ordinary namespace settings.
The page carries toggles for local authentication and SAML, so you can run both while migrating
and switch local off when you are confident.
The self-hosted deployment needs one variable set:
| Variable | Meaning |
|---|---|
SHELLHUB_SAML_SECRET |
Encrypts the service provider's X.509 private key |
ShellHub sends the IdP an assertion URL and entity ID that use http:// unlessSHELLHUB_AUTO_SSL=true. When TLS ends at a load balancer or ingress in front of ShellHub, setSHELLHUB_PUBLIC_URL=https://shellhub.example.com so both use https://. Registerhttps://shellhub.example.com as the entity ID in your IdP, with no trailing slash. Keycloak
calls this field the client ID.
The Enterprise Admin Console itself is reached withSHELLHUB_ENTERPRISE_ADMIN_USERNAME and SHELLHUB_ENTERPRISE_ADMIN_PASSWORD.
Re-authentication through SAML
On Enterprise, a policy's re-authentication step can be satisfied through SAML rather than the
built-in browser confirmation, so a step-up runs through the same IdP as the original sign-in.
See Login Approvals.
Leaving local authentication on
Keep at least one local administrator account working. If the IdP integration breaks — an expired
certificate, a metadata change — a local account is how you get back in to fix it.
Where to go next
- Members — Roles, and why membership is not access.
- Multi-Factor Authentication — The alternative when SAML is not available.
Ui Apps Docs Src Pages Manage Team Sso Not Working (`ui/apps/docs/src/pages/manage/team/sso-not-working.mdx`)
layout: ../../../layouts/DocsLayout.astro title: SAML Sign-In Fails
import Callout from "../../../components/Callout.astro";
SAML Sign-In Fails
SAML failing on a deployment that has it configured looks like an identity provider problem, and
usually is not.
Check the license first
Open License in the admin console. It states which it is: no license installed, expiring
soon, expired, or expired but inside its grace period. A license past its grace period stops
gating in your favour, and SAML is one of the things that stops.
Then check the edition
SAML is Enterprise only. It is not in Cloud, and it is not in Community — this is the one
capability where Cloud is not a superset. If sign-in fails on Cloud, it is not misconfigured;
the capability is not there. See Editions.
Then the configuration
Only after the two above. See Single Sign-On for what the
identity provider needs and what ShellHub expects back.
Where to go next
- Single Sign-On — Configuring it.
- Editions — Which edition has what.
- Getting Support — What to include when you ask.
Ui Apps Docs Src Pages Manage Team User Roles (`ui/apps/docs/src/pages/manage/team/user-roles.mdx`)
layout: ../../../layouts/DocsLayout.astro title: User Roles
import Callout from "../../../components/Callout.astro";
User Roles
Every member of a namespace holds one role. The role decides what they can do to the namespace
itself.
| Role | Can |
|---|---|
| Observer | View device and session details. Nothing else. An observer cannot connect to a device, except by native SSH in a legacy namespace |
| Operator | Device-related operations — connecting, viewing details, managing devices — but not removing a device. Also has session details |
| Administrator | Nearly everything the owner can, but cannot delete the namespace, and has no billing actions |
| Owner | Everything |
These are the roles a person holds. An automation holds none: it is an API key, and where it may
reach over SSH comes from the policies naming it. See
SSH Access.
Roles do not grant device access
Worth stating plainly, because the names suggest otherwise.
An operator is not automatically allowed to reach any device. Device access comes from
access policies, evaluated per connection. A member with
the operator role and no matching policy connects to nothing.
What the role controls is the console: what they see, what they can change, whether they can
remove a device.
A role never grants device access, but it can withhold it. The observer role holds no connect
permission. In a new namespace an observer is refused before their policies are read, and
the refusal covers the whole connection, including file transfer and port forwarding. The
browser terminal refuses them in both. Native SSH in a legacy namespace carries no user
identity, so the role cannot be enforced there. Grant an observer a policy and they still connect
to nothing. Make them an operator and the policy decides.
Roles as policy subjects
The two systems meet here, and it is the most useful pattern in the whole model. A policy can name
a role as its subject:
| Name | Effect | Subject | Devices | Logins |
|---|---|---|---|---|
| Operators to fleet | allow | Role: operator | tag production |
deploy |
Now the role is doing both jobs: it says what somebody can do in the console, and — through the
policy — what they can reach. Adding a member as an operator grants both at once.
Choosing
Observer for people who need to look — support staff triaging, someone reviewing a session
recording.
Operator for the people doing the work. The inability to remove a device is the point: they
can operate the fleet without being able to erase parts of it.
Administrator for people who manage the namespace day to day. The two exclusions —
deleting the namespace and billing — are the ones you want to keep to the owner.
Owner for exactly one person.
API keys carry roles too
An API key is created with a role, and it must be equal to or less than the role of whoever
creates it. An operator cannot mint an administrator key. Changing a key's role later takes the
same authority from whoever edits it.
A key never acts as an owner. The highest role it acts with is administrator, so an owner's key is
an administrator key. A key keeps its role when its creator's role changes, except that demoting
the creator to observer deletes their keys. A key stops working when its creator leaves the
namespace.
See API keys.
Where to go next
- Members — Adding people.
- Access Policies — Granting device access by role.
--- METRICS ---
- Files Extracted: 72
- Estimated Token Budget: ~71153 tokens
- Canonical Reference: https://codewiki.google/github.com/shellhub-io/shellhub