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: - The SSM Agent on the instance. Amazon Linux and the official Ubuntu AMIs already ship with it. - An IAM role attached to the instance with the AmazonSSMManagedInstanceCore managed policy. - 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 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 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: - The action: github.com/ankurk91/aws-ssm-run-command-action - Port forwarding action: github.com/enkhjile/aws-ssm-remote-port-forwarding-action AWS docs: - What is AWS Systems Manager - AWS Systems Manager Run Command - Install the SSM Agent on Linux - IAM instance profile for Systems Manager - Port forwarding with Session Manager - VPC endpoints for Systems Manager If you try the action and something is missing, open an issue on the repo. Stars are appreciated too. Top comments (0)
Comments
No comments yet. Start the discussion.