# Kadeessh, an SSH server that plugs into Caddy

> Kadeessh, formerly Caddy-SSH, is an SSH server written in Go that runs as a Caddy app, so an SSH listener, its authentication providers, its session authorisation and the actions a session may take are all configured in a Caddy JSON config alongside HTTP and TLS. The README marks it as still under heavy work in progress.

**kadeessh/kadeessh** — Kadeessh (formerly Caddy-SSH) is a general-purpose, extensible, modular, memory-safe SSH server built in Go

- Repository: https://github.com/kadeessh/kadeessh
- Stars: 564 · Forks: 15
- Language: Go
- License: Apache-2.0
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/kadeessh-kadeessh

## An SSH daemon as a Caddy app

Kadeessh, formerly Caddy-SSH, is described as an extensible, modular SSH server built as a Caddy app, with the aim of providing an SSH server with safe, modern and secure defaults. The README opens by saying it is still under heavy work in progress, and that warning belongs at the top of any evaluation.

The name has a story behind it. Kadeesh is the Levantine Arabic word for a hinny, the reciprocal of a mule, and the README frames it as a hat-tip to the two basic components of the project, Caddy and SSH. The primary language is Go, the licence is Apache-2.0, and the topics list names the territory: authentication, caddy, go, openssh, ssh and ssh-server.

Being a Caddy app is the whole architectural idea, and it has consequences in both directions. In one direction you get Caddy's properties for free: one process, one JSON configuration file, and the same address syntax, TLS handling and process supervision that a Caddy deployment already has. The sample configs all use the same shape, an `apps` object with an `ssh` key inside it, which is exactly where a Caddy user expects a new protocol to appear.

In the other direction you get Caddy's constraints. The configuration is a Caddy config, with Caddy's JSON adapter conventions, and the SSH-specific behaviour is expressed through Caddy app modules. That is why the sample configs are JSON rather than the SSH daemon's own file format, and why the server model has `configs`, `actors` and an `authorize` block rather than the familiar `sshd_config` directives.

The repository layout is small and mostly internal. There is `app.go` at the root, a `docs/` directory, an `internal/` directory, `go.mod`, `go.sum`, a `.golangci.yml` and a `.goreleaser.yaml`. The dependency list shows what is actually being used, and it is a serious one: `github.com/caddyserver/caddy/v2` at v2.11.4, `go.step.sm/crypto` for SSH key handling, `github.com/pkg/sftp` for SFTP, `github.com/msteinert/pam/v2` for PAM authentication, `github.com/creack/pty` for PTY allocation, `golang.org/x/crypto` at v0.54.0 and `github.com/tweekmonster/luser` for local user lookup, alongside `zap` for logging and `sprig` for templating.

The README notes that once the design and implementation are settled, the packages will be extracted outside of `internal`, which tells you exactly how unfinished the public API is.

## Servers, configs and actors

The configuration model has three moving parts, and understanding them tells you how the server is wired.

The `servers` map holds the listeners. Each one has an `address`, and the sample uses `tcp/0.0.0.0:2000-2012`, which is a range of ports rather than a single one, following Caddy's listener address syntax.

The `configs` list holds authentication configurations, and each entry can be scoped by a `match` block. The `act` section then says what an authenticated session is allowed to do, such as `shell`.

The `actors` list is the part that is worth reading carefully, because it is where per-user policy lives.

```json
          "actors": [
            {
              "match": [
                {
                  "user": {
                    "users": [
                      "user1"
                    ]
                  }
                }
              ],
              "act": {
                "action": "shell"
              }
            }
          ]
```

A `match` block says who an actor applies to, and here the criterion is a user name, `user1`. The `act` block says what that actor does, here granting a shell session. The match criteria are a list, so an actor can be selected by several conditions at once, and in the second sample config the criterion is a remote IP range instead.

That structure is the interesting design decision. Instead of a policy file that says what a given user may do, Kadeessh has a list of actors, each with a selector and an action, and the server evaluates which actor applies to a session. It reads a little like a routing table, which fits a project whose whole purpose is to live inside a Caddy config next to HTTP routing rules.

The server-level options also carry Caddy-style settings. The `grace_period` is set to `2s`, and `pty` has a `pty` key set to `allow`, which is what permits interactive sessions with a pseudo-terminal rather than only command execution.

## Authentication providers and the unauthenticated path

Authentication is expressed as a set of providers under an `authentication` block, and there are several ways to turn it on.

The static password provider lists accounts with a name and a password hash.

