Deployment

Everything here exists as a real file in deploy/ — a systemd unit, a Dockerfile, a compose file, and a healthcheck. This page explains the choices in them.

Read it as three questions in order: how does the process get supervised, how does it prove it is alive, and what does the security checklist say before you open the port.

Running it

A production entry point is one line, and the interesting part is which one:

from wijjit_ssh import WijjitSSH, AuthorizedKeys, load_host_keys

WijjitSSH(
    make_app,
    host_keys=load_host_keys(["/var/lib/wijjit-ssh/host_key"]),
    auth=AuthorizedKeys("/etc/wijjit-ssh/authorized_keys"),
    max_sessions=100,
    idle_timeout=600.0,
    banner="Authorized users only.\n",
).run(host="0.0.0.0", port=8022)

run() owns the process. It is the only entry point that installs signal handlers and configures logging, which is exactly what you want under a supervisor and exactly what you do not want when embedding — see Graceful shutdown.

Note load_host_keys() rather than ensure_host_key(). In production the key is managed out of band, and a missing one should be a loud startup failure rather than a silently generated new identity. Both are covered in Host keys.

systemd

wijjit-ssh.service is ready to install:

sudo useradd --system --home-dir /opt/wijjit-app --shell /usr/sbin/nologin wijjit
sudo install -m 0644 deploy/wijjit-ssh.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now wijjit-ssh
systemd-analyze security wijjit-ssh.service     # check the sandbox

Three parts of it matter more than the rest.

StateDirectory. StateDirectory=wijjit-ssh gives the service /var/lib/wijjit-ssh, owned by its user, mode 0700, persistent across restarts and package upgrades. That is where the host key goes. Generate it before the first start:

sudo -u wijjit ssh-keygen -t ed25519 -f /var/lib/wijjit-ssh/host_key -N ''

TimeoutStopSec must exceed shutdown_grace.

Warning

If systemd’s stop timeout is shorter than shutdown_grace (default 5.0), systemd wins: it sends SIGKILL in the middle of the drain, no session runs its teardown, and every connected user is left inside the alternate screen buffer with a terminal that needs reset. The unit sets TimeoutStopSec=30 against the default grace of 5 seconds. Raise both together, never one alone.

The sandbox. This process terminates untrusted connections, so the unit drops every capability, mounts the filesystem read-only except its state directory, restricts syscalls to @system-service, and permits only AF_INET and AF_INET6. It needs a socket and a directory; nothing else is a regression to take away.

Note

Binding port 22 needs CAP_NET_BIND_SERVICE — the unit has the two lines commented out. Prefer not to. Port 22 is also where the system sshd you use for administration lives, and a high port with a firewall redirect avoids the collision entirely.

Docker

docker compose -f deploy/compose.yaml up --build
ssh -p 8022 you@localhost

The image runs as a non-root user, is read-only apart from its state volume, and drops all capabilities.

Warning

The image as built is unauthenticated. It serves examples/hello_ssh.py, which uses public-key auth when it finds a ~/.ssh/authorized_keys and falls back to allow_anonymous=True when it does not — and there is no authorized_keys in the image, so the fallback is the only path it takes. Any client reaching the port gets a session as whatever username it typed. That is why compose.yaml publishes to 127.0.0.1 and why the container’s first log line is hello_ssh.py’s NO AUTHENTICATION warning.

Outside a container that example binds loopback on the fallback path. The image sets WIJJIT_SSH_HOST=0.0.0.0 to undo it, because Docker forwards a published port to the container’s own address — so the loopback protecting you here is the host-side mapping, not the app’s bind.

What transfers to production is the structure around the app — the non-root user, the read-only filesystem, the dropped capabilities, the host key volume, the stop timeout, the healthcheck. Repoint the COPY and CMD at an app that passes a real auth policy before you widen the port binding.

Danger

Mount a volume at /var/lib/wijjit-ssh. Without one the host key is regenerated on every docker run, and every returning user is greeted with REMOTE HOST IDENTIFICATION HAS CHANGED — the warning you least want people trained to click through. compose.yaml makes the named volume structural so it cannot be forgotten.

The tell is in your logs: ensure_host_key logs at WARNING every time it generates a key. On a correctly mounted volume you see that line exactly once, ever.

stop_grace_period: 30s is the compose equivalent of TimeoutStopSec, and carries the same warning. Docker’s default is 10 seconds; on the command line that is docker stop --timeout 30.

