Skip to content

Publish Steward

Steward is a product separate from Tenantry. The Steward page generates a bundle (a one-line download command, or a .zip) for the target host, which you run yourself. Tenantry runs nothing and never uses docker.sock. No secret is in the bundle: keys and passwords are generated or entered on the target host.

Which scenario should you choose?

Situation Scenario TLS What you need to provide
Quick trial, a single customer, no Teleport Standalone console-selfsigned (default) Nothing: the console generates its certificate (browser warning)
Production without a reverse proxy, certificate from your PKI Standalone files fullchain.pem + privkey.pem in certs/
Production, public name, no existing proxy Standalone caddy-acme Public DNS, ports 80 and 443 reachable; optional ACME email
An nginx / Traefik / Caddy already exists Standalone behind-proxy Your proxy (snippets provided in proxy/)
You already have a Teleport cluster Existing Teleport console-selfsigned or files tctl, access to an application agent
No Teleport, you want one, a single host All-in-one certificate for both names 2 DNS names, port 443, certificate covering both
The console runs at the customer's site and its client proxy publishes it through its reverse tunnel (the product's main use case): new proxy Via a client proxy, new mode console-selfsigned (internal hop) A join token (30 min, created in Tenantry), outbound TCP to the master, DNS + wildcard certificate on the master
Same, with a client proxy that is already connected (chosen from the fleet) Via a client proxy, existing mode console-selfsigned or files Root access to the proxy server, console address reachable from it
Production, nothing opened on the firewall Standalone or All-in-one with Cloudflare Tunnel console-selfsigned, files or ACME DNS-01 A Cloudflare account with Zero Trust, a tunnel token (see Cloudflare Tunnel)
Password reset (helpdesk) all all Reset account (UPN + password file), helpdesk option
Lab / demonstration All-in-one selfsigned or Standalone default self-signed Nothing (hosts file on client workstations)

The helpdesk can be added to any scenario: a second steward-pwd container (internal network, no published port), a binding key generated on the host and a password read from a file or entered interactively.

What each bundle contains

All scenarios: README.md (in French, specific to the scenario), compose.yml (hardened: read_only, tmpfs, cap_drop: ALL, no-new-privileges, two named volumes), .env (no secret), config/steward.yaml, backup.sh, restore.sh, upgrade.sh, uninstall.sh.

Scenario Additional files
Standalone install.sh; certs/README.md (files); Caddyfile + caddy service (caddy-acme); proxy/nginx.conf, proxy/traefik-dynamic.yml, proxy/Caddyfile (behind-proxy)
Existing Teleport install.sh, apply-teleport.sh, teleport/{roles.yaml, app.yaml, app-service.snippet.yaml, tenantry-role.yaml}; config/steward.yaml with Authentication:Teleport pre-filled
All-in-one preflight.sh, bootstrap.sh, config/teleport.yaml, config/extra/roles-steward.yaml, certs/README.md; a single compose with the teleport and steward services
Via a client proxy, new install.sh (token), preflight.sh, config/agent.yaml, teleport/roles.yaml; compose with the steward and teleport-agent services; config/master-ca.pem to drop in if the master has a private authority
Via a client proxy, existing install.sh, proxy/app-service.snippet.yaml, proxy/enable-app-on-proxy.sh, teleport/roles.yaml; certs/README.md (files)

The console image

The image is private: Tenantry does not know it, so the image field is mandatory (exact tag or digest, never latest). Tenantry administrator can pre-fill it with DEFAULT_STEWARD_IMAGE (exposed by GET /api/bundles/defaults).

Host without registry access: on a workstation that has access, docker pull <image> && docker save <image> -o image.tar, then copy image.tar into the bundle folder. install.sh, bootstrap.sh and upgrade.sh load it with docker load -i image.tar before starting.

Cloudflare Tunnel option

