Skip to content

Installing the server

Getting one ssoosshd process running on a Linux host. Everything here is the same whether you end up running one instance or twenty; the split-process and multi-instance work comes later.

The quickest path is the signed repository – sudo dnf install ssoosshd, or sudo apt install ssoosshd, once the release package is in place. See Installing from packages for the one-time setup and for what each package manager verifies.

The server ships as a ssoosshd package in .deb, .rpm and .apk, all carrying the same statically linked binary, built for linux/amd64 and linux/arm64. There is no libc to choose: the default build is cgo-free, so one binary per architecture runs on any distribution.

An ssoosshd-pkcs11 package (.deb and .rpm) exists for operators who need to load a PKCS#11 module into the server process itself. It is dynamically linked and needs glibc 2.28 or newer, and it declares Conflicts/Replaces/Provides against ssoosshd because both install the same binary path. Most HSM deployments do not need it – an ssh-agent holding the token reaches the same hardware from the default package. See HSM and PKCS#11 and The CA key in an ssh-agent.

On rpm, both packages recommend ssoosshd-selinux, the SELinux policy that lets the shipped ssoossh-agent.service start ssh-agent on an enforcing host. A server that reads its key from config does not need it; see SELinux on EL hosts.

What Where it lands
the binary /usr/sbin/ssoosshd
the annotated config sample /etc/ssoossh/ssoosshd.yaml
man pages ssoosshd(8) and one page per subcommand (ssoosshd-serve(8), ssoosshd-serve-api(8), ssoosshd-serve-full(8), ssoosshd-sign(8), ssoosshd-config(8), ssoosshd-config-check(8), ssoosshd-ldap(8), ssoosshd-ldap-probe(8), ssoosshd-ldap-sync(8), ssoosshd-version(8)), and ssoosshd.yaml(5)
reference mail templates /usr/share/ssoossh/mail-templates/

The config sample is installed as a no-replace config file, so an upgrade never overwrites your edits. It is generated from the server’s config structs, which makes it the same content as ssoosshd.yaml(5) and the configuration reference on this site. A key the server does not recognise, a typo or one from a newer release, does nothing, so the server logs a config key is not recognised warning at startup naming the key, the file, and the closest real key when one is close. ssoosshd config check reports the same without a start.

There are also container images, ghcr.io/mnestor/ssoossh-server:<version> (the default, on distroless/static-debian12) and ghcr.io/mnestor/ssoossh-server:<version>-pkcs11 (on distroless/cc-debian12, the one that can load a module).

image tag 1.2.4, and points the floating tags 1.2 and 1 at it too. Which one you pin decides how much a restart is allowed to change: 1.2.4 never moves, 1.2 follows patch releases, 1 follows minor releases as well. Both floating tags move only for a final release that is the newest in its series, so a release candidate never becomes what 1.2 means. There is no latest and there will not be: every tag published here names a piece of a version, so the one you pin says how far an upgrade may carry you, and latest is the tag that would say nothing at all. Each build also keeps an immutable sha-<commit> tag, which is what all three version tags are promoted from; pinning that works too, it just names a commit rather than a release. A Compose deployment ships at deploy/docker-compose.yml.

ssoosshd signs with an SSH CA keypair. Generate one:

Terminal window
ssh-keygen -t ed25519 -f ca -C "ssoossh CA" -N ""

That writes ca (private) and ca.pub (public). The private key goes into ssh_key as inline PEM, not as a file path, using a YAML literal block:

ssh_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

Once it is in the config file, delete the plaintext ca file or move it somewhere access-controlled.

Exactly one of ssh_key, ssh_key_file, ssh_key_agent or hsm may be set, and a signing process needs one of them: startup fails with none. To keep the private key out of the process entirely, source it from a PKCS#11 token (HSM and PKCS#11) or split the signer into its own process (Startup modes) – or both.

Ed25519 is a good default for ssh_key. It is the one algorithm the PKCS#11 path does not support, so choose ECDSA or RSA if you intend to move the key to a token later.

The package installs the unit at /usr/lib/systemd/system/ssoosshd.service and creates the ssoossh service account it runs as. It does not enable or start anything: ssoosshd will not run without a CA key and an identity provider configured, so a service started at install time would only fail on every fresh install. Configure it first, then:

Terminal window
install -o ssoossh -g ssoossh -m 0750 -d /var/lib/ssoossh
chown -R ssoossh:ssoossh /etc/ssoossh
systemctl enable --now ssoosshd

The shipped unit is deploy/ssoosshd.service. What it does, and why:

Directive Why
User=ssoossh, Group=ssoossh a dedicated unprivileged account rather than root or DynamicUser, because the identity has to be stable enough to chown the config and data directories to it ahead of time
StateDirectory=ssoossh, mode 0750 /var/lib/ssoossh, where the default SQLite file belongs
LogsDirectory=ssoossh, mode 0750 /var/log/ssoossh, for any logging.filename you set rather than leaving output to journald
ReadOnlyPaths=/etc/ssoossh the config directory holds the CA private key and is never written by the service
NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, PrivateDevices, ProtectKernelTunables, ProtectKernelModules, ProtectControlGroups, RestrictSUIDSGID, RestrictRealtime, LockPersonality, MemoryDenyWriteExecute the sandbox
Restart=on-failure, RestartSec=5s a config error fails at startup, so a crash loop here is a signal, not a nuisance

