Skip to content

Installer reference

deploy/install.sh installs, reconfigures and upgrades one client instance on a Linux Docker host. Instances share nothing: each has its own directory (/opt/steward/<name>), compose project (steward-<name>), volumes, database, secrets and audit log. It never touches Active Directory: AD rights are applied separately by a domain administrator (Delegation model and AD rights).

New here? Follow the Quick start first; this page is the reference behind it.

Linux only

Run the console on a Linux member server with Docker Engine and Docker Compose v2, never on a domain controller. Windows hosting is not supported: the LDAPS certificate validation follows a different code path there that is not tested.

Two ways to run it

Mode How When
Guided sudo deploy/install.sh in a terminal, without options (or with --interactive) First install, occasional changes
Scripted sudo deploy/install.sh --name ... --hostname ... [options] Automation, documented re-runs, upgrades

Both run the same checks and the same code: the guided install only builds the option list, shows it as the "equivalent command" in its summary, and hands it to the scripted path. Options are the reference; --non-interactive makes sure nothing is ever asked (for automation), and --dry-run checks the options, prints the resolved plan and stops before changing anything:

sudo deploy/install.sh --name acme --hostname steward.client-example.ch --tls acme-dns --dns-provider ovh \
     --dns-credentials-file /root/ovh.env --dry-run

Get the installer and verify the image

git clone https://github.com/Verttigo28/steward.git && cd steward && git checkout v1.0.0   # or unpack the release archive

Without --image or --build, the instance pulls verttigo28/steward:<version> from Docker Hub (mirror: ghcr.io/verttigo28/steward). --version defaults to latest: pin a release in production.

Verify the image (supply chain)

Every release image (linux/amd64 and linux/arm64) is built by the Release GitHub Actions workflow, carries an SBOM and a provenance attestation, and is signed by digest with cosign keyless signing (the GitHub OIDC identity of that workflow, no key to manage). Check it before installing:

VERSION=1.0.0
cosign verify "verttigo28/steward:$VERSION" --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity "https://github.com/Verttigo28/steward/.github/workflows/release.yml@refs/tags/v$VERSION"
# same for the mirror: ghcr.io/verttigo28/steward:$VERSION

A successful run proves that the image was produced by the release.yml workflow of Verttigo28/steward for that tag.

The installer does it too. After the pull, install.sh resolves the image to its digest and writes STEWARD_IMAGE=verttigo28/steward:<version>@sha256:<digest> into <instance>/.env: restarts, recreates and re-runs without --version run exactly that image, whatever later happens to the tag. When cosign is installed on the host it then runs cosign verify <image>@<digest> with the issuer https://token.actions.githubusercontent.com and the identity https://github.com/Verttigo28/steward/.github/workflows/release.yml@refs/tags/v<X.Y.Z> (any release tag), and stops when the signature does not verify. Without cosign it warns and continues; --require-signature makes the verification mandatory (no cosign, no digest, or an image that is not the official one: refused). For a mirror or your own build pipeline, set STEWARD_COSIGN_IDENTITY_REGEXP to the identity that signs it. You can also install by digest yourself (--image verttigo28/steward@sha256:<digest>). On a host that cannot reach the registry, build from the source you reviewed with --build.

The guided install

The questions, in order (defaults in brackets; on an existing instance, its current values are the defaults):

  1. Instance: name (acme: lowercase letters, digits, -), DNS name users will open, Steward version.
  2. Active Directory (optional): domain and DC names. They are not stored: the browser setup wizard asks for them. Their only use here: a DC that this host cannot resolve can be mapped to its IP inside the containers (--add-host).
  3. Helpdesk (off by default): the reset account's UPN and password; optionally the LAPS account's UPN and password.
  4. Exposure: LAN only, behind your reverse proxy/WAF, through Teleport, or Cloudflare Tunnel + Access (then the tunnel token).
  5. HTTPS certificate: public CA through ACME (port 80 reachable from the Internet? yes: HTTP-01; no: DNS-01 with a provider from the list: Infomaniak, OVHcloud, Cloudflare, Gandi, Hetzner, AWS Route 53, Azure DNS, deSEC, or any other acme.sh provider with its variables), your own ACME CA (directory URL, root, EAB), your certificate files, or self-signed (labs).
  6. Summary and confirmation, with the equivalent command.

Secrets (AD passwords, typed twice; DNS API tokens; tunnel token; EAB key) are read without echo, written to private temporary files (mode 0600, removed when the script exits) and passed as --*-file options, like a scripted install. They are never printed (the equivalent command shows <file holding ...> placeholders), never logged, and stored only under /opt/steward/<name>/secrets/. On a re-run, leave a secret empty to keep the stored one.

Options

