DEV Community

Cover image for Stop Whitelisting Port 22: SSH into Private EC2 from GitHub Actions via AWS SSM ๐Ÿ”
Ankur K
Ankur K

Posted on

Stop Whitelisting Port 22: SSH into Private EC2 from GitHub Actions via AWS SSM ๐Ÿ”

๐Ÿ˜ฉ The part of CI/CD nobody enjoys

You want a GitHub Actions job to rsync a build onto an EC2 box and restart a service. Simple, right?

Then reality shows up:

  • ๐Ÿ”‘ The key. You generate an SSH key pair, paste the private half into secrets.SSH_PRIVATE_KEY, and append the public half to ~/.ssh/authorized_keys on the instance. That key now lives forever. It never rotates. Anyone who can read repo secrets โ€” or any action you uses: that decides to be clever โ€” has shell on production.
  • ๐ŸŒ Port 22. GitHub-hosted runners come from a huge, changing pool of egress IPs. So you either open 22/tcp to 0.0.0.0/0 (๐Ÿ™ˆ), or you write a scheduled job that pulls GitHub's meta API and rewrites your security group ingress rules โ€” dozens of CIDRs, churning weekly, on every instance you deploy to.
  • ๐Ÿฐ The bastion. The "proper" fix. Now you have an extra instance to patch, monitor, pay for, and whose own key you also have to manage. Congratulations, the problem has a second copy of itself.
  • ๐ŸงŸ The leftovers. A public IP on a box that has no business having one. A key on an ex-employee's laptop. A known_hosts prompt that hangs a job at 2am.

Every one of these is accepted as "just how deploys work." It isn't, anymore.

โšก Enter AWS Systems Manager Session Manager

Session Manager flips the direction of the connection. ๐Ÿ”„

The SSM Agent on your instance makes an outbound HTTPS connection to AWS and holds it open. When you want in, you ask the SSM API for a session, and AWS brokers the two ends together over that existing channel.

Read that again, because everything good follows from it:

  • ๐Ÿšซ Zero inbound rules. Security group ingress can be completely empty. Port 22 closed. To everyone. Forever.
  • ๐Ÿ•ณ๏ธ No public IP, no bastion. Private subnet instances work identically. Behind a NAT gateway, or with no internet at all if you add the three VPC interface endpoints.
  • ๐Ÿชช IAM is the auth layer. Access is an IAM policy, not a file on a disk. Revoke a role and access dies instantly โ€” no hunting for authorized_keys entries.
  • ๐Ÿ“œ CloudTrail sees every session start, attributed to the identity that opened it.

And the underrated trick: the AWS-StartSSHSession document turns that broker into a raw byte tunnel, which OpenSSH will happily use as a ProxyCommand. Meaning SSH itself still runs โ€” end-to-end encrypted, host keys and all โ€” it just stops caring about routing.

Pair it with EC2 Instance Connect (SendSSHPublicKey) and the last piece falls over too: you push a freshly generated public key into instance metadata, where sshd picks it up for 60 seconds and then forgets it. ๐Ÿ”ฅ An SSH key with a one-minute shelf life. Nothing to rotate, nothing to leak.

That's the whole idea. The annoying part is the boilerplate: generate a key, push it, write a ProxyCommand block into ~/.ssh/config, get the host-key checking right, and tear it all down afterwards.

So I packaged it. ๐Ÿ“ฆ

๐Ÿš€ The action

ankurk91/setup-ssh-over-ssm-action does the setup and the cleanup. It doesn't wrap ssh โ€” it configures the runner, then gets out of your way.

name: Deploy

on:
  push:
    branches: [ main ]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_IAM_ROLE_ARN }}
          aws-region: us-east-1

      - uses: ankurk91/setup-ssh-over-ssm-action@v1
        with:
          instance-id: ${{ vars.EC2_INSTANCE_ID }}
          os-user: ubuntu

      - run: ssh ssm-target 'uptime'
Enter fullscreen mode Exit fullscreen mode

No secrets. No key. No security group rule. ๐ŸŽ‰ OIDC gets short-lived AWS credentials, the action does the rest.

๐Ÿงฉ What it actually wrote

That middle step generates an ephemeral ed25519 key, pushes it via EC2 Instance Connect, and drops a fenced block into ~/.ssh/config:

