Skip to content

SSH and tailnet access

Getting a shell on a Zimmer droplet is one command:

Terminal window
ssh -p 2222 -i <your-operator-key> root@zimmer-staging

The 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.

digitalocean_firewall.zimmer opens no public TCP port at all. The single inbound rule is Tailscale’s 41641/udp:

infra/terraform/main.tf
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:

PortServerAuthenticates byUsed by
:22Tailscale SSH (tailscale up --ssh)tailnet identity, against the tailnet’s SSH policy. It does not consult the key your client offersKamal’s deploys, CI, and tailscale ssh root@zimmer-<env> for break-glass
:2222real 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.

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:

  • MagicDNSzimmer for production, zimmer-<env> for everything else (local.tailnet_hostname in main.tf). Always available, needs no DNS setup, and it is what tailscale ssh takes. It only resolves on a client that uses Tailscale’s DNS.
  • A custom domainzimmer.tadasant.com, staging.zimmer.tadasant.com on this deployment. Set var.domain and the domain-cert workflow 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.domain is "" by default, so a fresh self-hosted deployment has MagicDNS only until it is set.)
Terminal window
# Operator shell (real OpenSSH, publickey):
ssh -p 2222 -i <your-operator-key> root@zimmer-staging
ssh -p 2222 -i <your-operator-key> [email protected] # same box, custom domain
# Break-glass (Tailscale SSH — tailnet identity, no key involved):
tailscale ssh root@zimmer

For 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:

Terminal window
SSH_HOST=zimmer.tadasant.com
SSH_PORT=2222
SSH_USERNAME=root

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.x address — staging’s went from 100.67.207.41 to 100.64.2.61 on one rebuild. A tailnet IP pinned in an MCP config or an ~/.ssh/config goes 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.61

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:

Keyzimmer-productionzimmer-stagingobsCI runner
zimmer-production-operator — the key a Zimmer agent session holdsno — deliberately excludedyesyesyes
agent-orchestrator-prod-hetzner — the orchestrator, which runs on a separate hostyesyes
root@local — a human’s laptop, break-glass onlyyesyes
the Kamal deploy key (var.deploy_ssh_pubkey)yesyes

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@local is 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.

Key revocation alone is one authorized_keys line away from being undone by a converge run, so the capability is taken away twice, independently:

  1. Authorization — production’s /root/.ssh/authorized_keys does 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 use admin_ssh_pubkeys at all (see below). There is one door, and the key is not on the list for it.
  2. 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:

  • exclude is 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’s name. A catalog whose indexes are local paths contributes its artifacts under @local, whatever the catalog calls itself. Reaching for the catalog’s name as the scope matches nothing, and AIR says so in a warning rather than an error.
  • exclude is 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.jsonexclude 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:

EnvironmentDeclared inConverged by
stagingthe ADMIN_SSH_PUBKEYS Actions variable on this repo → TF_VAR_admin_ssh_pubkeyscloud-init, at droplet creation
productionzimmer/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.tfvars leaves admin_ssh_pubkeys unset (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 next terraform 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.

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:

Terminal window
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.

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:

/etc/systemd/system/ssh.socket.d/zz-tailnet-altport.conf
[Socket]
ListenStream=0.0.0.0:2222
ListenStream=[::]:2222

The 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:

/etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
PermitRootLogin prohibit-password
KbdInteractiveAuthentication no

Do 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:

Terminal window
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.service

The only honest check is what the daemon advertises to a client:

Terminal window
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:

Terminal window
$ 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 root

Root 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.

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 MaxStartups pre-auth queue was saturated, to the point that Exceeded MaxStartups reset every connection before the handshake. To a client that surfaces as read ECONNRESET or “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.

SymptomCauseFix
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 oneClose public 22 in main.tf — never by hand, apply reverts that — and connect on :2222 over the tailnet
Connection hangs, no errorYou pointed at the public IP (firewalled: packets dropped, not refused), or your client is off the tailnet, so the 100.x address is unroutableUse a name, and check tailscale status
Connection closed by … port 22, and ssh -v shows remote software version TailscaleYou reached Tailscale SSH with a publickey client whose tailnet identity does not satisfy the SSH policy. Your key was never consultedAdd -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 boundThe ssh.socket drop-in listed only ListenStream=2222, and the unit’s BindIPv6Only=ipv6-only made it IPv6-onlyList both families: ListenStream=0.0.0.0:2222 and ListenStream=[::]:2222
Permission denied (publickey) on :2222Your 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 onlyConverge 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 succeedsDigitalOcean force-expired root’s password (lastchg=0) and pam_unix refuses every sessionscripts/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 passwordssshd -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 startsystemctl restart ssh.socket ssh.service, then verify on the wire
An ssh-* MCP server fails its healthcheck immediatelyNo private key in the container, or SSH_PORT left at its 22 defaultCheck the key exists and is 0600; set SSH_PORT=2222
Nothing works at all, on any porttailscale up failed at boot, so there is no :22, no :2222, and no public TCPThe 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 OnlineThe host is thrashing on memory, which starves tailscaled of the data path. Online is a control-plane fact, not a reachability oneLook at the host, not at SSH: dmesg -T | grep -i "killed process", free -m, uptime