Option Meaning
--name <name> Instance name: lowercase letters, digits, -, 31 characters at most. Required.
--hostname <dns name> Name users open; it goes into the certificate. Required on a first install (a re-run keeps it, and the ports)
--version <x.y.z> / --image <ref> / --build Release from Docker Hub (first install: latest unless given; a re-run without any of them keeps the image in use) / any image reference / build from this source tree
--dir <path> Base directory (default /opt/steward); the instance lives in <dir>/<name>
--https-port <n> / --http-port <n> Host ports (default 443 / 80; port 80 only redirects to HTTPS and answers HTTP-01)
--add-host <fqdn>:<ip> (repeatable) / --no-add-host Map DCs the host DNS cannot resolve (Steward connects to DCs by name only) / remove the mappings
--helpdesk --reset-upn <upn> --reset-password-file <file> / --no-helpdesk Start the separate password service (Helpdesk) / stop it
--laps-upn <upn> --laps-password-file <file> Dedicated LAPS account, with --helpdesk (LAPS)
--pin-password-service With --helpdesk: pin the password service to the console's current domain, DCs, LDAPS CA and managed OUs, shown for review first. After the setup wizard and after every change of them (Helpdesk)
--pwd-domain <dns name> --pwd-dcs "<fqdn> ..." --pwd-ca-file <pem> --pwd-managed-ous <guid>,... The same pins given explicitly
--trusted-proxy <ip\|network> (repeatable) / --no-trusted-proxy Reverse proxy whose X-Forwarded-For is believed (Exposure); automatic with Cloudflare Tunnel
--require-signature Refuse an image that cosign cannot verify (cosign missing included): see Verify the image
--tls acme-dns --dns-provider <name> --dns-credentials-file <file> Publicly trusted certificate, DNS-01 (HTTPS certificate)
--tls acme-http Publicly trusted certificate, HTTP-01 (port 80 reachable from the Internet)
--acme-email, --acme-server, --acme-eab-kid, --acme-eab-hmac-file, --acme-ca-bundle, --wildcard, --acme-dns-sleep, --acme-challenge-alias ACME details: see HTTPS certificate
--tls files --cert <fullchain.pem> --key <key.pem> Your own certificate files
--tls selfsigned Labs only: every browser shows a certificate error (the default when nothing else is given, with a warning)
--exposure lan\|proxy\|teleport\|cloudflare-tunnel Who reaches the console and how (default lan): Exposure and access
--tunnel-token-file <file> With --exposure cloudflare-tunnel: file holding the tunnel token (first time, or to rotate it)
--interactive / --non-interactive Force the guided install / never ask anything
--dry-run Check everything, print the plan (secrets: "from file", "kept" or "none", never values), change nothing
-h, --help Usage

Files given with --*-file hold one value (or NAME=value lines for --dns-credentials-file); create them with mode 600, and delete them after the install: the installer keeps its own copy.

Every choice is kept across re-runs (it is recorded in <dir>/<name>/.env, without secrets): re-running with only --name and --hostname keeps the image version, the TLS method, the exposure, the helpdesk and the DC mappings, and passing an option changes only that.

Examples

# Client LAN, DNS-01 with Infomaniak (example provider), helpdesk with LAPS
sudo deploy/install.sh --name acme --hostname steward.client-example.ch --version 1.0.0 \
     --dns-provider infomaniak --dns-credentials-file /root/infomaniak.env --acme-email ops@msp.example \
     --helpdesk --reset-upn svc-steward-reset@corp.client-example.ch --reset-password-file /root/reset.txt \
     --laps-upn svc-steward-laps@corp.client-example.ch --laps-password-file /root/laps.txt

# MSP technicians through Teleport, client's own certificate
sudo deploy/install.sh --name beta --hostname steward.beta.example.org --version 1.0.0 --exposure teleport \
     --tls files --cert /root/fullchain.pem --key /root/key.pem

# Cloudflare Tunnel + Access: no inbound port at all
sudo deploy/install.sh --name gamma --hostname steward.gamma-client.ch --version 1.0.0 --exposure cloudflare-tunnel \
     --tunnel-token-file /root/tunnel-token.txt --dns-provider cloudflare --dns-credentials-file /root/cf.env

What it does

  1. Checks every option (and that credential files hold the variables the DNS provider needs: names only, values are never read).
  2. Pulls (or builds) the image, and the pinned sidecar images it needs (acme.sh, cloudflared).
  3. Writes the instance directory and copies the secrets into it, owned by the container user that reads them.
  4. With ACME: obtains the certificate and checks it before Steward starts (when that fails, Steward is not started and the script says why).
  5. Starts the compose project, waits until it is healthy, and checks that the console serves a certificate that verifies for the name users open.
  6. Prints the URL, the TLS and exposure summary with what is left to do (for Cloudflare: the public hostname and the Access application to create), the one-time setup token (also in /data/setup-token inside the container) and the backup command.

What it creates

/opt/steward/acme/
├── compose.yml                 the instance's compose file (no "build"; no ports behind Cloudflare Tunnel)
├── compose.acme.yml            ACME sidecar (acme-dns / acme-http)
├── compose.tls-files.yml       your certificate files (files)
├── compose.cloudflared.yml     Cloudflare Tunnel sidecar (cloudflare-tunnel)
├── compose.hosts.yml           DC name -> IP mappings (--add-host)
├── .env                        every non-secret choice (re-runs read it)
├── config/                     steward.example.yaml; put your steward.yaml here (optional settings)
├── acme/                       ACME sidecar script (+ the internal CA root)
├── tls/                        your certificate and key (files), readable by the container user only
└── secrets/                    0700: reset/LAPS passwords and link key (0600), ACME credentials (0400, root),
                                tunnel token (0400, cloudflared's user)

Plus two Docker volumes per instance: steward-data (database, backups, LDAP trust store, setup token) and steward-secrets (master key, Data Protection keys, generated certificate). The containers run as a non-root user, with a read-only root file system, no capabilities and no-new-privileges.

Optional settings (session lengths, throttling, audit retention, Teleport sign-in, ...) go in config/steward.yaml: copy config/steward.example.yaml, which documents each key with its default. Environment variables STEWARD_<Section>__<Key> override the file.

Re-runs, changes and upgrades

Re-run the script to change anything (it keeps what you do not pass), or to upgrade: --version <newer> pulls the new image; pending database migrations run at start, after an automatic backup. Details: Operations.