The Cloudflare Tunnel option adds a pinned cloudflared service to the bundle. It opens an outbound connection to Cloudflare and no port is published to the Internet. Cloudflare Access (identity and MFA) sits in front. It is available for the standalone and all-in-one scenarios only: with an existing Teleport or a client proxy, Teleport itself publishes the console.

  • install.sh (standalone) or bootstrap.sh (all-in-one) takes --tunnel-token-file FILE, or asks for the tunnel token, typed hidden. The token is stored only in secrets/cloudflared/tunnel-token (mode 0400, uid 65532). It is never in the bundle.
  • Standalone: keep a console certificate (console-selfsigned, files or ACME DNS-01). caddy-acme and behind-proxy are refused: Cloudflare serves the public certificate. The tunnel route is https://steward:8443 (with No TLS Verify on for console-selfsigned, otherwise Match SNI to Host).
  • All-in-one: the certificate may be DNS-01, files or (lab) self-signed; HTTP-01 is refused because it needs port 80 from the Internet. Two routes, both to https://teleport:443 with Match SNI to Host: the Teleport name and the console name.
  • All-in-one and tsh or agents: they cannot log in to Cloudflare Access. Set the bind address to a private network interface (WireGuard or NetBird) and port 443 stays published on that address only, or add an Access Bypass for their source IPs. See Teleport behind Cloudflare Tunnel + Access + Duo.
  • The bundle README.md lists the public hostnames to create in the tunnel and the Access applications. Create the Access application before the public hostname.
  • Tenantry polls the console over HTTPS: if the console sits behind Access, Tenantry's egress IP needs a Bypass or an Allow policy.

Getting the bundle onto the server

Two ways, same files:

  • One command (default). Tenantry's "Generate the install command" button keeps the rendered bundle for 30 minutes behind an unguessable URL and gives you a command to paste on the target server. It downloads a self-extracting script, checks its SHA-256 (computed by Tenantry and embedded in the command), and writes ./steward-<name>/ in the current directory. No scp, no unzip. It only writes files: it installs nothing and refuses to overwrite an existing folder. Then run the lines shown below it (cd steward-<name> && ./install.sh, ...). The server must be able to reach Tenantry's public address (PUBLIC_API_BASE, https in production).
  • Zip. "Download the Docker bundle (.zip)" is the fallback when the server cannot reach Tenantry: copy it with scp and unzip it.

The bundle holds no secret, so the link can be used again until it expires (a retry, a second server); the URL is the only capability, its key is masked in Tenantry's logs and every use is audited (script.download). Generating it is audited as bundle.link.

The address the console listens on (read this before generating)

With an existing Teleport or a client proxy, the console is published by Teleport: the Teleport agent opens a connection to the console, and nobody else should. The bundle therefore binds the console to STEWARD_BIND (default 127.0.0.1) on port STEWARD_HTTPS_PORT, and the Teleport application uri is built from the same address.

  • Console and Teleport agent on the same host: keep 127.0.0.1.
  • Console and agent on different hosts (a console on its own VM, the proxy elsewhere): 127.0.0.1 cannot work, because the agent would connect to its own loopback. Set the bind address to the private IP of the console host (Advanced settings of the Steward page) before generating, and allow that port in the host firewall for the agent's address only. The warning "the console listens only on 127.0.0.1" on the page means exactly this.

