Skip to content
Source: docs/deployment.md
Commit: 2569c845dff126f9a4106edf17c1888532397463
Source updated:  ·  Edit this page

Deployment and graceful upgrades

Ansible deployment, configuration, fingerprint seeding, and zero-downtime process upgrades.

Installation

Download static Linux binaries from the GitHub releases page, build from source with Go 1.27.1 or newer, or use the container image published at ghcr.io/kilo666mj/sshgate.

The container runs as UID 65532. A safe first run keeps the host's sshd on port 22 and exposes SSHGate on port 2222:

mkdir -p sshgate-data
sudo chown 65532:65532 sshgate-data
docker run --rm --network host \
  -v "$PWD/sshgate-data:/var/lib/sshgate" \
  ghcr.io/kilo666mj/sshgate:latest \
  serve --allow-unknown --route '[::]:2222=127.0.0.1:22'

Host networking lets the container reach an sshd bound to the host's loopback interface. With ordinary container networking, 127.0.0.1 means the container itself.

Ansible Deployment

The repo includes an Ansible playbook that builds sshgate locally, installs it under /usr/local/bin, creates a dedicated sshgate system user, and manages a systemd service:

cd ansible
cp inventory.example inventory
cp group_vars/sshgate.yml.example group_vars/sshgate.yml
# Edit inventory and group_vars/sshgate.yml for your deployment.
ansible-galaxy collection install ansible.posix
ansible-playbook --syntax-check playbook.yml
ansible-playbook playbook.yml

The real inventory and group variables files are ignored so deployment-specific host names, fingerprints, and settings are not committed accidentally. Override variables in group_vars/sshgate.yml, the inventory, or with -e as needed:

sshgate_binary: /usr/local/bin/sshgate
sshgate_data_dir: /var/lib/sshgate
sshgate_config_dir: /etc/sshgate
sshgate_routes:
  - "[::]:2222=127.0.0.1:22"
sshgate_allow_unknown: false
sshgate_max_fingerprints: 100000
sshgate_metrics_listen: "127.0.0.1:9108"
sshgate_configure_local_prometheus: true
sshgate_prometheus_config: /etc/prometheus/prometheus.yml
sshgate_prometheus_target: "127.0.0.1:9108"
sshgate_control_plane: {}
sshgate_approved_fingerprints: []
sshgate_goarch: amd64

The playbook installs the binary at /usr/local/bin/sshgate, stores runtime state under /var/lib/sshgate, and installs config at /etc/sshgate/config.json. Only the data directory is writable by the sshgate user. The default route listens on port 2222, so the service does not need privileged bind capabilities. It forwards to 127.0.0.1:22; adjust the backend if your real sshd listens somewhere else. The playbook cross-compiles a Linux binary locally with CGO_ENABLED=0, deriving GOARCH from the target host's architecture unless sshgate_goarch is overridden.

The Ansible default is deny-first: unknown fingerprints are recorded as blocked and are not forwarded. Set sshgate_allow_unknown: true temporarily during enrollment if you want new clients to pass through before approval.

The Ansible service exposes Prometheus metrics at http://127.0.0.1:9108/metrics by default. Set sshgate_metrics_listen to an empty string to disable it, or to another address when a remote Prometheus server must scrape the service. Secure any non-loopback listener with firewall rules or a private monitoring network. Ansible writes the address into config.json so graceful upgrades can apply changes without replacing the process command line or dropping live SSH sessions.

When /etc/prometheus/prometheus.yml exists, the playbook also adds a managed sshgate scrape job, validates the complete configuration with promtool, and reloads Prometheus. Set sshgate_configure_local_prometheus: false when another configuration manager owns that file. sshgate_prometheus_config and sshgate_prometheus_target override the config path and scrape target.

You can also seed approved fingerprints during deployment:

sshgate_approved_fingerprints:
  - fingerprint: "0123456789abcdef0123456789abcdef"
    label: "Alice laptop"
  - fingerprint: "fedcba9876543210fedcba9876543210"
    label: "CI deploy key"

Seeding is additive. The playbook approves the listed fingerprints with --register, preserving existing database entries and leaving fingerprints not listed in inventory unchanged.

Graceful Upgrades

Because sshgate sits inline in the SSH data path, a plain process restart tears down every live SSH session it is proxying — unlike restarting sshd itself, whose existing sessions run in separate child processes and survive.

To get the same "restart without dropping connections" behavior, sshgate uses tableflip. On SIGHUP it re-execs the binary on disk (picking up a newly installed version), passes the listening sockets to the new process, and keeps the old process alive to finish serving its existing connections. New connections go to the new process immediately, so none are refused during the handoff.

# Trigger a zero-downtime upgrade of a running instance
kill -HUP <pid>
# or, under systemd:
systemctl reload sshgate

The old process stops accepting, stops its background database writers, and waits for its remaining sessions to close before exiting. --drain-timeout bounds that wait (default 1h, 0 waits indefinitely) so departing processes cannot pile up after repeated upgrades while a long-lived session stays open — analogous to nginx's worker_shutdown_timeout. During the drain window you may briefly see two sshgate processes, which is expected.

The systemd unit is Type=notify with NotifyAccess=all: after an upgrade the new server reports its PID to systemd via sd_notify, so systemctl keeps tracking the process that is actually serving. The Ansible deployment reloads (rather than restarts) on binary and config changes, so a deploy does not drop active sessions — including Ansible's own connection when it runs through sshgate.

Note that fingerprint approve/block changes never require a restart at all: each new connection reads its verdict from the database live. Only binary upgrades and --route/config changes need the process to reload.