Public VPS Deployment¶
This guide deploys shed-server on an internet-facing VPS, locked down so that only your GitHub keys can SSH in, all HTTP is encrypted, and the credential bus is reachable only with the required scope over TLS.
The default shed posture is local-only (open, bound to loopback). This guide is
the opposite end: a hardened, internet-facing server. Two postures both derive
the same hardening bundle (SSH allowlist enforced + HTTP credential enforced +
TLS-only, with no plain-HTTP listener) and refuse to start if any piece is
missing; one bind_address faces the network:
auth.mode: mtls— the recommended posture. The HTTP credential is a client certificate bound to a private key that never leaves the client's device, issued over SSH. An unauthenticated peer can never complete the TLS handshake, let alone reach the router.auth.mode: token— a bearer token minted over SSH. Use this instead when something outside the shed client fleet needs to call the API directly with a bearer header —curl, a CI runner, a third-party integration. mtls servers cannot be reached that way at all, sotokenmode is retained and not deprecated.
Either way, there are no credentials to mint or paste by hand: clients get them automatically over SSH.
1. Server config¶
Write /etc/shed/server.yaml. Both modes require an SSH key source, and —
since v0.7.4 every posture defaults to loopback — a bind_address so the VPS
is reachable from off-box:
auth:
mode: mtls # derives the full bundle; refuses to start without a key source
ssh:
github_users: [charliek] # only these GitHub keys may SSH in (and enroll certificates)
tls_names:
- shed.example.com # your VPS hostname / public IP (extra cert SANs on the server's own leaf)
bind_address: 0.0.0.0 # face the network (loopback is the default) — or a specific public IP
mtls mode forces auth.ssh.mode: enforce, turns on pinned TLS with
RequireAndVerifyClientCert (https_port defaults to 8443), and serves
no plain-HTTP listener (TLS-only) — so the only API is HTTPS on 8443,
gated by a client certificate of the required scope rather than a bearer
token. (shed server add against an mtls server therefore needs
--ssh-port, as below — it cannot probe /api/info over plain HTTP first,
because the HTTPS listener itself demands a certificate before answering
anything.)
bind_address is required for a remote server
Since v0.7.4 bind_address defaults to loopback (127.0.0.1) in every
mode, so without the bind_address: 0.0.0.0 line above the VPS binds
loopback only and is unreachable from your laptop. Neither token nor
mtls mode needs an allow_insecure_exposure ack — TLS plus a required
credential make the network bind safe.
Start (or restart) the server. If a required piece is missing it exits immediately, naming the gap:
auth.mode: mtls requires at least one SSH key source (auth.ssh.github_users, authorized_keys, or authorized_keys_file)
Token mode alternative¶
If you need curl/CI/third-party callers to reach the API directly, use
auth.mode: token instead — everything else in this guide (the bind_address
requirement, the SSH-first add flow, the rotation story) is the same shape,
just with a bearer token instead of a certificate:
See Security › Token mode for the token-specific details (scopes, TTL, revocation by allowlist removal).
2. Add the server from your client¶
From your laptop, one command enrolls a certificate over SSH — shed server
add is SSH-first for every enforced mode, so it needs the SSH port (default
2222; override with --ssh-port if you changed ssh_port in
server.yaml):
This performs a bounded SSH key-scan to capture and confirm the host key,
then connects over the reserved _bootstrap SSH channel (using one of your
allowlisted keys). The client generates a P-256 keypair locally, sends a CSR,
and the server's internal CA signs it — the private key never leaves your
machine. The pinned entry — api_url, tls_cert_fingerprint, auth_mode:
mtls, and the certificate/key paths under ~/.shed/creds/<name>/ (mode
0700/0600) — is written to ~/.shed/config.yaml:
servers:
shed.example.com:
api_url: https://shed.example.com:8443
tls_cert_fingerprint: sha256:<pinned at add time>
auth_mode: mtls
client_cert_file: /Users/you/.shed/creds/shed.example.com/client.pem
client_key_file: /Users/you/.shed/creds/shed.example.com/client.key
client_cert_expires_at: 2026-06-15T00:00:00Z # refreshed automatically
Your SSH key must be on the server's allowlist for enrollment to succeed (it
is — you listed it in github_users). This is a behavior change from
plain-TOFU adds: shed server add against a token/mtls server now
requires an allowlisted SSH key at add time, not just lazily on first API
call. Verify the control plane over the pinned mTLS connection:
To verify the fingerprints out-of-band (e.g. from a CI runner with a key in the allowlist), pass them explicitly:
shed server add shed.example.com --ssh-port 2222 \
--tls-fingerprint sha256:<hex> --fingerprint SHA256:<ssh>
Adding against token mode looks identical except the entry gets
control_token/control_token_expires_at instead of a certificate — see
Security › HTTP tokens are minted over
SSH.
3. Credential brokering over TLS¶
Point the host-agent (running on your laptop) at the server. It enrolls its
own credentials-scope certificate over the same SSH bootstrap channel —
there is no credential to paste — and subscribes to the credential bus over
the pinned TLS connection, brokering SSH signatures / cloud credentials to
your remote shed. It picks up api_url and tls_cert_fingerprint from the
same ~/.shed/config.yaml entry, and persists its own certificate/key in its
state dir (never the same key material the CLI holds — one certificate per
process, per scope).
The bus stream is long-lived and often idle; shed-server sends a periodic SSE keepalive comment so an idle NAT or proxy does not evict the connection. If the agent reconnects across a blip, any un-acked credential request is re-delivered.
4. Rotation and expiry¶
Client certificates are short-lived (auth.token_ttl, default 24h) and
renew themselves: near expiry, or on an auth-shaped failure, the client
generates a fresh keypair + CSR and re-enrolls over SSH — the same
reqwest::Client/http.Transport instance keeps running throughout (no
client rebuild). You never rotate a certificate by hand. To revoke
access, remove the key from the allowlist (drop it from github_users, or
from the GitHub account); the next request on any connection presenting that
certificate is rejected. This is coarser than token mode's per-token revoke —
removing the SSH key also cuts shell/SFTP access, since the allowlist is now
the one lever for both. Accepted tradeoff; see Security › mTLS
mode for the full revocation model.
CA rotation (distinct from per-client certificate renewal above) is
manual: deleting ca_cert.pem + ca_key.pem on the server and restarting
invalidates every previously-issued client certificate at once — a
fleet-wide re-enrollment. Every well-behaved client recovers on its own (one
silent SSH round-trip on its next command), so the cost is "every client
pays one extra SSH round-trip," not a coordinated outage. There is no shed
server ca rotate CLI yet. The server logs the CA fingerprint and expiry at
startup and warns when it's within 90 days of expiring; /api/info also
reports ca_fingerprint / ca_not_after in mtls mode.
Rotate the server's own TLS cert (e.g. after changing tls_names) and
re-pin clients — this is independent of the client-certificate CA above:
Hardening the add-time trust¶
shed server add closes the add-time MITM window by prompting you to confirm
the SSH host key (and, for mtls, the TLS cert fingerprint) before enrolling.
Tighten it further by verifying out-of-band (--fingerprint /
--tls-fingerprint, read from the server's startup log), or bring your own
TLS certificate instead of the self-signed one with tls_cert_file /
tls_key_file in the server config (this affects only the server's own
leaf, not the internal client-certificate CA).
Hardening the SSH surface¶
SSH stays the internet-exposed root of trust in both modes — it's the shell access channel and the channel that issues every HTTP credential, so it's worth the same operational care as any other internet-facing SSH daemon:
auth.ssh.max_auth_tries(default10) bounds public-key attempts per connection; raise it only if a legitimate client's agent (1Password, Secretive) holds enough keys that the allowlisted one gets tried late.- fail2ban (or an equivalent rate-limiter) watching shed-server's SSH
auth-failure log lines is standard, optional hardening for a
publicly-reachable
ssh_port— shed's own allowlist rejects unauthorized keys, but a connection-rate limiter reduces log noise and the (already bounded) cost of processing scanner traffic. This is operator-managed; shed does not ship a rate-limiter itself. - Keeping
ssh_portoff the well-known22is a minor, optional reduction in automated-scanner noise, not a security boundary on its own.
Co-located host-agent¶
If you instead run the host-agent on the VPS itself, no extra config is
needed: the credential bus (credentials scope) and Connect tunnel (control
or credentials) ride the single pinned-TLS listener, and the on-box
host-agent reaches them over https://127.0.0.1:8443 with the pinned cert (in
mtls mode, its own credentials-scope client certificate). Remote shed
forward keeps working at the same time, because there is no longer a
separate loopback-only listener. See the network
surface.