You cannot reach a loopback-bound console from your browser by IP either: open it through Teleport (https://<application>.<master>).

Changing the address after the install

The address is written in three places, and the Teleport token audience must match the application uri. If you change one by hand, change all three, then restart:

Where What Command
Console host, .env STEWARD_BIND=<ip> sed -i 's/^STEWARD_BIND=.*/STEWARD_BIND=<ip>/' .env
Console host, config/steward.yaml Authentication.Teleport.Audience = the application uri, exactly sed -i "s#^\( *Audience:\).*#\1 'https://<ip>:443'#" config/steward.yaml
Proxy host, /etc/teleport.yaml uri: of the application sed -i "s#uri: 'https://<old>'#uri: 'https://<ip>:443'#" /etc/teleport.yaml

Then docker compose restart steward on the console host and systemctl restart teleport on the proxy host.

docker compose up -d does not re-read a mounted file

config/ is mounted into the container. If only config/steward.yaml changed, docker compose up -d finds nothing to recreate and the console keeps the old values. Use docker compose restart steward (or up -d --force-recreate).

Scenario 1: standalone

cd steward-acme    # after the install command above (zip: unzip steward-acme.zip && cd steward-acme)
./install.sh            # with helpdesk: ./install.sh --reset-password-file /path/password.txt

install.sh is idempotent: folders, secrets (0600), image loading, up -d, wait for the healthy state, then display of the address and the setup token (single use, also in /data/setup-token). Open the address, enter the token and follow the wizard (domain and LDAPS, technical accounts, OUs, groups, first AD administrator).

The TLS variants

The wizard asks a decision (details in TLS certificates): an automatic public certificate by default ("reachable from the Internet on port 80?" -> HTTP-01 with Caddy, else DNS-01), your own ACME / PKI, your own files, your reverse proxy, or lab (self-signed, with a warning).

  • acme-dns (default): an acme service (official acme.sh image, pinned by digest) obtains a publicly trusted ECDSA P-256 certificate through DNS-01 with your DNS provider's API (Infomaniak, OVHcloud, Cloudflare, Gandi, Hetzner, Route 53, Azure DNS, deSEC, DigitalOcean, Linode, PowerDNS), renews it (acme.sh --cron) and publishes it atomically in the acme-certs volume; the console reads it through Tls:CertificatePemPath / Tls:KeyPemPath and hot-reloads it. No inbound port, internal-only hosts work. The DNS API token is never in the bundle: ./install.sh asks for it (or --dns-credentials-file FILE) and writes secrets/acme-dns.env (0600). Also available with an existing Teleport or an existing client proxy (the agent then verifies the console with its system trust store: no CA to distribute, no insecure_skip_verify).
  • console-selfsigned (lab / testing): the console generates a self-signed certificate (2 years) on first start, with the host name entered. Ports 443 and 80 (redirect) published. Every client sees a certificate error.
  • files: the console reads a PFX (Tls:CertificatePath + Tls:CertificatePasswordFile). Drop in certs/fullchain.pem and certs/privkey.pem; install.sh checks the match, the validity and the name, builds certs/console.pfx and a random password, and hands them to the container read-only. Renewal: replace the PEMs, delete console.pfx and pfx-password, rerun install.sh, docker compose restart steward.
  • caddy-acme: a caddy service (image caddy:2.10.2, pinned) obtains a public certificate for the name and relays to the console's HTTPS listener. The console is not published. The Caddy -> console hop stays in the private Docker network (console's self-signed certificate, not verified). Requires 443 and 80 (ACME challenge) and public DNS. The certificates live in the caddy-data volume.
  • behind-proxy: the console listens only on STEWARD_BIND:STEWARD_HTTPS_PORT (default 127.0.0.1:8443). Your proxy terminates the public TLS and relays over HTTPS to that address; forward Host, X-Forwarded-For, X-Forwarded-Proto. The three snippets are ready to paste.

Scenario 2: existing Teleport

./install.sh
./apply-teleport.sh          # --identity FILE --auth-server HOST:PORT if tctl uses a dedicated identity
  1. The console is published only on STEWARD_BIND (default 127.0.0.1). If the agent is on another host, put the console's private address in .env and filter with a firewall (the bundle's README.md gives the ufw rule; with Docker, filter in DOCKER-USER).
  2. apply-teleport.sh: checks tctl, refuses to overwrite a role or an application of the same name that does not carry the tenantry-managed label (unless --overwrite), creates the steward-admin / steward-helpdesk roles and the application, then checks that they exist.
  3. The agent must trust the console's certificate (insecure_skip_verify: false): use files mode with an authority known to the agent, or add teleport/console-cert.pem (exported by install.sh) to the agent's trust store. Registration: dynamic (app.yaml, applied by the script) or static (app-service.snippet.yaml).
  4. Give the Teleport roles to the users, then open the console from Teleport: the first administrator is the Teleport user who completes the wizard. Then map the roles in the console (Delegations > Teleport).

teleport/tenantry-role.yaml (Tenantry identity) is not applied: the right to create roles is equivalent to administering the cluster.

Scenario 3: all-in-one (Teleport + console)

