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 |
|
Healthy. Everything up to and including the auth policy works. |
Refused by a limit |
|
Alive and at capacity — a real condition, not a failure. |
Anonymous login accepted |
|
Healthy, but prints a warning: this is |
Connection refused / timeout |
|
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 |
Never |
Host key managed out of band |
|
Limits left on |
|
Unprivileged user, high port |
Never root. Reach port 22 with a redirect or a proxy, not with privileges. |
Stop timeout > |
Otherwise every deploy wedges the terminal of everyone connected. |
Password hashing, if you use passwords |
|
Backpressure understood |
A client that stops reading buffers frames without bound. Known, scheduled
for M5, and the reason to set |
Your app’s own authorisation |
This package authenticates the connection and hands you
|
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.