```json
                "authentication": {
                  "username_password": {
                    "providers": {
                      "static": {
                        "accounts": [
                          {
                            "name": "user1",
                            "password": "JDJhJDE0JDcxOENoL2duS3FuR2VPRUpLa2lVM085Mk40T1JkcHBvQW4ycHU2c0FkMm1qLkhKejhzWG9t"
                          }
                        ]
                      }
                    }
                  }
                }
```

The stored password is not plain text, it is a bcrypt hash, which is the format you get from tools such as `htpasswd` with the bcrypt option. The README notes that the password in the sample is `test`, and that once you are satisfied with the design and implementation the packages will be extracted outside of `internal`.

The public key provider uses an `os` provider in the other examples, which means the server validates against the keys held by the local operating system for the account, which is the standard OpenSSH behaviour of reading `authorized_keys`. A `deny_users` list can be placed alongside it, and the sample that allows local users to log in without authentication except root combines `deny_users` with `public_key` in one configuration, so that root is excluded and everyone else falls through to OS keys.

That second sample is also the one that carries the `no_client_auth` switch, set to true for connections coming from the remote address range `192.168.0.0/16`, and set to false in the default configuration. So the model is that each entry in `configs` can carry its own match and its own authentication rules, which means different sources of connections can be treated differently. That is a real feature, and it is also the setting to look at hardest, because a rule that allows connections from a private range without authentication is exactly the kind of thing that gets copied into production without being thought about.

The `loader` key set to `provided` appears in every config and appears to select how the server configuration is supplied, as opposed to being loaded from somewhere else.

## Session authorisation, jumping and forced commands

Authorisation is a separate stage from authentication, and the README describes it as a modular process that controls session authorisation based on the session context details.

Two authorizers are described. The `max_session` module restricts the number of currently active sessions to a certain number, and the `public` module grants access without restriction and is the default if none is provided.

```json
          "authorize": {
            "authorizer": "max_session",
            "max_sessions": 2
          },
```

That is a server-level block rather than a per-actor one, so the limit applies to the whole listener, which is what makes it useful for a shared machine where you want to bound how many people can be logged in at once.

The jump server example is the one that shows how much of OpenSSH the project has absorbed. The README explains that as a jump server, the server establishes a local forwarding channel to upstream per the documentation of the `-J` option, so this has to be enabled in the config. The quoted OpenSSH text describes connecting to a target host by first making an SSH connection to the jump host and then establishing a TCP forwarding to the ultimate destination, and notes that multiple jump hops may be separated by comma characters.

The mechanism in the config is a `localforward` block with `forward` set to `allow`, alongside a `signer` block whose `module` is `fallback` and a public key provider using the `os` provider. So Kadeessh is not inventing a jump host concept, it is implementing the `-J` semantics of the OpenSSH client, which is the sensible choice since that is what people already know how to use.

The forced command example is described as running the `go version` command for authenticated users, regardless of the command the user has sent. That is the `force_command` behaviour, and it is the standard way to turn an SSH login into a fixed command, useful for a server whose only purpose is to print a version or run a controlled diagnostic.

Together these show the scope: shells with a PTY, forced commands, local port forwarding for jump hosts, per-user actors and a session count limit. The pieces a working SSH server needs are mostly there, in the configuration rather than in a daemon-specific file.

## Installing it, and the state of the project

There are two install routes, and the README is explicit about both.

The first is to look for the binaries on the GitHub Releases page, download the executable and place it somewhere in your PATH. The second is to build the project with xcaddy, Caddy's own build tool.

```shell
xcaddy build --with github.com/kadeessh/kadeessh[@<version>]
```

The `[@<version>]` part is optional, and the version may be replaced by the desired version. That single command produces a Caddy binary with the SSH app compiled in, which is the whole point of the Caddy app model: there is no separate daemon to supervise, and the SSH listener lives in the same process as your HTTP and TLS configuration.

The Go module requires Go 1.25.8, so a recent toolchain is needed if you are building from source.

Now the state of the project, which is the part to weigh before adopting it. The README says it is still under heavy work in progress. The recorded releases are v0.0.2 from 2023-09-20 and v0.0.1 from 2022-03-28, so the last tagged version is a 0.0.x from three years ago. The last push to the repository was on 2026-07-18, which shows the branch is still moving even though nothing has been tagged in a long while, and the release tags being at 0.0.x is consistent with the README's own statement that the packages are still under `internal` and will be moved out once the design is settled.

That combination, active development on the branch but no release since 2023 and the public API not yet settled, means the sensible way to run it is from source or from a build you make yourself, and to expect the configuration keys to change. The `.goreleaser.yaml` at the repository root shows that automated release tooling is configured, and the `.golangci.yml` shows linting is enforced in CI, so the project has the usual hygiene even though it has not cut a release.

