How to Deploy Docker to EC2 Securely with GitHub Actions and AWS SSM — Stop Using SSH Forever

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-information returns 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, and ec2messages services. Without these, the agent cannot reach the SSM service.
  • OIDC subject claim mismatch. The sub claim format is exact: repo:org/repo:ref:refs/heads/branch. A typo means AssumeRoleWithWebIdentity will 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:SendCommand on Resource: "*" 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 SendCommand to specific instance IDs or tags.
  • Always verify. Use get-command-invocation to 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.

Leave a Comment

Your email address will not be published. Required fields are marked *