DEV Community

Cover image for Deploy to EC2 from GitHub Actions without opening port 22
Ankur K
Ankur K

Posted on

Deploy to EC2 from GitHub Actions without opening port 22

If you deploy to EC2 from GitHub Actions, the usual recipe is to put a private key in your repo secrets and SSH into the box. It works, but it means you are keeping a long-lived key around and leaving port 22 open to the internet (or to GitHub's very large IP range). AWS has a better answer for this: Systems Manager Run Command. The SSM Agent on your instance makes an outbound connection to AWS, and you send commands through the SSM API. There is no inbound port, no key to rotate, and every command is recorded in CloudTrail. Your instance can sit in a private subnet with no public IP at all and this still works.

I wanted to use this in my own pipelines. I looked around the marketplace and could not find a single action that did it well. Some were thin wrappers around aws ssm send-command that fired the command and never checked whether it actually succeeded. Some did poll, but swallowed the remote exit code, so a failed deploy showed up as a green build. Others hit the SSM output limit (roughly 24 KB) and truncated my logs right at the interesting part. A few were simply abandoned.

So I wrote one: ankurk91/aws-ssm-run-command-action.

SSM vs SSH

Both get the job done. Here is how they actually compare for CI/CD:

SSM Run Command SSH
Inbound port 22 Not needed Required (or a bastion)
Public IP on the instance Not needed Usually needed
Credentials in CI IAM role via OIDC, short-lived Long-lived private key in secrets
Key rotation Nothing to rotate You own the whole rotation dance
Instance in a private subnet Works (NAT or VPC endpoints) Needs a bastion or VPN
Who can run what IAM policies, scoped per instance or tag authorized_keys on each box
Audit trail CloudTrail + SSM command history Whatever auth.log kept
Revoking access Detach the IAM policy Edit files on every server
Live output streaming No, you poll and fetch Yes
File copy (scp / rsync) No Yes
Server-side setup SSM Agent + an IAM role sshd + key distribution

SSM wins on everything that matters for security and access control. SSH keeps two real advantages: live output streaming and file transfer. For a deploy script I have not missed either. The full log lands in S3 anyway, and it is usually better to have the server pull its build artifacts from S3 or a registry than to push them over scp from a runner.

Using the action

You need three things on the AWS side:

  1. The SSM Agent on the instance. Amazon Linux and the official Ubuntu AMIs already ship with it.
  2. An IAM role attached to the instance with the AmazonSSMManagedInstanceCore managed policy.
  3. A private S3 bucket for logs. This is how the action gets around the 24 KB output limit. Add a lifecycle rule to delete old objects and forget about it.

Then the workflow:

name: Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}

      - name: Run commands on EC2
        uses: ankurk91/aws-ssm-run-command-action@v1
        with:
          ec2_instance_id: ${{ vars.EC2_INSTANCE_ID }}
          run_as_user: ubuntu
          log_bucket_name: ${{ vars.LOG_BUCKET_NAME }}
          commands: |
            set -e
            cd /var/www/app
            git pull --ff-only
            npm ci
            npx prisma migrate deploy
            npm run build
            pm2 reload ecosystem.config.js --update-env
Enter fullscreen mode Exit fullscreen mode

That is the whole thing. No secrets other than the AWS role, no port 22.

A few notes from using this in production:

  • Start your script with set -e. Without it the shell keeps going after a failed command and reports success.
  • The action exposes a command-exit-code output, so you can branch on it if you need to.
  • execution_timeout defaults to one hour. Lower it for a normal deploy so a hung command does not sit there burning runner minutes.
  • Full output lands in your S3 bucket, so nothing gets cut off.

The pipeline itself only needs ssm:SendCommand, ssm:ListCommandInvocations and ssm:GetCommandInvocation. The full policy is in the repo.

Bonus: port forwarding through SSM

Run Command is for firing off a script on the server. Sometimes you want a network connection instead. In the deploy above, prisma migrate deploy runs on the EC2 instance, which is fine. But you may prefer to run migrations from the runner, so that a bad migration fails the pipeline before any new code goes out.

That needs the runner to reach your database, and your database is almost certainly in a private subnet. Session Manager port forwarding solves it, and enkhjile/aws-ssm-remote-port-forwarding-action wraps it up nicely. It closes the session in its post step, so there is nothing for you to clean up.

      - name: Open a tunnel to RDS
        uses: enkhjile/aws-ssm-remote-port-forwarding-action@v1
        with:
          target: ${{ vars.EC2_INSTANCE_ID }}
          host: my-db.abc123.ap-south-1.rds.amazonaws.com
          port: 5432
          local-port: 5432

      - name: Run Prisma migrations through the tunnel
        run: |
          npm ci
          npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://${{ secrets.DB_USER }}:${{ secrets.DB_PASSWORD }}@127.0.0.1:5432/app?schema=public
Enter fullscreen mode Exit fullscreen mode

Prisma just sees a database on localhost. Your EC2 instance is the jump host, but you never log into it, and neither the instance nor the RDS security group needs an inbound rule from the internet.

Wrapping up

If you are still shipping a private key to GitHub secrets to deploy to EC2, SSM is worth an afternoon of your time. You delete the key, close the port, and get an audit log for free.

Links:

AWS docs:

If you try the action and something is missing, open an issue on the repo. Stars are appreciated too.

Top comments (0)