The unit also carries a commented Environment=GODEBUG=fips140=only line. It is left commented because it would enforce FIPS on every server that installs the unit, regardless of whether that deployment sets fips.

Point db.connection_string at a path inside the state directory so the SQLite file persists across restarts:

db:
connection_string: /var/lib/ssoossh/ssoossh.db

A CA key, a public URL, and an OIDC client. Everything else has a working default.

ssh_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
-----END OPENSSH PRIVATE KEY-----
http:
# The scheme and host browsers actually reach this deployment at.
public_url: "https://ssh.example.com"
authentication:
client_id: "..."
client_secret: "..."
provider_url: "https://idp.example.com"

http.public_url is required and is an origin only – a path, query, or fragment is rejected at startup. Four things derive from it: the OIDC redirect URI, the origin the CSRF middleware compares against, the host name requests must be addressed to (anything else gets 421 Misdirected Request), and whether the deployment is HTTPS at all, which decides the session cookie’s Secure attribute.

More complete starting points, including TLS, PostgreSQL, and multi-instance: Server configuration examples.

ssoosshd config check loads the configuration exactly as serve and sign do (the same file search or --config, defaults.yaml, the ldap.schema preset, then the file) and runs the same validation, but reports every problem instead of stopping at the first:

  • settings a start refuses, in every section, each tagged with its block
  • keys nothing reads, each with the file it is in and the closest real key
  • the TLS key pair, and the cert_options key ID templates, require conditions and lifetime policies the certificate service parses at start
  • the warnings a start logs: a non-https public_url, a plaintext or unauthenticated mail relay, ldap.tls_insecure_skip_verify, and the rest
Terminal window
sudo ssoosshd config check # the file serve would find
sudo ssoosshd -c /etc/ssoossh/ssoosshd.yaml config check --strict
ssoosshd -c ssoosshd.yaml config check --json # machine-readable
ssoosshd -c ssoosshd.yaml config check --show-sources

It exits 0 when the configuration is valid, warnings included, and 1 when it has any error. Unknown keys are warnings, because a file written for a newer release must still load; --strict makes them errors. --show-sources adds every effective setting and the layer it came from (default, preset or file), with secrets shown as [redacted], as on the admin configuration page. The server reads no configuration keys from the environment, so there is no environment layer.

Nothing is contacted: no database, directory, NATS broker, CA key source or identity provider. Use ldap probe and the diagnostics page for those. Local files the configuration names are read, as a start reads them: ssh_key_file, the TLS pair, the NATS certificates, password files and mail.template_dir. Run it as the user the service runs as, on a host where those paths exist, or expect it to report them missing.

In CI for a config repository, where the CA key and certificates are not present, point those paths at stand-ins in the job and keep --strict, so a renamed or misspelt key fails the pipeline rather than the next restart:

- name: Check ssoosshd.yaml
run: ssoosshd -c ssoosshd.yaml config check --strict --json > config-check.json

In a package postinstall, report a problem but do not fail the install: an upgrade that leaves a key the new release no longer reads is something to say, not a reason to leave the package half-configured.

Terminal window
if ! ssoosshd config check >/dev/null 2>&1; then
echo "ssoosshd: the configuration has problems; run 'ssoosshd config check' before restarting" >&2
fi
Terminal window
systemctl status ssoosshd
journalctl -u ssoosshd -f

ssoosshd validates its whole configuration at startup and refuses to run on a bad one rather than failing at the first request, naming the problem in each section that has one. A malformed key ID template, an unparseable CIDR in a source policy, a mail template override that does not compile, an unreadable SMTP password file, a lifetime policy with no default_duration, multi_instance: true with no http.cookie_key – all of these stop the process with a message naming the setting. ssoosshd config check finds them without a start.

Two liveness endpoints answer without authentication, and both are registered ahead of the host-name check so a probe addressing the server by IP still reaches them:

Terminal window
curl -f http://localhost:8080/healthz # {"status":"ok"}
curl -f http://localhost:8080/ping # pong

Then confirm the CA key the running server actually holds, rather than trusting the file you generated:

Terminal window
ssoossh --server https://ssh.example.com ca

That is the same key to install on your hosts as TrustedUserCAKeys – Trusting the CA in sshd.

/api/version answers without authentication, and the web UI’s footer renders what it says: a tagged build as its version linked to the GitHub release, an untagged one as development with a short commit. That is convenient for support, and it is also enough for anyone to match a build against known issues. version.mode decides how much to say:

version.mode /api/version returns The footer shows
show (default) the real version, commit, and release URL the version, linked to its release
hide every field empty no version at all
any other string that string as the version, with no commit, repository, or release link that string, unlinked

show and hide are reserved words, so neither can itself be used as a decoy version.

Point it at your identity provider. Until an OIDC client exists, nobody can log in to approve anything.