The CMD is in exec form so the server is PID 1 and receives SIGTERM directly. A shell-form CMD would put /bin/sh at PID 1, which does not forward signals, and every deploy would kill sessions instead of draining them.

Health checks

python deploy/healthcheck.py --port 8022 --verbose
# 127.0.0.1:8022 up (authentication refused, as expected)

healthcheck.py exits 0 if the server is up and 1 otherwise.

Do not use a TCP connect for this. A process that accepted the socket and then wedged still answers a TCP handshake, because the kernel completes it without the application ever being scheduled. The probe reports healthy while nobody can log in — the classic false-healthy.

Instead the probe completes the SSH version exchange and key exchange, which requires a running event loop, a loadable host key, and a working transport, and then offers no credentials at all. Being refused is the success condition:

Outcome

Exit

Meaning

Authentication refused

0

Healthy. Everything up to and including the auth policy works.

Refused by a limit

0

Alive and at capacity — a real condition, not a failure.

Anonymous login accepted

0

Healthy, but prints a warning: this is allow_anonymous=True in production.

Connection refused / timeout

1

Nothing listening, or the loop is not running.

It never authenticates, so it never starts a session and never counts against max_sessions. It is an ordinary connection, so it counts against max_per_ip and connect_rate: probing loopback every 30 seconds sits comfortably inside the defaults, but a one-second interval against connect_rate=0.5 would eventually rate-limit the probe and report a healthy server as dead.

Scaling out

Sessions are independent — one app instance per connection, no shared state unless your application introduces it — so horizontal scaling is a TCP load balancer in front of N identical instances. Two consequences:

  • Every instance must serve the same host key, or a client reconnecting through the balancer lands on a different identity and refuses to talk. Mount the same key everywhere; this is the one piece of shared state the deployment requires.

  • SSH is a long-lived connection, not a request. Balance on connections, not requests, and give the pool a generous idle timeout — anything shorter than idle_timeout (default 600s) cuts sessions the server considers healthy.

Anything the app shares between sessions — a chat room, a live dashboard feed — is per-process. Two instances mean two rooms. Splitting that across instances needs a real backend, which is the app’s problem rather than this package’s.

The vertical bound is memory: a session is a live Wijjit app, so max_sessions is a memory limit wearing a different name. Its default of 100 is a plausible but unmeasured guess, and the honest answer depends on your app — measure one session and divide. See Resource limits.

Security checklist

Before the port is open:

Check

Why

A real auth policy

Never allow_anonymous=True or OpenAuth in production. Construction is fail-closed so this cannot happen by omission — only by typing it.

Host key managed out of band

load_host_keys(), from persistent storage, mode 0600. A key that regenerates trains users to ignore the warning that protects them.

Limits left on

max_sessions, max_per_ip, login_timeout, idle_timeout are on by default. The failure mode here is turning them off, not forgetting to turn them on.

Unprivileged user, high port

Never root. Reach port 22 with a redirect or a proxy, not with privileges.

Stop timeout > shutdown_grace

Otherwise every deploy wedges the terminal of everyone connected.

Password hashing, if you use passwords

check_password() is constant-time for comparison; anything stored at rest wants a real password hash. Prefer public keys.

Backpressure understood

A client that stops reading buffers frames without bound. Known, scheduled for M5, and the reason to set MemoryMax in the unit file.

Your app’s own authorisation

This package authenticates the connection and hands you session.username. What that user may then see is your factory’s decision.

What you do not have to check is the exec surface. There is no shell, no exec, no SFTP, and no port forwarding: those asyncssh handlers are never implemented, so a session has no code path to anything but your Wijjit app. See the security policy.

Logging in production

run() configures stderr logging unless you already set up a handler, which is the right default under systemd (the journal captures stderr) and under Docker (docker logs). Each line carries the session id, username, and peer address:

2026-07-16 11:04:22 INFO wijjit_ssh.session: [3f9a1c04 ada@10.0.0.7] Session started (term=xterm, 120x40)

For metrics, pass on_event=. It fires for connection.*, auth.*, and session.*, so a Prometheus exporter is a few counters in your own process and no dependency here. See Logging and metrics.

Two lines are worth alerting on directly: ensure_host_key at WARNING after the first boot means your persistent storage is not, and a sustained rate of connection.rejected means a limit is biting — either an attack, or a limit set too low for real traffic.