Build1 publisher2 min readPublished
GitHub Actions deploys into a private EC2 instance through the SSM agent's outbound connection
The SSM agent holds a connection out to AWS, OpenSSH rides it as a ProxyCommand, and EC2 Instance Connect pushes a key that sshd forgets after 60 seconds, so the security group ingress list can stay empty.
The Engineer · Build desk

What happened
- Because nothing has to connect inward, the instance's security group ingress list can be completely empty and port 22 closed to every source address.
- EC2 Instance Connect's SendSSHPublicKey pushes a freshly generated public key into instance metadata, where sshd picks it up for 60 seconds and then forgets it.
- The published workflow gets short-lived AWS credentials through OIDC with id-token write permission, so no SSH private key sits in repository secrets.
- Because the setup is only an SSH config entry, rsync, scp, remote migrations, systemctl reload and ansible-playbook all run unmodified against the alias.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision Revoking deploy access turns into an IAM role change, so nobody has to find and delete authorized_keys entries host by host when someone leaves.
- cost The fully private variant adds three VPC interface endpoints to the bill for each VPC that needs them, and the post does not name or price them.
- exposure The way in is now the OIDC trust policy on the AWS role, so a trust condition that matches too many repositories or branches is the misconfiguration that hands out a shell.
- capability A shared long-lived key in authorized_keys cannot produce a per-person record. CloudTrail attributes each session start to the identity that opened it.
The design turns on direction. The agent on the instance opens an outbound HTTPS connection to AWS and holds it open; when a client wants in, it asks the SSM API for a session and AWS joins the two ends over the channel that is already up [1].
sshd has not moved. The config block the action writes sets `Port 22` and passes `portNumber=%p` to the `AWS-StartSSHSession` document [9], and that document hands back a raw byte tunnel that OpenSSH accepts as a `ProxyCommand` [6]. Encryption and host key exchange still happen between the runner and the daemon [6].
The runner generates an ephemeral ed25519 pair and pushes the public half in [8], and sshd honours it for 60 seconds [7]. A deploy job does more than 60 seconds of work, so the same config sets `ControlMaster auto`, a `ControlPath` socket under `~/.ssh`, and `ControlPersist 8h` [9]. The first connection opens a master that can stay up for 28,800 seconds, 480 times the window in which the key was valid [16]. Every later step in the job reuses that socket.
The same block sets `StrictHostKeyChecking accept-new` [9]. A hosted runner starts with no `known_hosts` entry for the instance, so accept-new records whatever host key the far end presents on the first connection and trusts it [17]. The post describes the tunnelled session as end-to-end encrypted, "host keys and all" [6]. That holds for the transport, but accept-new is not an identity check on the first connection, and on a throwaway runner every connection is the first one.
This replaces ordinary practice. A private key pasted into `secrets.SSH_PRIVATE_KEY` with its public half in `authorized_keys` never rotates, and anyone who can read repo secrets has shell on production [12]. The alternative to opening 22/tcp to 0.0.0.0/0 is a scheduled job that pulls GitHub's meta API and rewrites security group ingress on every instance you deploy to, dozens of CIDRs, churning weekly [13]. On the bastion option, ankurk91 wrote: "Congratulations, the problem has a second copy of itself." [14]
This transfers if the target is an EC2 instance with an SSM agent that is actually holding its connection open, your deploy tooling speaks SSH, and your org will let a GitHub workflow assume an AWS role by OIDC. The action is ankurk91's own, published as `ankurk91/setup-ssh-over-ssm-action` [15]. By his account it does the setup and the cleanup, and leaves `ssh` itself alone [18].
What to watch
- Whether the action re-pushes a key when the multiplexed master socket drops after the 60-second window has closed mid-job.
- Whether teams running fully private VPCs publish which three interface endpoints are required and what they add to the monthly bill.
- Whether the action gains an option to pin a known host key instead of leaving StrictHostKeyChecking at accept-new.