# certMode acme-dns (default): the DNS API token is asked for (or --dns-credentials-file FILE)
# certMode files: drop in certs/fullchain.pem and certs/privkey.pem (covering BOTH names)
./bootstrap.sh          # with helpdesk: --reset-password-file /path/password.txt
  • Certificate: acme-dns (default) or acme-http (port 80 reachable from the Internet): the acme service issues ONE certificate covering both names (Teleport and console) before Teleport starts; Teleport re-reads it every https_keypairs_reload_interval (1 h) after a renewal. files and selfsigned (lab) remain.

  • A single compose: teleport (public.ecr.aws/gravitational/teleport-distroless, version in .env, port 443, cap_add: NET_BIND_SERVICE, DAC_OVERRIDE) and steward (not published). A network alias makes Teleport's public name resolve to its container for reading the public keys (JWKS).

  • config/teleport.yaml: multiplexing on 443, https_keypairs, static application steward (uri: https://steward:8443, public_addr = console name). insecure_skip_verify: true is used there only because this hop stays in the compose's private Docker network.
  • config/steward.yaml: Issuer = cluster name, Audience = https://steward:8443 (the application URI), JwksUrl = https://<teleportFqdn>/.well-known/jwks.json, JwksCaFile if self-signed certificate, TrustSessionMfa: true.
  • preflight.sh: docker/compose, port 443 free, both names resolved to the host, clock, certificate. In files mode, the certificate must cover both names (exact names or wildcard): checked by openssl x509 -ext subjectAltName and -checkhost. In selfsigned mode: generation of a certificate with both SANs, LAB ONLY banner.
  • bootstrap.sh: preflight, startup, wait for healthy (Teleport, console, and passwords if helpdesk), creation of the roles, then of the first administrator (tctl users add). The invitation link is displayed only once.
  • Why not Teleport's built-in ACME? Application access requires the certificate to cover each application's address; the built-in ACME only issues the proxy's name. The acme.sh sidecar issues the two-name certificate instead.
  • Open https://<console> through Teleport; the first administrator is the Teleport user who completes the wizard; then map the Teleport roles to the console roles.

Scenario 4: via a client proxy

The console runs at the customer's site; a client proxy (Teleport agent with a reverse tunnel to the master) publishes it as a Teleport application. Unlike scenario 2 (application registered globally on the master, with no choice of agent), the chosen proxy serves the application: spec.host_id / spec.hostname of tctl apps ls.

Common prerequisites (master side)

  • Public address of the application: by default <application>.<master proxy domain> (e.g. steward.teleport.acme.ch). Teleport application access requires wildcard DNS (*.teleport.acme.ch) and a wildcard certificate (or one covering this name) on the master. Otherwise provide an explicit address (appPublicAddr) that your DNS and certificate cover. It must differ from the master proxy's name and the console's name.
  • Roles: teleport/roles.yaml (steward-admin, steward-helpdesk, selector app: steward) to create on the master (tctl create -f teleport/roles.yaml), then to assign to the Teleport users.
  • Versions: the agent must not be newer than the master (the masterVersion field makes a higher version get refused).
  • In the bundle, config/steward.yaml is pre-filled: Issuer = cluster name, Audience = the application URI, JwksUrl = https://<master proxy>/.well-known/jwks.json, JwksCaFile if private authority, TrustSessionMfa, LaunchUrl.

new mode: a new proxy installed with the console

# 1. In Tenantry: "Generate a join token (30 min)" (POST /api/bundles/proxy-token, 30 minutes); copy it into a 0600 file
umask 077 && cat > token.txt
# 2. on the customer host:
unzip steward-*.zip && cd steward-*
./install.sh --token-file token.txt        # or with no option: masked prompt
  • Compose: steward (not published, no port) and teleport-agent (teleport-distroless:<x.y.z> pinned in .env, cap_drop: ALL + DAC_OVERRIDE, agent-data volume). The agent opens an outbound connection to the master's proxy.
  • config/agent.yaml (mounted at the image's default path /etc/teleport/teleport.yaml, without overriding command): proxy_server = master proxy, join_params: {method: token, token_name: /run/secrets/join-token} (the token is read from a file), static application steward-<name> → https://steward-<name>:8443 (instance-specific name and audience), public_addr, label app: steward. insecure_skip_verify: true is used there only because this agent → console hop stays in the compose's private Docker network (commented in the file).
  • preflight.sh (run by install.sh): docker/compose, master DNS, outbound connection to the master proxy (/dev/tcp), clock, public address resolution, private authority.
  • install.sh: writes the token to secrets/join/join-token (0600), starts, waits for the agent to join, then deletes the token and asks you to delete your copy. If the join is not confirmed, the token is kept, the script fails and explains what to do. Rerun without a token, it restarts an already-joined agent (the identity is in agent-data).
  • Private master authority: drop in config/master-ca.pem (mounted in the agent via SSL_CERT_FILE and read by the console via JwksCaFile).
  • Backup: backup.sh adds an archive of the agent's identity; restore.sh --agent-archive <archive> restores it; upgrade.sh --agent-version X.Y.Z changes the agent version (never newer than the master, no major version jump).

existing mode: an already-connected client proxy

The proxy is chosen from the fleet (the bundle carries only its host name, for information and for verification).

./install.sh                                   # on the console host
# copy the proxy/ folder to the client proxy server, then, ON THAT SERVER, as root:
umask 077 && cat > token.txt                 # NEW token (app + windowsdesktop roles), created in Tenantry: "A client proxy already installed" option
sudo ./enable-app-on-proxy.sh --token-file token.txt --both-roles

Why a re-enrollment (proven in the lab, Teleport 18.2). A Teleport agent gets its service rights from the identity issued when it joins: adding app_service to an already-joined proxy fails, even with a valid token (control stream not configured to support app server heartbeats, or can not join the cluster with role "App"). Variants tested: Application-only token, identity kept: failure; Application + windowsdesktop token, identity kept: failure; two-role token + setting <data_dir>/proc aside (re-enrollment) + original configuration + app_service: success (apps ls shows the application served by the same host name, the static desktop stays registered, restart without the token works). Consequences to announce to the customer: the proxy keeps its name and its servers but takes a new host identifier (a new instance in the fleet, the old one expires; locks placed by identifier do not follow) and the sessions going through it are cut for a few seconds.

  • The console is published only on STEWARD_BIND (default 127.0.0.1, if console and proxy share the host). If the proxy is elsewhere: the console's private address + a firewall rule allowing only the proxy host (see the bundle's README.md).
  • proxy/app-service.snippet.yaml: static application, uri: https://<console address as seen from the proxy>:<port>, insecure_skip_verify: false by default. To have a self-signed certificate trusted: authority bundle (system store + proxy/console-cert.pem) pointed to by the proxy's Teleport service SSL_CERT_FILE (proxyCaFile documents the path). true only if the operator explicitly asks for it.
  • enable-app-on-proxy.sh (root, on the proxy): refuses to merge if an app_service: block exists (any form; it prints the entry to add by hand) or if the token is in clear text in the configuration; without systemd it does nothing and prints the manual procedure. Otherwise, in order: validation of the new configuration (teleport configure --test on a temporary file in the same folder), confirmation (type the machine name, or --yes), systemctl stop teleport, setting <data_dir>/proc aside (proc.before-app-<date>), backup of the configuration and the old token, atomic installation of the configuration and the token (0600, location read from auth_token: or join_params: token_name:), systemctl start teleport, wait for the active state and for "All applications successfully started" with no join error in journalctl. At the slightest failure or signal: stop, restoration of proc, the configuration and the token, restart, non-zero code. On success: token deleted (shred) and old identity deleted (stale keys), after displaying what is deleted and what is kept. --both-roles attests that the token has both roles (the script cannot read them). The token is never displayed. Idempotent.

Review 4 (re-enrollment robustness): restoration is uninterruptible (HUP, INT, TERM and SIGPIPE ignored, output redirected to a .teleport-app-enrol.log.* log next to the configuration); run the script under systemd-run --unit=teleport-app-enrol --wait --collect (or nohup, tmux). State flags are set before each action and the restoration tests the actual existence of the backups; if it partially fails, an "INCOMPLETE RESTORE: MANUAL ACTION REQUIRED" frame lists precisely what remains and where (non-zero code). After "All applications successfully started", a 15 s observation (service active, no join error) precedes the conclusion; a join refusal (can not join the cluster with role, token expired or not found, control stream not configured) makes it fail immediately. The old identity is deleted only after this observation; after a restoration, the aborted identity (private keys) is deleted (shred). The configuration is snapshotted at the start: if it changes during the confirmation, the script stops. data_dir is read without comments or CRLF; duplicate or flow form: failure with --data-dir DIR; <data_dir>/proc missing: failure before any modification. Your token file is deleted (shred) after success; the token stays valid for 10 minutes and multi-use in the cluster: tctl tokens rm <the token created in Tenantry> as soon as you are done. The two-role token lives 10 minutes (30 for an application-only token); the cap of 5 tokens counts only application tokens with a readable future expiry, and the creation lock is per process.

Review 3: the application name is steward-<instance name> (specific to each site); token creation is limited (3/min, 409 refusal at 5 valid application tokens, all types combined); backup.sh never archives secrets/join. The roles: 'app+windowsdesktop' option of POST /api/bundles/proxy-token creates a --type=app,windowsdesktop token (only the values app and app+windowsdesktop are accepted, 400 otherwise; the audit records the role set).

Verify

In Tenantry, Verify (POST /api/steward/verify) checks the roles, the application, its URI, its label and displays served by <host>: the proxy that actually serves the application. With expectServedBy (the chosen proxy), any other host makes the verification fail (old global registration on the master, wrong proxy). An unreadable list returns 502, never "nothing is served".

Operations (all scenarios)

Action Command
Backup ./backup.sh [folder]: consistent copy of the database + /secrets (master key) + host archive (.env, config/, secrets/, certs/, data/). Archives 0600, to be encrypted.
Restore ./restore.sh <console-archive.tgz>: stops the console, sets the current state aside, restores, waits for healthy.
Upgrade ./upgrade.sh <new-image> [--teleport-version X.Y.Z] [--agent-version X.Y.Z]: backs up, changes the tag in .env, loads image.tar or pulls the image, recreates, waits for healthy. Refuses latest and, for Teleport, a major version jump.
Rollback cp .env.before-upgrade-<date> .env && docker compose up -d, then ./restore.sh if the database has already migrated (migrations follow an automatic backup in /data/backups).
Stop ./uninstall.sh keeps the volumes; ./uninstall.sh --purge deletes them after confirmation (type the project name).

Troubleshooting

Symptom Lead
steward not healthy docker compose logs --tail 80 steward: image missing (registry login or image.tar), unreadable PFX, DC not resolved (bundle DNS entries).
Setup token lost docker compose exec steward dotnet Steward.Api.dll reset-setup --confirm.
Port already in use ss -ltnp (filter on 443), then STEWARD_HTTPS_PORT in .env.
preflight.sh: the certificate does not cover a name The certificate must contain both names as SANs (or *.domain).
Caddy does not obtain a certificate Public DNS, ports 80/443 reachable, docker compose logs caddy.
Client proxy new: the agent does not join docker compose logs teleport-agent: token expired (30 min) or already used (create another), private authority (config/master-ca.pem), DNS or outbound firewall, clock. The token is deleted only after a confirmed join.
Client proxy: the public address does not respond Wildcard DNS and wildcard certificate on the master for <application>.<master domain> (or appPublicAddr covered).
"Verify": served by another host The application is served by another agent (or registered globally by scenario 2): delete the old app resource.
Teleport: the console answers "access denied" Open it from Teleport, check Issuer / Audience / JwksUrl in config/steward.yaml and the clock.
"The Teleport identity could not be verified" docker compose logs steward \| grep 'Teleport JWT rejected' gives the reason. SecurityTokenInvalidAudienceException: Audience is not exactly the application uri (after an address change, see above); restart with docker compose restart steward. Also open the console from Teleport, never by its IP.
"Verify": application ... not registered in the cluster Fixed in current Tenantry: a static app of a proxy's teleport.yaml is not an app resource (tctl get app/<name> says "does not exist") but shows in tctl apps ls. Update Tenantry; the check now reads both.
The application name does not open in the browser See the three prerequisites in Teleport behind Cloudflare: wildcard DNS, an edge certificate that covers *.teleport.<domain>, and the tunnel route.
Steward setup token docker compose exec steward cat /data/setup-token from the bundle folder (a new token at every start until setup is finished).
Behind a proxy, all clients have the same IP See below.

Points to check behind a reverse proxy

The console's documentation mentions neither support for X-Forwarded-For / X-Forwarded-Proto, nor a list of trusted proxies. Behind a proxy, the console may therefore log the proxy's address as the client address and apply its per-IP rate limiting to all clients at once. Test with your proxy before production; the existing Teleport or all-in-one mode behaves the same way (the client seen is the Teleport agent).