This document describes the first production architecture for fedify.com.es.
The administrative account, host packages, and firewall were bootstrapped on
July 14, 2026. The authenticated private beta was deployed the same day and
passed its initial end-to-end and post-reboot checks. The operator approved
anonymous public access later that day so Fedify CLI can use the service. The
public cutover was deployed and passed its rollback, non-HTTP rejection,
selective shutdown, idempotency, and post-reboot end-to-end checks. It uses a
reviewed sish fork with protocol-level HTTP-only forwarding.
fedify.com.es provides public HTTPS URLs for HTTP services running on a
developer's local machine. A client opens an SSH reverse tunnel to the VPS. The
VPS assigns a subdomain, terminates TLS, and forwards each request through that
SSH connection.
The service is designed for the fedify tunnel command in @fedify/cli.
Fedify CLI uses @hongminhee/localtunnel to invoke the system SSH client with
remote forwarding, so the server must speak the same protocol without requiring
a separate client binary.
Fedify CLI will own the fedify.com.es service definition and pass it to
openTunnel() at runtime through its custom services registry. The service
will not be added to the built-in service registry in
@hongminhee/localtunnel.
flowchart TB
dns["Cloudflare DNS<br/>fedify.com.es and *.fedify.com.es"]
public_client["Public client"]
local_app["Local application<br/>SSH client with pinned sish host key"]
operator["Operator workstation"]
subgraph vps["Ubuntu 26.04 VPS"]
direction TB
sish_ssh["sish SSH endpoint<br/>:2222"]
sish_http["sish HTTP endpoint<br/>:80, redirects to HTTPS"]
sish_https["sish HTTPS endpoint<br/>:443, wildcard TLS"]
openssh["OpenSSH<br/>:22"]
sish_ssh -. "Registers route" .-> sish_https
end
public_client -->|"DNS lookup"| dns
dns -. "A records resolve to VPS" .-> sish_http
dns -. "A records resolve to VPS" .-> sish_https
public_client -->|"HTTP request"| sish_http
public_client -->|"HTTPS request"| sish_https
local_app ==>|"Verified SSH reverse tunnel"| sish_ssh
sish_https ==>|"Request and response through tunnel"| local_app
operator -->|"Ansible over administrative SSH"| openssh
The thick arrows represent the SSH reverse tunnel and the traffic carried inside it. sish owns ports 80, 443, and 2222. The operating system's OpenSSH daemon continues to own port 22.
sish is an SSH server built for HTTP, HTTPS, WebSocket, and TCP reverse
forwarding. This deployment uses only its HTTP and HTTPS path. It fits the SSH
command shape that @hongminhee/localtunnel uses on behalf of Fedify CLI:
ssh -p 2222 \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/path/to/fedify.com.es.known_hosts \
-o GlobalKnownHostsFile=/path/to/fedify.com.es.known_hosts \
-o KnownHostsCommand=none \
-o VerifyHostKeyDNS=no \
-o CheckHostIP=no \
-o UpdateHostKeys=no \
-R 80:localhost:8000 fedify.com.esThe public service does not authenticate tunnel clients. The client still
verifies the server against the pinned sish host key. sish assigns a random
16-character subdomain and ignores user-selected names. The deployed
fedify-dev/sish build adds an http-only setting that rejects TCP and alias
remote forwards before creating a listener. It also lets this deployment
disable HTTP access logs that would otherwise contain complete request URIs.
The same reviewed fork can serve a static directory on the exact root domain,
which provides the public landing page without exposing its files through
tunnel subdomains.
The command uses a dedicated known_hosts file containing the pinned sish host
public key for [fedify.com.es]:2222. @hongminhee/localtunnel 0.5.0 exposes
the equivalent trust configuration through Service.knownHosts, whose type is
Readonly<Record<string, readonly string[]>>. The Fedify-owned service
definition supplies the same host pattern and one or more verified public keys.
When knownHosts is present, localtunnel enables strict host-key checking,
isolates the connection from other SSH trust sources, and removes its private
temporary known_hosts file after startup failure or tunnel closure. Omitting
knownHosts retains legacy non-strict behavior, so it is required for this
service.
systemd supervises the sish process, applies its resource and filesystem
restrictions, and restarts it after failures. sish runs as a dedicated user. The
unit grants CAP_NET_BIND_SERVICE; it does not run as root merely to bind ports
below 1024.
Ansible declares host packages, users, directories, firewall rules, artifacts, configuration, certificates, and systemd state. It connects through the administrative SSH service and requires no resident deployment agent.
mise pins the tools used from the operator workstation and presents a small
command surface for checks, previews, applies, and verification. Ansible remains
the deployment engine.
Certbot obtains one certificate containing both names:
fedify.com.es*.fedify.com.es
A wildcard certificate requires a DNS challenge. Certbot uses a Cloudflare API
token restricted to Zone:Zone:Read and Zone:DNS:Edit for this zone. The
plugin lists the zone to find its ID before it creates the short-lived ACME TXT
record. The token is stored on the target with root-only permissions and
encrypted at rest in the repository if Ansible Vault manages it.
The apex and wildcard A records resolve directly to the VPS during the first deployment. They remain DNS-only while the service is brought up and tested.
Changing the wildcard record to Cloudflare-proxied is a separate design change.
If that happens, the sish SSH endpoint needs a specific DNS-only hostname, such
as ssh.fedify.com.es, because Cloudflare's HTTP proxy must not sit in front of
port 2222. Proxying also changes the client IP path, caching behavior, TLS
layers, and abuse controls.
- The client obtains the pinned sish host public key through the repository or another authenticated distribution channel.
- The client connects to sish on TCP port 2222, verifies that host key, and requests a remote HTTP forward to a local port.
- sish accepts the unauthenticated SSH connection but permits only an HTTP or HTTPS proxy remote forward.
- sish allocates a random subdomain under
fedify.com.esand prints the public URL to the SSH session. - A public client resolves the subdomain through the wildcard A record and connects to port 443 on the VPS.
- sish terminates TLS with the wildcard certificate and selects the matching
live tunnel from the HTTP
Hostheader. - The request and response travel through the existing SSH connection to the local application.
- When the SSH connection closes, sish removes the route.
HTTP requests on port 80 redirect to the equivalent HTTPS URL. The sish
configuration sets force-all-https: true; enabling the HTTPS listener alone
does not enable this redirect. The service does not forward cleartext public
traffic to a tunnel.
The intended target filesystem is:
flowchart TB
host["Ubuntu VPS filesystem"]
host --> opt["/opt/sish/"]
opt --> releases["releases/<version>/sish<br/>Versioned, checksum-verified binary"]
opt --> current["current<br/>Symlink to releases/<version>"]
host --> etc_sish["/etc/sish/"]
etc_sish --> config["config.yml<br/>Non-secret sish configuration"]
etc_sish --> ssl["ssl/"]
ssl --> tls_releases["releases/<deployment>/"]
tls_releases --> cert["fedify.com.es.crt<br/>Deployed full certificate chain"]
tls_releases --> key["fedify.com.es.key<br/>Deployed TLS private key"]
ssl --> tls_current["current<br/>Symlink to releases/<deployment>"]
host --> state["/var/lib/sish/"]
state --> host_keys["keys/<br/>Persistent sish SSH host keys"]
state --> authorized_keys["authorized_keys/<br/>Private-beta client public keys"]
host --> letsencrypt["/etc/letsencrypt/<br/>Certbot account and renewal state"]
host --> unit["/etc/systemd/system/sish.service"]
The sish https-certificate-directory setting points to
/etc/sish/ssl/current. sish 2.23.0 scans that directory for *.crt files and
pairs each one with a same-basename .key file, so the active release exposes
fedify.com.es.crt and fedify.com.es.key. The private key is readable by root
and the sish group, but not by other users. The configuration and certificate
are read-only to the sish process. Persistent runtime state lives under
/var/lib/sish.
Initial issuance should use the Let's Encrypt staging environment first, with a
certificate name that cannot be mistaken for production state. Once the DNS
automation succeeds, Certbot requests the production apex and wildcard
certificate under the canonical fedify.com.es certificate name.
On initial issuance and renewal, a deploy hook performs these actions:
- Create a uniquely named directory under
/etc/sish/ssl/releases. Copy Certbot'sfullchain.pemtofedify.com.es.crtandprivkey.pemtofedify.com.es.keywith the expected owner and modes. - Parse both files, confirm that the certificate covers the expected names and validity period, and verify that its public key matches the private key.
- Create a replacement
currentsymlink and atomically rename that one directory entry over/etc/sish/ssl/current. Both credential paths then switch to the validated release as one unit. - Restart sish through systemd and verify that it becomes active.
- If the restart fails during an update, atomically restore the previous symlink and restart sish with the previous credential release.
- Leave
currentand the running process untouched when staging or validation fails. Retain the previous release until the new release is active.
The standard Certbot systemd timer schedules renewals. Renewal configuration
and the deploy path are verified with
certbot renew --dry-run --run-deploy-hooks. Certbot uses the current active
certificate, not the temporary test certificate, when it invokes a deploy hook
during a dry run. This check therefore exercises the hook and service restart
without promoting the test certificate.
Ansible downloads a pinned sish release, verifies its checksum, and installs it
under /opt/sish/releases/<version>. Updating the current symlink and
restarting systemd activates a release. Reapplying the same playbook should
produce no change.
A deployment follows this path:
flowchart LR
revision["Git revision"] --> deploy_check["mise run deploy:check"]
deploy_check --> review["Operator review"]
review --> deploy["mise run deploy"]
deploy --> handlers["Ansible handlers"]
handlers --> verify["mise run verify"]
verify --> smoke_test["End-to-end tunnel smoke test"]
Changing the configured version back to a retained release provides the basic rollback path. Certificate and persistent SSH state are independent of the binary release.
The administrative OpenSSH service and the public sish SSH service are separate
listeners with separate host keys and purposes. Root SSH is used only for
bootstrap. Routine deployment uses the named fedify-deploy account after its
SSH and sudo paths have been tested.
Ansible manages packages, users, firewall policy, systemd units, binaries, and private certificate material, so that deployment principal is root-equivalent. Its passwordless sudo rule is limited to the dedicated account, but not to a misleading command-pattern allowlist: Ansible modules execute Python code as root, and such a list would not form a security boundary. Access is instead limited by the account's single operator-managed SSH key. A less privileged model would require a separately designed root-owned deployment interface.
The sish host key is also the client trust anchor. After the persistent key is
created on the VPS, the operator derives its public key through the
authenticated administrative path and compares its SHA-256 fingerprint with the
VPS console or another independent channel. The verified public key is
published as a [fedify.com.es]:2222 entry in this repository. ssh-keyscan
may collect the presented key for comparison, but its output is never trusted
without matching the pinned key.
Manual private-beta clients use that entry with StrictHostKeyChecking=yes, a
dedicated file as both UserKnownHostsFile and GlobalKnownHostsFile, and
disabled KnownHostsCommand, VerifyHostKeyDNS, CheckHostIP, and
UpdateHostKeys settings. Fedify CLI supplies the same trust anchor through
localtunnel 0.5.0's knownHosts map. In either path, the hostname-and-port
entry is the only trust source and a mismatch must stop the SSH connection
before a remote forward is established. Host-key rotation uses a staged client
update: publish the new pin alongside the old one, wait for client adoption,
switch the server key, and remove the old pin later. Clients that have not
received the new pin fail closed.
Anonymous users can host phishing pages, malware, prohibited content, or traffic that harms the domain's reputation. The public design therefore uses random names, HTTP-only forwarding, per-source SSH connection rate limiting, systemd resource limits, lifecycle logging, and a documented abuse response.
sish 2.23.0 does not provide one setting that rejects every non-HTTP SSH remote
forward. In particular, disabling TCP aliases does not disable an ordinary TCP
forward, and a client can request an automatically allocated port with -R 0.
The fedify-dev/sish fork classifies each remote-forward request and, when
http-only is enabled, rejects TCP and alias requests before it creates even a
Unix listener. The following containment remains as defense in depth:
- Set
force-tcp-address: true,tcp-address: 127.0.0.1, andbind-random-ports: falseso any TCP listener that sish does create is not publicly bound. - Apply
SocketBindAllow=tcp:2222,SocketBindAllow=tcp:80,SocketBindAllow=tcp:443, andSocketBindDeny=anyto the sish systemd unit as defense in depth. Verify that the target kernel and systemd enforce the policy; systemd's socket-bind filter is not the sole security boundary. - Keep UFW limited to public TCP ports 22, 2222, 80, and 443. Rate-limit new connections to port 2222 per source address.
Leave port-bind-range unset. With port-bind-range: "0", a denied raw TCP
bind made sish 2.23.0 retry port 0 recursively until the process exhausted its
stack. The initial negative test found this failure. Without that setting, the
same systemd denial produces a failed SSH remote-forward request and leaves the
running process intact.
Negative tests must confirm that explicit TCP forwards, aliases, and -R 0
receive an SSH-level rejection without creating a listener or restarting sish.
The systemd socket policy and UFW remain independent barriers if a future sish
change bypasses the protocol check.
No secret crosses into the public sish session. The Cloudflare token is used only by Certbot and is never placed in the sish environment.
The first architecture has one VPS and one sish process. It has no high availability or centralized coordination. DNS, certificate issuance, and existing SSH sessions are external dependencies.
The minimum operating signals are:
- systemd service state and restart count;
- journal errors and authentication failures;
- listener availability on ports 2222, 80, and 443;
- certificate expiration and renewal failures;
- disk, memory, and connection usage;
- an end-to-end tunnel probe from outside the VPS.
The initial beta baseline after two hours of uptime was 48 MB peak memory,
eight tasks, no active tunnel connections, and no service restarts. The public
unit sets MemoryHigh=512M, MemoryMax=768M, CPUQuota=150%,
LimitNOFILE=4096, and TasksMax=512. UFW rate-limits new SSH tunnel
connections from one source, and sish closes SSH sessions that do not create a
forward within ten seconds. These limits leave room for the operating system on
the 2 GiB VPS while bounding one process under abusive load.
sish writes SSH connection addresses, allocated subdomains, rejected forwards, and tunnel start and stop events to the systemd journal. HTTP access logging is disabled because its upstream format includes complete request URIs and query strings. Journald retains at most 256 MB and removes entries older than 14 days. Public abuse reports use the repository's issue form. The apex serves a small, static landing page with service limits, client documentation, source code, and the abuse-report link.
The landing page is deployed under /etc/sish/landing and served directly by
the reviewed sish fork when the request host is exactly fedify.com.es. sish
continues to own ports 80 and 443, so this does not add a reverse proxy, another
public listener, or a second long-running service. Port 80 redirects the apex
request to HTTPS before the static file is served. Random tunnel subdomains
continue through the existing proxy path and cannot read files from the landing
directory.
Backups must cover Ansible Vault data, Certbot account state, sish host keys, and the repository. The service can otherwise be rebuilt from a clean Ubuntu VPS by reapplying the playbooks.
- Multiple sish nodes or automatic failover.
- TCP and SNI tunnels.
- User-selected subdomains or custom domains.
- A web administration console.
- Cloudflare proxying of public tunnel traffic.
- Further changes to
@hongminhee/localtunnel, or addingfedify.com.esto its built-in service registry. Version 0.5.0 already provides the required host-key pinning API.