SSH and tailnet access
Getting a shell on a Zimmer droplet is one command:
ssh -p 2222 -i <your-operator-key> root@zimmer-stagingThe port and the key are both load-bearing, and getting either wrong produces a failure that looks like something else. This page explains the access path, then documents each trap in it. Most of them stay invisible until they bite: sshd reports a config it isn’t running, a port binds only IPv6, publickey auth succeeds and the session dies anyway.
The one thing to read even if you read nothing else is who is authorized where: a Zimmer agent session can SSH into staging, but it is deliberately denied root on production — the host it is itself running on.
SSH is tailnet-only
Section titled “SSH is tailnet-only”digitalocean_firewall.zimmer opens no public TCP port at all. The single inbound rule is
Tailscale’s 41641/udp:
inbound_rule { protocol = "udp" port_range = "41641" source_addresses = ["0.0.0.0/0", "::/0"]}There is no 22 rule, and adding one back is the thing not to do (why).
The tailnet is the only way in — for the app, for SSH, for break-glass.
On the tailnet interface, SSH is two different servers, and conflating them is where most of the confusion here comes from:
| Port | Server | Authenticates by | Used by |
|---|---|---|---|
:22 | Tailscale SSH (tailscale up --ssh) | tailnet identity, against the tailnet’s SSH policy. It does not consult the key your client offers | Kamal’s deploys, CI, and tailscale ssh root@zimmer-<env> for break-glass |
:2222 | real OpenSSH (bound by an ssh.socket drop-in) | publickey (admin_ssh_pubkeys plus the Kamal key) | operators, and ssh2/publickey clients like an SSH-based MCP server |
A DigitalOcean cloud firewall filters the public interface only; it does not filter tailscale0.
So tailnet peers reach both ports and the internet reaches neither, with no firewall rule for either.
Because :22 decides on identity rather than on your key, a key-bearing client can still get in there
— the deploy does exactly that, running ssh root@"${STAGING_HOST}" 'docker ps …' on the default port
— but the key is not what let it in; its tailnet identity was. When the identity does not satisfy
the policy, the connection closes during the handshake and the key you offered is irrelevant. That is
what :2222 is for: a port where the key is what decides.
Only the firewall enforces the tailnet-only property. :2222 binds 0.0.0.0, so it is the absence
of any TCP inbound rule that keeps the internet out, not the bind. Detach
digitalocean_firewall.zimmer from the droplet and :2222 is world-reachable immediately, with no
rule in Terraform to grep for. (It is key-only sshd, so the exposure is bounded — but the firewall is
the control.)
The firewall is Terraform-managed, so hand-edits do not stick
Section titled “The firewall is Terraform-managed, so hand-edits do not stick”terraform apply runs on every deploy. A rule you add through the DigitalOcean API or console is
drift, and the next deploy reverts it. Production’s public 22/tcp was re-opened by apply three
times after being closed by hand, because the rule was still in the code. The fix had to land in
main.tf.
The same is true in the other direction: you cannot hand-open a port to dig yourself out and expect it
to survive. Change the code, or use the two doors that always exist — tailscale ssh root@zimmer-<env>,
and the DigitalOcean web console.
Connecting
Section titled “Connecting”Your client must be on the tailnet. Both name forms below resolve to a 100.x CGNAT address,
which is unroutable from anywhere else — so an off-tailnet client does not fail fast with a clear
error, it hangs until it times out. If a connection hangs, check tailscale status before you
debug anything else. (Joining the tailnet, and the ACLs that govern it, are covered under
Provisioning.)
There are two ways to name a droplet, and which one works depends on your client:
- MagicDNS —
zimmerfor production,zimmer-<env>for everything else (local.tailnet_hostnameinmain.tf). Always available, needs no DNS setup, and it is whattailscale sshtakes. It only resolves on a client that uses Tailscale’s DNS. - A custom domain —
zimmer.tadasant.com,staging.zimmer.tadasant.comon this deployment. Setvar.domainand thedomain-certworkflow upserts a public A record pointing at the droplet’s tailnet IP. Anyone can resolve it; only a tailnet peer can reach it. This is the form to use from a client that has no MagicDNS — a CI runner, a container — which is exactly why it exists. (var.domainis""by default, so a fresh self-hosted deployment has MagicDNS only until it is set.)
# Operator shell (real OpenSSH, publickey):ssh -p 2222 -i <your-operator-key> root@zimmer-staging
# Break-glass (Tailscale SSH — tailnet identity, no key involved):tailscale ssh root@zimmerFor an SSH-based MCP server (Zimmer’s private catalog wires up ssh-agent-mcp-server, an ssh2
publickey client), the same thing in env-var form:
SSH_HOST=zimmer.tadasant.comSSH_PORT=2222SSH_USERNAME=rootNever a raw IP
Section titled “Never a raw IP”Neither IP works the way you would hope, and a name is the only thing that stays correct:
- The public IP is firewalled. No TCP inbound rule means a connection to it is dropped, not refused, so it hangs. This is the most misleading failure mode on the box.
- The tailnet IP moves. Rebuild the droplet and Tailscale assigns a new
100.xaddress — staging’s went from100.67.207.41to100.64.2.61on one rebuild. A tailnet IP pinned in an MCP config or an~/.ssh/configgoes stale silently.
The custom-domain A record is self-maintaining precisely because nothing pins that IP:
scripts/domain-cert.sh resolves the droplet’s current tailnet IP from tailscale status --json
and upserts the Cloudflare record before it touches the cert. The staging deploy chains that workflow
after every deploy (and it also runs weekly), so a rebuilt box is followed by DNS on its own:
[domain-cert] resolved zimmer-staging -> tailnet IP 100.64.2.61[domain-cert] updated A record staging.zimmer.tadasant.com -> 100.64.2.61Who is authorized where
Section titled “Who is authorized where”Every key that can reach a Zimmer box is root. There is no unprivileged SSH user, so the only
access control that exists is which host authorizes which key — and the lists are deliberately not
the same:
| Key | zimmer-production | zimmer-staging | obs | CI runner |
|---|---|---|---|---|
zimmer-production-operator — the key a Zimmer agent session holds | no — deliberately excluded | yes | yes | yes |
agent-orchestrator-prod-hetzner — the orchestrator, which runs on a separate host | yes | yes | — | — |
root@local — a human’s laptop, break-glass only | yes | yes | — | — |
the Kamal deploy key (var.deploy_ssh_pubkey) | yes | yes | — | — |
A — means “not part of this deployment’s access model”, not “denied”. There is exactly one no in
the table, and it is the decision the rest of this section is about: zimmer-production-operator off
production. root@local is yes on both boxes on purpose — a human’s break-glass key belongs wherever
a human might have to break in, and staging counts. Do not mistake that for reconciling the lists;
the control is the operator-key cell, not the human one.
Root on production is for humans and for an off-box orchestrator. Not for a session running on it.
Section titled “Root on production is for humans and for an off-box orchestrator. Not for a session running on it.”A Zimmer agent session runs on zimmer-production, inside the worker container. If it also held a
key that is root on zimmer-production, an agent could stop the containers, fill the disk, or rm
the catalog out from under the very service executing it — and take the orchestrator down mid-task,
with itself inside the blast radius. The failure would be self-inflicted and unrecoverable from
within: there is nothing left running to fix it.
So the line is drawn by where the SSH client runs, not by who it is:
- Off-box is allowed. The orchestrator that reaches into production from a different host
(
agent-orchestrator-prod-hetzner) can hold root there. If it breaks production, it is still alive to repair it. - A human is allowed.
root@localis break-glass, and a human is the recovery path of last resort. - On-box is denied.
zimmer-production-operator— the identity every session in the worker container presents — is left out of production’s authorized keys. It keeps staging, obs, and the CI runner, which is what those sessions actually need to operate the fleet.
The capability is removed at two layers
Section titled “The capability is removed at two layers”Key revocation alone is one authorized_keys line away from being undone by a converge run, so the
capability is taken away twice, independently:
- Authorization — production’s
/root/.ssh/authorized_keysdoes not contain the operator key, and neither does the list production converges from (admin_authorized_keys.pub, in the private companion repo). A converge cannot re-add what is not declared — and for production that file is the only place a key can be declared, because production does not useadmin_ssh_pubkeysat all (see below). There is one door, and the key is not on the list for it. - Availability — the AIR catalog production runs on does not even offer the SSH MCP server that points at production. A session cannot attach a server it cannot see, so it never gets as far as presenting a key.
Layer 2 is an exclude in the air.json of the catalog production runs on, which lives in the
private companion repo:
"exclude": { "mcp": ["@local/ssh-tadasant-zimmer-prod"]}Three things about that snippet are easy to get wrong:
excludeis a per-type object, keyed by artifact type. The flat-array form ("exclude": ["@local/x"]) is rejected by the schema outright — it is not a deprecated spelling, it does not resolve.- The scope is
@local, not the catalog’sname. A catalog whose indexes are local paths contributes its artifacts under@local, whatever the catalog calls itself. Reaching for the catalog’snameas the scope matches nothing, and AIR says so in a warning rather than an error. excludeis the only composition control AIR has — no override, no field-level patching. You drop the artifact or you keep it.
The entry is still in that catalog’s mcp.json — exclude drops it at resolve time rather than
deleting it — so the server remains available to a human running it by hand from a laptop, where the
client is off-box and none of the reasoning above applies. Excluding an artifact is an expected drop,
not a degraded resolve, so it does not trip AirCatalogService’s
dangling-reference check.
Zimmer’s own in-image catalog —
the mcp.json in this repository, which config/environments/production.rb falls back to when the
mounted catalog is not on the box yet — needs no such exclude: it declares no ssh-* MCP server at
all. So both catalogs a production box can boot on leave a session with no SSH server to attach,
aimed at production or anywhere else.
Each environment’s key list lives somewhere different
Section titled “Each environment’s key list lives somewhere different”This is the part that is easy to get wrong, because nothing in this repository authorizes anything on production:
| Environment | Declared in | Converged by |
|---|---|---|
| staging | the ADMIN_SSH_PUBKEYS Actions variable on this repo → TF_VAR_admin_ssh_pubkeys | cloud-init, at droplet creation |
| production | zimmer/admin_authorized_keys.pub (the private companion repo) | its authorize-admin-keys-prod workflow, live over Tailscale SSH — and re-asserted by the prod deploy, so it self-heals across a rebuild |
The two environments do not merely hold different lists — they use different mechanisms, and this is the detail that makes the exclusion hold:
- Production’s admin keys are not a Terraform variable at all. Its private
production.tfvarsleavesadmin_ssh_pubkeysunset (it defaults to[]) and says so explicitly, because keys managed as file content can be converged onto a live box, while keys managed through cloud-init reach a box only when it is created. So production has exactly one declaration site,admin_authorized_keys.pub— nothing else can put a key on that box, which is what turns “the key is not in the file” into a real guarantee rather than a race with the nextterraform apply. - Staging’s admin keys are the Terraform variable, because staging is rebuilt freely and cloud-init is the natural path.
The only Terraform variables file in this repo is staging.tfvars.example, and the only workflows
that consume it are deploy-staging and teardown-staging. Production’s Terraform state, tfvars, and
key list are all private. Nothing in this repository authorizes a key on production. So the
operator key on staging is authorized on staging and nothing else — which is intended: a session
reaching staging is the point, it is disposable, and a session that destroys it costs a rebuild.
Operator keys
Section titled “Operator keys”Staging’s list is the ADMIN_SSH_PUBKEYS Actions variable (Settings → Secrets and variables →
Actions → Variables), a JSON list of strings that deploy-staging.yml exports as
TF_VAR_admin_ssh_pubkeys:
[ "ssh-ed25519 AAAA...FEp3 zimmer-production-operator", "ssh-ed25519 AAAA...EmRa agent-orchestrator-prod-hetzner", "ssh-ed25519 AAAA...y3Zn root@local"]A variable, not a secret: public keys are not secret, and GitHub masks a secret’s value everywhere in a log — which would blank out the “who did this apply authorize” line the deploy prints. The deploy fails fast if the value is set but is not a JSON list of strings.
Unset is a working droplet, not a brick. var.admin_ssh_pubkeys defaults to [], and nothing
else depends on it: the Kamal deploy key is its own variable, so CI still deploys, and humans on the
tailnet still reach the box over Tailscale SSH on :22. What an empty list costs is the publickey
door on :2222 — no ssh-* MCP server can attach, and there is no OpenSSH break-glass login for when
the tailnet identity path is itself what is broken.
Do not reach for ssh_key_fingerprints (DigitalOcean-registered keys) instead. It is ForceNew on
digitalocean_droplet, so adding a key there makes the deploy’s auto-approved terraform apply
destroy and recreate the droplet — skipping the tailnet-node reap that only runs behind
recreate_droplet, which lands the replacement as zimmer-<env>-1 and breaks the hostname the deploy
resolves. Leaving it empty is also what triggers DigitalOcean’s forced root-password
expiry, which
cloud-init handles.
Adding a key does not touch a running droplet
Section titled “Adding a key does not touch a running droplet”admin_ssh_pubkeys is interpolated into user_data, and the droplet carries ignore_changes = [user_data]. cloud-init runs once, at creation. Editing the list produces no plan diff and reaches
no existing box; it decides who is authorized on the next rebuild. Two ways to converge it, and which
one you want depends on whether the box is disposable:
Staging — rebuild it. Run deploy-staging with recreate_droplet=true (a terraform -replace of
the droplet). Staging is disposable by design and this is the intended path. Read the one fallback
door a rebuilt droplet has
first: a rebuild whose tailscale up fails leaves you with only the DigitalOcean console. And budget
the rebuilds — each destroys the box’s TLS cert, and Let’s Encrypt allows only five issuances a
week.
Production — append the key live, over Tailscale SSH. Production cannot be casually recreated, so
a rebuild is not an option. Tailscale SSH on :22 is, because it works regardless of what :2222 is
doing:
tailscale ssh root@zimmer \ "grep -qxF '$(cat operator_key.pub)' /root/.ssh/authorized_keys || \ printf '%s\n' '$(cat operator_key.pub)' >> /root/.ssh/authorized_keys"That is the same idempotent append cloud-init does at boot. The author automates it as an
authorize-admin-keys-prod workflow in the private companion repo, which
is the natural home for it — production’s deploy pipeline lives there, not here. It converges from
zimmer/admin_authorized_keys.pub, and the prod deploy re-asserts the same file, so that file — not
a tfvars entry — is what a key must be in to survive: a hand-appended key that nobody wrote down is
dropped by the next rebuild, since production’s cloud-init list is empty by design.
That cuts exactly the other way for a key you want gone, which is why the operator key has to stay
out of admin_authorized_keys.pub and not merely out of the live authorized_keys: a converge reads
the declared list and re-adds everything on it. Revoking a key on the box while leaving it in the file
buys you one deploy of safety.
Removal has no such path. The cloud-init loop only ever appends, so taking a key out of
admin_ssh_pubkeys revokes nothing on a running droplet: that needs a rebuild, or an edit of
authorized_keys on the box. See Admin keys are add-only.
Why sshd is configured the way it is
Section titled “Why sshd is configured the way it is”Four traps, every one of them silent. All four are also commented in
infra/terraform/cloud-init.yaml.tftpl; this is the prose version.
Port 2222 in sshd_config does not add a port. It moves one.
Section titled “Port 2222 in sshd_config does not add a port. It moves one.”Ubuntu 24.04 socket-activates sshd, so the listen set belongs to ssh.socket, not sshd_config. A
Port 2222 line there is not inert: openssh-server ships
/usr/lib/systemd/system-generators/sshd-socket-generator, which on every daemon-reload turns a
Port/ListenAddress line into a generated ssh.socket drop-in that resets ListenStream= and
rewrites it. So Port 2222 moves sshd off :22 instead of adding :2222, and you are left with
neither the listener you wanted nor the one you had.
A drop-in appends, which is what we want:
[Socket]ListenStream=0.0.0.0:2222ListenStream=[::]:2222The drop-in filename must sort after the generated one
Section titled “The drop-in filename must sort after the generated one”Hence zz-. systemd applies drop-ins in filename order across all drop-in directories, and that
generated addresses.conf begins with a bare ListenStream= reset. A 10- prefix would be
silently wiped the moment anyone adds a Port or ListenAddress line — the listener would vanish with
no error, at some unrelated future daemon-reload.
Both address families, or IPv4 gets Connection refused
Section titled “Both address families, or IPv4 gets Connection refused”The shipped ssh.socket sets BindIPv6Only=ipv6-only. That is the unit, not the
net.ipv6.bindv6only sysctl (which is 0), so reading the sysctl tells you nothing. A bare
ListenStream=2222 therefore binds IPv6 only, and every IPv4 client gets Connection refused on a
port that ss -tlnp swears is listening. Both lines are required.
sshd_config.d is first-match-wins, so a “later” hardening file loses
Section titled “sshd_config.d is first-match-wins, so a “later” hardening file loses”sshd takes the first value it sees for a keyword. The Ubuntu cloud image ships
60-cloudimg-settings.conf with PasswordAuthentication no — and cloud-init writes
PasswordAuthentication yes into 50-cloud-init.conf, which sorts first. The 60 file’s no never
won. Root password auth was live on the public internet while the config on disk said otherwise.
So the hardening drop-in is named to sort before 50:
PasswordAuthentication noPermitRootLogin prohibit-passwordKbdInteractiveAuthentication noDo not audit this by reading the files. Reading them is how it was missed in the first place. Read the
config with sshd -T, which resolves the precedence for you:
sshd -T | grep -E '^(passwordauthentication|permitrootlogin|kbdinteractive)'Then confirm on the wire, because sshd -T has a lie of its own: it is a fresh parse of the files on
disk, not a readout of the running daemon. ssh.socket is Accept=no, so it hands its sockets to one
long-lived sshd -D that parsed its config once, at start. Write a hardening drop-in without
restarting ssh.service and sshd -T reports passwordauthentication no while the live daemon keeps
taking passwords — which is how the first, hand-applied fix on production sat inert. cloud-init
restarts both units for exactly this reason:
- systemctl daemon-reload- systemctl restart ssh.socket ssh.serviceThe only honest check is what the daemon advertises to a client:
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password -p 2222 root@zimmer-staging# key-only -> Permission denied (publickey).# still bad -> Permission denied (publickey,password).Both halves of this are also on the limitations page.
DigitalOcean force-expires root’s password, and that rejects every OpenSSH session
Section titled “DigitalOcean force-expires root’s password, and that rejects every OpenSSH session”Create a droplet with no DO-registered SSH key — which is what ssh_key_fingerprints = [] does,
deliberately — and DigitalOcean sets a random root password, emails it out, and marks it as needing an
immediate change (chage -d 0 root, i.e. lastchg=0 in /etc/shadow).
That flag is not cosmetic. pam_unix’s account stack refuses the session outright when
lastchg == 0, after publickey auth has already succeeded:
$ ssh -p 2222 -i ~/.ssh/operator_key root@zimmer-staging 'hostname'You are required to change your password immediately (administrator enforced).Password change required but no TTY available.So :2222 authenticates you and then throws the session away. Your key is right, sshd is right, the
firewall is right, and nothing works. Tailscale SSH on :22 never notices, because it authenticates by
tailnet identity and does not run pam_unix at all: Kamal deploys, CI health checks, and every
tailscale ssh break-glass keep succeeding on a box whose OpenSSH is entirely dead. Only a real
OpenSSH client can see this failure.
cloud-init drops the password in runcmd, ahead of the restart that puts sshd on :2222, so there is
no window in which the port answers and every session dies:
- usermod -p '*' root- chage -d $(date +%Y-%m-%d) -M -1 rootRoot is key-only here, so the password has no legitimate use; removing it also invalidates the one
DigitalOcean emailed. usermod -p '*' sets an invalid hash — passwd -d would leave an empty
one, which means “no password required” rather than “no password login”. -M -1 disables aging, so it
cannot re-expire.
Never re-open public tcp/22
Section titled “Never re-open public tcp/22”Not even temporarily, to test something. The old posture — 22/tcp open to 0.0.0.0/0 against an sshd
that (first-match on 50-cloud-init.conf) accepted root password auth — put both droplets under a
sustained brute-force flood, and that flood re-saturates a box within minutes of the port opening.
What it did is worse than the obvious:
- Production logged 1023 pre-auth failures per 2000 lines of
journalctl -u ssh. - Staging’s sshd
MaxStartupspre-auth queue was saturated, to the point thatExceeded MaxStartupsreset every connection before the handshake. To a client that surfaces asread ECONNRESETor “Connection lost before handshake”. SSH was effectively down, and it looked nothing like an auth problem — you can lose an hour debugging your key.
Break-glass without a rule: tailscale ssh root@zimmer-<env>, or the DigitalOcean web console.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Fix |
|---|---|---|
Exceeded MaxStartups in the server log; client sees read ECONNRESET or “Connection lost before handshake” | Public tcp/22 is open and sshd’s pre-auth queue is saturated by the brute-force flood. An availability failure, not an auth one | Close public 22 in main.tf — never by hand, apply reverts that — and connect on :2222 over the tailnet |
| Connection hangs, no error | You pointed at the public IP (firewalled: packets dropped, not refused), or your client is off the tailnet, so the 100.x address is unroutable | Use a name, and check tailscale status |
Connection closed by … port 22, and ssh -v shows remote software version Tailscale | You reached Tailscale SSH with a publickey client whose tailnet identity does not satisfy the SSH policy. Your key was never consulted | Add -p 2222. For an MCP server, set SSH_PORT=2222 — it defaults to 22 |
Connection refused on :2222 from an IPv4 client, while the port looks bound | The ssh.socket drop-in listed only ListenStream=2222, and the unit’s BindIPv6Only=ipv6-only made it IPv6-only | List both families: ListenStream=0.0.0.0:2222 and ListenStream=[::]:2222 |
Permission denied (publickey) on :2222 | Your key is not in /root/.ssh/authorized_keys. Adding it to admin_ssh_pubkeys does not reach a running box — cloud-init runs at creation only | Converge it: rebuild staging, or append the key live over Tailscale SSH on production |
You are required to change your password immediately / Password change required but no TTY available, after publickey auth succeeds | DigitalOcean force-expired root’s password (lastchg=0) and pam_unix refuses every session | scripts/clear-root-password-expiry.sh <host> — it goes in over Tailscale SSH, which the expiry does not affect |
sshd -T says passwordauthentication no, but the daemon still takes passwords | sshd -T is a fresh parse, not the running daemon. ssh.socket is Accept=no, so one long-lived sshd -D holds the config it parsed at start | systemctl restart ssh.socket ssh.service, then verify on the wire |
An ssh-* MCP server fails its healthcheck immediately | No private key in the container, or SSH_PORT left at its 22 default | Check the key exists and is 0600; set SSH_PORT=2222 |
| Nothing works at all, on any port | tailscale up failed at boot, so there is no :22, no :2222, and no public TCP | The DigitalOcean web console is the only remaining door |
Everything times out for a few minutes and then recovers on its own, while tailscale status says the node is Online | The host is thrashing on memory, which starves tailscaled of the data path. Online is a control-plane fact, not a reachability one | Look at the host, not at SSH: dmesg -T | grep -i "killed process", free -m, uptime |