Allowing GitHub Actions to SSH into Your Server

Automating deployments usually means giving GitHub Actions a way into your server. The approach below opens SSH only for the duration of the job and closes it again afterwards, so the port isn’t sitting open to the internet between deploys.

You need three things on the AWS side: an OIDC provider, a security group, and an IAM role. Then a workflow that opens the door, deploys, and shuts it behind itself.

Configuring AWS

1. Set up the GitHub OIDC provider

  1. Go to the IAM console in AWS
  2. Navigate to Identity providers
  3. Click “Add provider”
  4. Select “OpenID Connect”
  5. Provider URL: https://token.actions.githubusercontent.com
  6. Audience: sts.amazonaws.com
  7. Click “Add provider”

2. Security group

Create a security group named github-actions-ec2-ssh with no rules configured, and attach it to your EC2 instance. It stays empty until a job needs it.

To create the security group:

  1. Go to the EC2 console
  2. Navigate to Security Groups
  3. Click “Create security group”
  4. Name it github-actions-ec2-ssh
  5. Attach it to your EC2 instance

3. IAM role

Create an IAM role called Github-Action-Assume-Role with the following trust relationship:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::AWS_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
                },
                "StringLike": {
                    "token.actions.githubusercontent.com:sub": "repo:GITHUB_ORG/*"
                }
            }
        }
    ]
}

4. Policy for the role

Create a policy named manage-security-group-ingress and attach it to your IAM Role:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:RevokeSecurityGroupIngress",
                "ec2:AuthorizeSecurityGroupIngress"
            ],
            "Resource": "arn:aws:ec2:AWS_REGION:AWS_ACCOUNT_ID:security-group/SECURITY_GROUP_ID"
        }
    ]
}

Configuring GitHub Actions

1. Updating security group rules

To allow GitHub Actions to connect to your EC2 instance via SSH, you need to add rules to your github-actions-ec2-ssh security group. You can do this through the AWS Management Console or by using the AWS CLI.

Example CLI command

This authorises SSH access from a given address:

aws ec2 authorize-security-group-ingress \
--group-id SECURITY_GROUP_ID \
--protocol tcp \
--port 22 \
--cidr GITHUB_ACTIONS_IP/32

Replace GITHUB_ACTIONS_IP with the IP address or CIDR range from GitHub’s published list of Action runner IPs.

2. The workflow

With the IAM role and security group in place, configure these secrets in your repository settings: AWS_ASSUME_ROLE (ARN of the IAM role), AWS_REGION, AWS_SECURITY_GROUP (ID of the github-actions-ec2-ssh group), DEPLOY_USER, HOSTNAME and SSH_PRIVATE_KEY.

Here’s a job that adds the runner’s IP to the security group, connects, and then revokes the rule in a second job that always runs:

  deploy:
    name: Deploy
    runs-on: ubuntu-24.04
    outputs:
      ipv4: ${{ steps.ip.outputs.ipv4 }}
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }}
          role-session-name: github-runner-${{ env.APP_ENV }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Public IP
        id: ip
        run: |
          ipv4=$(dig +short -4 txt ch whoami.cloudflare @1.0.0.1)
          echo "ipv4=$ipv4" >> $GITHUB_OUTPUT

      - name: Add Github Actions IP to Security group
        run: |
          aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SECURITY_GROUP }} \
          --ip-permissions IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges="[{CidrIp=${{ steps.ip.outputs.ipv4 }}/32,Description='Github Action'}]"

      - name: Prepare SSH configuration.
        run: |
          mkdir -p ~/.ssh
          chmod 700 ~/.ssh
          touch ~/.ssh/known_hosts
        shell: bash

      - name: Add SSH key.
        run: |
          ssh-agent -a $SSH_AUTH_SOCK > /dev/null
          echo "$SSH_KEY" | ssh-add -
        env:
          SSH_AUTH_SOCK: /tmp/ssh_agent.sock
          SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
        shell: bash

      - name: Verify SSH Connection
        run: |
          attempt_ssh_connection() {
            local ssh_options="$1"
            timeout 10s ssh $ssh_options -p 22 ${{ secrets.DEPLOY_USER }}@${{ secrets.HOSTNAME }} exit 2>/dev/null
          }

          connection_successful=false

          if attempt_ssh_connection "-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no"; then
            echo "SSH connection successful"
            connection_successful=true
          fi

          if $connection_successful; then
            exit 0
          fi

          echo "CONNECTION FAILED"
          ssh -vvv -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5 -p 22 ${{ secrets.DEPLOY_USER }}@${{ secrets.HOSTNAME }}
          exit 1
        shell: bash
        env:
          SSH_AUTH_SOCK: /tmp/ssh_agent.sock

      - name: ssh-keyscan to get the public key of the remote server.
        run: |
          ssh-keyscan -p 22 -H ${{ secrets.HOSTNAME }} >> ~/.ssh/known_hosts
          chmod 644 ~/.ssh/known_hosts
        shell: bash
        env:
          SSH_AUTH_SOCK: /tmp/ssh_agent.sock

      # Additional steps for deployment can be added here...

  revokeSecurityGroup:
    name: Revoke Security Group Ingress
    runs-on: ubuntu-24.04
    needs: deploy
    if: always()
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }}
          role-session-name: github-runner-${{ env.APP_ENV }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Remove Github Actions IP from Security group
        run: |
          aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SECURITY_GROUP }} --protocol tcp --port 22 --cidr ${{ needs.deploy.outputs.ipv4 }}/32

The revokeSecurityGroup job uses if: always(), which matters more than it looks. Without it, a failed deploy leaves the rule in place and the port open.

Worth doing as well

  • Rotate SSH keys on a schedule.
  • Keep the IAM role scoped to the one security group it needs.
  • Use CloudTrail to audit security group changes.
  • Watch for failed deployments that never reached the revoke step.

If you’d rather not manage SSH at all, AWS Systems Manager Session Manager is the alternative worth looking at.

I’ll write separately about how we use this with PHP Deployer and Docker Swarm to handle the actual deployments.