The Apache-2.0 licence is the permissive one and covers the code as it stands, so there is no licensing obstacle to building on it.

## The alternatives and the honest comparison

The obvious alternative is OpenSSH, the reference server that the topic list names and whose `-J` and `force_command` semantics this project reuses. The difference in approach is worth being precise about.

OpenSSH is a mature, single-purpose daemon with a configuration file of its own, decades of hardening behind it, and a client ecosystem that matches it exactly. Its unit of configuration is a `Match` block in `sshd_config`, its authorisation is expressed through the authorized_keys file format and the `AllowUsers` and `DenyUsers` directives, and its session limits come from a set of `MaxSessions` style options.

Kadeessh takes a different route on each of those. The configuration is Caddy's JSON, the session limit is a `max_session` authorizer on the server, the users an actor may act for are chosen by a `match` block, and the authentication providers are Caddy-style module configurations with an `os` provider for local keys. The advantage of that route is compositional: the SSH server sits next to HTTP in one file and one process, so the same deployment, the same port ranges and the same secret handling apply to both. The cost is that none of the SSH configuration is OpenSSH configuration, so knowledge does not transfer, and the maturity gap is the gap between a daemon that has been hardened for decades and a 0.0.x release.

Two other options exist for the same requirement. A Caddy plugin that speaks SFTP only would cover file transfer without a shell, and a solution built on an SSH library you embed, rather than run, would let you control the protocol handling inside your own service. Neither is a like-for-like replacement, because both give up the part that matters here, which is a real SSH server that standard clients connect to.

So the decision is between OpenSSH's maturity and Caddy's integration, and for a deployment that already runs Caddy with a complicated configuration, the integration is a genuine argument. For a general-purpose SSH server on a machine with real users on it, a 0.0.x release from 2023 is the fact that decides it.

## Conclusion

Kadeessh suits an operator who already runs Caddy and wants SSH on the same ports, with the same JSON configuration and the same process supervision, and it suits a Go developer who wants to read how a modular SSH server is put together. It does not suit anyone who needs a stable, widely deployed SSH daemon, because the README calls it heavy work in progress, the packages still live under `internal/`, and the last tagged release is v0.0.2 from 2023-09-20. Verify first that your required authentication method is one of the providers the config exposes, that the `address` range syntax such as `tcp/0.0.0.0:2000-2012` maps to the range of ports you expect, and that the `no_client_auth` path in your chosen config is intentional.

## FAQ

### What is Kadeessh?

It is an extensible, modular SSH server built as a Caddy app, formerly Caddy-SSH, written in Go with the aim of providing safe, modern and secure defaults. Because it is a Caddy app, the SSH listener is configured in the same JSON file and runs in the same process as the rest of your Caddy configuration.

### How do I install Kadeessh?

Download the executable from the GitHub Releases page and place it in your PATH, or build it with xcaddy using xcaddy build --with github.com/kadeessh/kadeessh[@<version>], where the version part is optional. Building from source requires Go 1.25.8.

### How does Kadeessh decide what a logged-in user may do?

Through an actors list, where each actor has a match block of criteria such as a user name or a remote address range, and an act block giving the action, for example shell. The match criteria are evaluated per session, so different sources of connection can be given different actors.

### What authentication methods does Kadeessh support?

The configuration exposes a username_password provider with static accounts holding bcrypt hashes, and a public_key provider with an os provider that reads local accounts, which can be combined with a deny_users list. A no_client_auth option can also be set on a per-config basis.

### Can Kadeessh limit how many sessions are active at once?

Yes. The max_session authorizer restricts the number of currently active sessions, set through a server-level authorize block with an authorizer of max_session and a max_sessions value. The public authorizer grants access without restriction and is the default if none is provided.

### Is Kadeessh ready to use in production?

The README states it is still under heavy work in progress and that the packages will be extracted outside of internal once the design is settled. The recorded releases are v0.0.2 from 2023-09-20 and v0.0.1 from 2022-03-28, with the last push on 2026-07-18, so the licence is permissive but the API is not settled.

## Sources

- [Issues](https://github.com/kadeessh/kadeessh/issues)
- [kadeessh/kadeessh on GitHub](https://github.com/kadeessh/kadeessh)
- [License: Apache-2.0](https://github.com/kadeessh/kadeessh/blob/master/LICENSE)
- [README](https://github.com/kadeessh/kadeessh/blob/master/README.md)
- [Releases](https://github.com/kadeessh/kadeessh/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/kadeessh-kadeessh
