Intermediate
If your GitHub Actions deploy step still SSHes into an EC2 instance on port 22 using a stored private key, you have a security liability sitting in your pipeline. An open SSH port is an attack surface. A long-lived SSH key stored in GitHub Secrets is a credential that can leak. And you have zero native audit trail of what commands were executed on that instance.
This article shows you how to replace all of that with AWS Systems Manager (SSM) Session Manager. No open inbound ports. No SSH keys. Full command audit logging in CloudTrail. And we authenticate GitHub Actions to AWS using OpenID Connect (OIDC) — meaning zero long-lived AWS credentials stored as secrets.
This builds directly on top of the multi-stage CI/CD pipeline structure (build → test → deploy) we covered previously on the blog. Here, we go deep on the deploy stage specifically.
Prerequisites
- An EC2 instance running Amazon Linux 2023 or Ubuntu 22.04+ with Docker installed.
- The SSM Agent installed and running on the instance (preinstalled on Amazon Linux 2023 and recent Ubuntu AMIs).
- An ECR repository where your Docker image is pushed during the build stage.
- A GitHub repository with Actions enabled.
- AWS CLI v2 installed locally for verification steps.
Why SSM Is More Secure Than SSH
SSH requires port 22 open in your security group. That port gets scanned constantly. Even with key-based auth, you’re managing key distribution, rotation, and revocation manually. If a key leaks from GitHub Secrets, an attacker has direct shell access.
SSM Session Manager flips the model entirely. The SSM Agent on the EC2 instance initiates an outbound HTTPS connection to the SSM service endpoint. There are no inbound ports required — your security group can have zero inbound rules. Authentication is handled via IAM roles, not SSH keys. Every command invocation is logged in AWS CloudTrail with the full command text, the IAM principal that invoked it, the instance ID, and the execution result. This gives you an audit trail that SSH simply cannot provide.
Additionally, you can restrict who can run commands on which instances using IAM policies with conditions on resource tags — something impossible with SSH key distribution.
Setting Up the EC2 IAM Role for SSM
Your EC2 instance needs an IAM instance profile with the AmazonSSMManagedInstanceCore managed policy. This policy grants the SSM Agent permission to communicate with the Systems Manager service. If your instance also pulls images from ECR, attach ECR read permissions too.
Create the role and attach it:
# Create the trust policy for EC2
cat > ec2-trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
EOF
# Create the IAM role
aws iam create-role \
--role-name EC2DockerDeployRole \
--assume-role-policy-document file://ec2-trust-policy.json
# Attach SSM core policy
aws iam attach-role-policy \
--role-name EC2DockerDeployRole \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
# Attach ECR read-only for pulling images
aws iam attach-role-policy \
--role-name EC2DockerDeployRole \
--policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
# Create instance profile and associate
aws iam create-instance-profile --instance-profile-name EC2DockerDeployProfile
aws iam add-role-to-instance-profile \
--instance-profile-name EC2DockerDeployProfile \
--role-name EC2DockerDeployRole
# Attach to a running instance
aws ec2 associate-iam-instance-profile \
--instance-id i-0abc123def456789a \
--iam-instance-profile Name=EC2DockerDeployProfile
Verify the instance is registered with SSM (may take 1-2 minutes after attaching the role):
aws ssm describe-instance-information \
--filters "Key=InstanceIds,Values=i-0abc123def456789a" \
--query "InstanceInformationList[].{ID:InstanceId,Status:PingStatus}" \
--output table
# Expected output:
# ---------------------------------
# | DescribeInstanceInformation |
# +----------------------+--------+
# | ID | Status |
# +----------------------+--------+
# | i-0abc123def456789a | Online |
# +----------------------+--------+
Configuring GitHub Actions OIDC Authentication with AWS
Instead of storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub Secrets, use OIDC federation. GitHub's OIDC provider issues short-lived tokens that AWS STS exchanges for temporary credentials. No long-lived secrets exist anywhere.
First, create the OIDC identity provider in AWS (one-time setup):
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
Now create the IAM role that GitHub Actions will assume. The trust policy restricts access to your specific repository and branch:
cat > github-actions-trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012: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:your-org/your-repo:ref:refs/heads/main"
}
}
}
]
}
EOF
aws iam create-role \
--role-name GitHubActionsDeployRole \
--assume-role-policy-document file://github-actions-trust-policy.json
Attach a policy that allows SSM SendCommand and reading command results, scoped to your specific instance or tag:
cat > ssm-deploy-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:SendCommand",
"ssm:GetCommandInvocation"
],
"Resource": [
"arn:aws:ssm:us-east-1::document/AWS-RunShellScript",
"arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123def456789a"
]
}
]
}
EOF
aws iam put-role-policy \
--role-name GitHubActionsDeployRole \
--policy-name SSMDeployPolicy \
--policy-document file://ssm-deploy-policy.json
The Complete GitHub Actions Deploy Workflow
This workflow assumes your build and test stages (covered in the previous article) have already pushed the Docker image to ECR. The deploy job runs after those stages pass.
name: Deploy to EC2 via SSM
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
ECR_REGISTRY: 123456789012.dkr.ecr.us-east-1.amazonaws.com
ECR_REPOSITORY: my-app
IMAGE_TAG: ${{ github.sha }}
INSTANCE_ID: i-0abc123def456789a
jobs:
# Build and test jobs omitted — covered in previous article
deploy:
runs-on: ubuntu-latest
needs: [build, test]
steps:
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
aws-region: ${{ env.AWS_REGION }}
- name: Deploy Docker container via SSM
id: ssm-deploy
run: |
COMMAND_ID=$(aws ssm send-command \
--instance-ids "${{ env.INSTANCE_ID }}" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=[
"aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${{ env.ECR_REGISTRY }}",
"docker pull ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}",
"docker stop my-app || true",
"docker rm my-app || true",
"docker run -d --name my-app --restart unless-stopped -p 80:8080 ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}",
"sleep 5",
"docker ps --filter name=my-app --format \"{{.Status}}\""
]' \
--timeout-seconds 120 \
--comment "Deploy ${{ env.IMAGE_TAG }}" \
--query "Command.CommandId" \
--output text)
echo "command_id=$COMMAND_ID" >> $GITHUB_OUTPUT
- name: Wait for deployment to complete
run: |
aws ssm wait command-executed \
--command-id ${{ steps.ssm-deploy.outputs.command_id }} \
--instance-id ${{ env.INSTANCE_ID }}
- name: Verify deployment result
run: |
RESULT=$(aws ssm get-command-invocation \
--command-id ${{ steps.ssm-deploy.outputs.command_id }} \
--instance-id ${{ env.INSTANCE_ID }} \
--query "{Status:Status,Output:StandardOutputContent,Error:StandardErrorContent}" \
--output json)
echo "$RESULT" | jq .
STATUS=$(echo "$RESULT" | jq -r '.Status')
if [ "$STATUS" != "Success" ]; then
echo "::error::Deployment failed with status: $STATUS"
exit 1
fi
echo "Deployment succeeded."
The key detail: aws ssm send-command is asynchronous. It returns a Command ID immediately. You then use aws ssm wait command-executed to block until execution finishes, and aws ssm get-command-invocation to retrieve the output and exit status.
Verifying the Deployment
Beyond the pipeline check, you can verify manually from your local machine. Start an interactive SSM session (no SSH needed):
# Install the Session Manager plugin first if you haven't
# https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html
aws ssm start-session --target i-0abc123def456789a
Once connected:
docker ps
# CONTAINER ID IMAGE STATUS PORTS
# a1b2c3d4e5f6 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:abc123 Up 2 minutes 0.0.0.0:80->8080/tcp
docker logs my-app --tail 20
curl -s http://localhost:80/health
# {"status":"ok"}
You can also verify in CloudTrail. Search for the SendCommand event — it logs the IAM role ARN (GitHubActionsDeployRole), the instance ID, the document name, and the command parameters. This is your complete audit trail.
Common Mistakes
- SSM Agent not running or instance not registered. If
describe-instance-informationreturns nothing, check that the IAM instance profile is attached, the SSM Agent service is running (sudo systemctl status amazon-ssm-agent), and the instance has outbound HTTPS access to SSM endpoints (via NAT gateway or VPC endpoint). - Missing VPC endpoint or NAT gateway. Instances in private subnets with no internet access need VPC endpoints for
ssm,ssmmessages, andec2messagesservices. Without these, the agent cannot reach the SSM service. - OIDC subject claim mismatch. The
subclaim format is exact:repo:org/repo:ref:refs/heads/branch. A typo meansAssumeRoleWithWebIdentitywill be denied. Check the exact format in GitHub's OIDC documentation. - Forgetting
permissions: id-token: write. Without this at the job or workflow level, the OIDC token request will fail silently and credential configuration will error out. - SendCommand timeout too short. Docker pull on a large image can take 30-60 seconds. The default SSM timeout is 3600 seconds, but if you set it low, the command will be killed mid-pull. Use at least 120 seconds.
- Not scoping the IAM policy. Granting
ssm:SendCommandonResource: "*"allows deployment to any instance in the account. Always scope to specific instance IDs or use tag-based conditions.
Security and Cost Considerations
SSM Session Manager and SendCommand have no additional charge — you pay only for the EC2 instance and data transfer you're already using. If you set up VPC endpoints for private subnets, those cost approximately $0.01/GB processed plus ~$7.20/month per endpoint per AZ.
For a tighter security posture: enable SSM Session Manager logging to an S3 bucket and CloudWatch Logs group. This captures full session output, not just API-level events. Combine this with an SCP or IAM boundary that denies ec2:AuthorizeSecurityGroupIngress on port 22 to ensure no one can accidentally re-open SSH across your organization.
Conclusion
Replacing SSH with SSM for EC2 deployments eliminates an entire class of security risks — open ports, leaked keys, unaudited access — with zero additional AWS cost. Combined with GitHub Actions OIDC, your entire pipeline runs without a single long-lived credential.
- Close port 22 permanently. SSM uses outbound HTTPS only — no inbound rules needed.
- Use OIDC, not stored AWS keys. Short-lived tokens scoped to your repo and branch.
- Scope IAM policies tightly. Restrict
SendCommandto specific instance IDs or tags. - Always verify. Use
get-command-invocationto confirm deployment status in your pipeline. - Enable CloudTrail and session logging. Every command is auditable — use that advantage.
Found this helpful? Share it with your team. For more practical AWS and DevOps guides, visit riseofcloud.com.
Let's keep learning consistently at a medium pace.