Host ssm-target
  HostName i-0123456789abcdef0
  User ubuntu
  Port 22
  IdentityFile /home/runner/.ssh/ssm-target
  IdentitiesOnly yes
  StrictHostKeyChecking accept-new
  ControlMaster auto
  ControlPath ~/.ssh/ssm-<digest>.sock
  ControlPersist 8h
  ProxyCommand sh -c "aws ssm start-session --target %h \
    --document-name AWS-StartSSHSession --parameters 'portNumber=%p' --region us-east-1"
Enter fullscreen mode Exit fullscreen mode

Because it's just SSH config, everything that speaks SSH just works โ€” unmodified: ๐Ÿ› ๏ธ

- run: rsync -az --delete ./build/ ssm-target:/var/www/app/current/
- run: scp ./config/production.env ssm-target:/srv/app/.env
- run: ssh ssm-target 'cd /srv/app && ./bin/migrate --no-interaction'
- run: ssh ssm-target 'sudo systemctl reload nginx'
- run: ansible-playbook -i inventory.yml site.yml   # ansible_host: ssm-target
Enter fullscreen mode Exit fullscreen mode

Name the alias whatever reads well in your pipeline:

- uses: ankurk91/setup-ssh-over-ssm-action@v1
  with:
    instance-id: i-0123456789abcdef0
    host-alias: app-server
    os-user: ec2-user      # Amazon Linux
Enter fullscreen mode Exit fullscreen mode

Bring your own key instead of the ephemeral one โ€” required for hybrid mi- managed nodes, which EC2 Instance Connect doesn't support:

- uses: ankurk91/setup-ssh-over-ssm-action@v1
  with:
    instance-id: mi-0123456789abcdef0
    private-key: ${{ secrets.SSH_PRIVATE_KEY }}
Enter fullscreen mode Exit fullscreen mode

๐Ÿงน And it cleans up after itself

A post step running on always() removes the config block, deletes the key material, closes the multiplexed master connection, and terminates the SSM sessions this job opened. Nothing survives the job. ๐Ÿซง

โœ… What you need

On the runner โ€” AWS CLI v2 and the Session Manager plugin. Both are preinstalled on GitHub-hosted Ubuntu runners, so: nothing. On self-hosted, add install-aws-cli-action and install-session-manager-plugin-action.

On the instance โ€” Linux, sshd running (port can stay firewalled shut), SSM Agent โ‰ฅ 2.3.672.0, AmazonSSMManagedInstanceCore on the instance profile, and the ec2-instance-connect package (preinstalled on AL2023 standard and Ubuntu 20.04+).

On the runner role โ€” ssm:StartSession, ssmmessages:OpenDataChannel, ssm:DescribeInstanceInformation, ssm:DescribeSessions, ssm:TerminateSession and ec2-instance-connect:SendSSHPublicKey. The full policy, plus the three IAM mistakes that cause most failures, are in docs/IAM.md. ๐Ÿ‘€ The big one: scoping ssm:TerminateSession with ${aws:username} silently does not work under OIDC federation.

โš ๏ธ Caveats

Sharp edges, up front:

  • ๐Ÿ”‡ No command-level audit. The tunnel is opaque to AWS, so session logging captures nothing readable and CloudTrail notes only that a session opened. If you need a record of what ran, reach for SSM Run Command instead.
  • ๐ŸŒ It's a relay, not a pipe. Tens of MB move fine; a multi-gig artifact is miserable. Ship those through S3.
  • ๐Ÿ”“ You still own sshd. The door moved, it didn't disappear โ€” keep patching it.
  • โฑ๏ธ Keys expire after a minute. Only the first connection has to beat the clock (multiplexing carries the rest), so put the action right before the steps that use it. Long gaps in the pipeline? Pass your own private-key.
  • ๐ŸŽฏ Cleanup plays it safe. The post step only ends sessions it can confidently pin on this job, so a busy self-hosted runner may leave a stray one rather than cut off a neighbour. terminate-sessions: false opts out.
  • ๐Ÿง Linux only, runner and instance both.

๐ŸŽฌ Wrapping up

Long-lived SSH keys in CI secrets and IP-whitelisted port 22 are habits from before Session Manager existed. Swapping them out costs you one step in a workflow file and an IAM policy โ€” and your deploy commands don't change at all.

๐Ÿ”— Links


โญ If this saved you from writing another security-group-updating cron job, star the repo โ€” it's the signal that tells me which parts to keep building, and it helps the next person find it instead of pasting a private key into a secret.

Issues and PRs welcome. ๐Ÿ™Œ Closed port 22 already? Tell me how it went in the comments. ๐Ÿ’ฌ

Top comments (0)