
AWS Systems Manager: The Full Tour — Operating an EC2 Fleet Without SSH
Operate an entire EC2 fleet without SSH keys, open ports, or bastion hosts. A hands-on tour of AWS Systems Manager, from Run Command to Patch Manager.
Every bastion host is a liability you pay for twice. You pay once in security — an open port 22, SSH keys to rotate and leak, a public-facing jump box to patch. You pay again in operations — the moment you need to run one command across 500 instances, SSH stops being a tool and becomes a
for loop over a hostlist, with no audit trail and no way to know which boxes actually ran it.The fix isn't a better bastion. It's not having one. AWS Systems Manager (SSM) is a single, IAM-controlled control plane for operating a fleet of EC2 instances — no SSH keys, no inbound ports, no jump boxes. It's not one feature; it's a set of capabilities that all share the same foundation and the same access model.
This post takes the full tour. We'll set up one managed instance, then use it to drive every major SSM capability, grouping them by the two jobs they actually do: acting on instances right now, and keeping instances in a desired state over time.
The Bastion Host Problem
Traditional EC2 access and operations lean on SSH, and SSH doesn't scale down to "secure" or up to "fleet":
- Security debt — port 22 open to some CIDR, long-lived key pairs, a bastion host that itself needs hardening and patching.
- No audit trail — who ran what, where, and when? SSH won't tell you without extra tooling.
- Doesn't scale — running a command on one instance is easy; running it on 500, stopping safely when some fail, and collecting the results is a scripting project.
SSM replaces all of that with API calls authorized by IAM and executed by an agent already running on the instance. Before any of the capabilities make sense, you need to understand the one thing that unlocks them.
One Control Plane, One Prerequisite
Technically, Systems Manager is a set of capabilities — Run Command, Session Manager, Patch Manager, Automation, State Manager, Inventory — that use the SSM Agent on managed nodes, AWS APIs, and IAM-based access controls. (AWS docs ) An EC2 instance becomes a managed node when two things are true:
- The SSM Agent is running on it (pre-installed on Amazon Linux, Ubuntu, Windows Server, and other supported AMIs).
- The instance has an IAM instance profile that grants SSM permissions — the AWS managed policy
AmazonSSMManagedInstanceCoreis exactly this.
The way this work, is that the agent makes outbound calls to the SSM service. There's no inbound connection, which is why you can close port 22 entirely.
1
2
3
4
flowchart LR
You["You<br/>(AWS CLI / Console)"] -->|IAM-authorized<br/>API calls| SSM["Systems Manager<br/>(control plane)"]
SSM <-->|outbound HTTPS<br/>from the agent| Agent["SSM Agent<br/>on managed node"]
Agent --> EC2["EC2 instance"]Launch a Managed Instance
Let's create the instance we'll use for the rest of the tour. First the IAM instance profile — the role that trusts EC2 and carries
AmazonSSMManagedInstanceCore. This is the single prerequisite that turns an ordinary instance into a managed node.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Trust policy: let EC2 assume this role
cat > ssm-trust.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Principal": { "Service": "ec2.amazonaws.com" }, "Action": "sts:AssumeRole" }
]
}
EOF
aws iam create-role --role-name SSMLabRole \
--assume-role-policy-document file://ssm-trust.json
# AmazonSSMManagedInstanceCore is the minimum policy for SSM management
aws iam attach-role-policy --role-name SSMLabRole \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
# Wrap the role in an instance profile so EC2 can use it
aws iam create-instance-profile --instance-profile-name SSMLabProfile
aws iam add-role-to-instance-profile \
--instance-profile-name SSMLabProfile --role-name SSMLabRole
sleep 10 # give the instance profile a moment to propagateNow launch the instance. Note what's missing: no
--key-name (no SSH key) and no security group rule for port 22. We tag it with Environment and PatchGroup because tags are how we'll target it later.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Latest Amazon Linux 2023 AMI, resolved from a public SSM parameter
AMI_ID=$(aws ssm get-parameter \
--name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
--query 'Parameter.Value' --output text)
# Default VPC + a subnet in it
VPC_ID=$(aws ec2 describe-vpcs --filters Name=isDefault,Values=true \
--query 'Vpcs[0].VpcId' --output text)
SUBNET_ID=$(aws ec2 describe-subnets --filters Name=vpc-id,Values=$VPC_ID \
--query 'Subnets[0].SubnetId' --output text)
INSTANCE_ID=$(aws ec2 run-instances \
--image-id "$AMI_ID" \
--instance-type t2.micro \
--iam-instance-profile Name=SSMLabProfile \
--subnet-id "$SUBNET_ID" \
--tag-specifications \
'ResourceType=instance,Tags=[{Key=Name,Value=SSM-Lab},{Key=Environment,Value=dev},{Key=PatchGroup,Value=dev-servers}]' \
--query 'Instances[0].InstanceId' --output text)
aws ec2 wait instance-running --instance-ids "$INSTANCE_ID"
echo "Instance $INSTANCE_ID running; waiting ~60s for the SSM Agent to register..."
sleep 60Confirm SSM sees it as a managed node. A
PingStatus of Online is the green light for everything that follows:1
2
3
aws ssm describe-instance-information \
--filters Key=InstanceIds,Values=$INSTANCE_ID \
--query 'InstanceInformationList[0].{Id:InstanceId,PingStatus:PingStatus,Agent:AgentVersion}'If it isn't
Online yet, wait another 30 seconds — the agent registers shortly after boot. With a managed node ready, let's use it.Acting Now: Run Command, Session Manager, Automation
The first family of capabilities does something to your instances immediately. These are your replacements for "SSH in and run a thing."
Run Command — Execute Across a Fleet
Run Command executes an SSM document (a predefined script wrapper) on managed nodes without logging in. Start with a single instance using the built-in
AWS-RunShellScript document:1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Send a command; capture the command ID to fetch output afterward
COMMAND_ID=$(aws ssm send-command \
--instance-ids "$INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["hostname","uptime","df -h"]' \
--query 'Command.CommandId' --output text)
sleep 5
# Retrieve the result for this instance
aws ssm get-command-invocation \
--command-id "$COMMAND_ID" \
--instance-id "$INSTANCE_ID" \
--query '{Status:Status,Output:StandardOutputContent}'The real power shows up when you target by tag instead of instance ID. This same command would hit one instance or ten thousand — whatever carries
Environment=dev:1
2
3
4
5
6
7
# Target by tag, and roll out safely across the fleet
aws ssm send-command \
--targets Key=tag:Environment,Values=dev \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["echo Ran on $(hostname) at $(date)"]' \
--max-concurrency "50%" \
--max-errors "1"Two parameters make this production-safe:
--max-concurrency "50%"— run on at most half the fleet at a time, so a bad command doesn't hit everything at once.--max-errors "1"— stop the whole rollout once a second instance fails.
Session Manager — Interactive Access, No SSH
When you genuinely need a shell, Session Manager gives you one over the agent's outbound channel. Session Manager lets you close inbound ports, freeing you from managing SSH keys, bastion hosts, and jump boxes. (AWS docs )
1
2
# Opens an interactive shell on the instance — no key, no port 22
aws ssm start-session --target "$INSTANCE_ID"Inside, you're the
ssm-user. Type exit to close the session. Access is governed entirely by IAM policy, and every session can be logged to S3 and CloudWatch Logs for a full audit trail — the thing plain SSH never gave you. (We'll touch on enabling that logging in the best-practices section.)Automation — Multi-Step Runbooks
Run Command runs scripts; Automation runs workflows. An Automation runbook is a document of ordered steps, where each step can call any AWS API via
aws:executeAwsApi. AWS ships many prebuilt runbooks — here's one that creates an AMI from our instance:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Start a built-in runbook; capture the execution ID to poll
AUTOMATION_ID=$(aws ssm start-automation-execution \
--document-name "AWS-CreateImage" \
--parameters "{\"InstanceId\":[\"$INSTANCE_ID\"],\"NoReboot\":[\"true\"]}" \
--query 'AutomationExecutionId' --output text)
# Poll until the workflow leaves the InProgress state
while true; do
STATUS=$(aws ssm get-automation-execution \
--automation-execution-id "$AUTOMATION_ID" \
--query 'AutomationExecution.AutomationExecutionStatus' --output text)
echo "$(date +%H:%M:%S) $STATUS"
[ "$STATUS" != "InProgress" ] && break
sleep 10
doneAutomation is what you reach for when a task is more than one command and needs ordering, branching, or error handling. It can be triggered on a schedule, by EventBridge, or as the remediation action for a noncompliant Config rule.
That covers acting in the moment. The other half of SSM is about the opposite: making sure instances stay the way you want them.
Keeping State: State Manager, Patch Manager, Inventory
The second family answers "how do I keep my fleet correct over time?" rather than "do this once, now."
State Manager — Enforce a Desired State on a Schedule
State Manager runs an SSM document against targets on a schedule, continuously reconciling the fleet to a desired state. An association binds a document, a set of targets, and a schedule. This one keeps the CloudWatch agent installed on every
dev instance:1
2
3
4
5
6
7
# Reinstall/verify the CloudWatch agent daily on all dev instances
aws ssm create-association \
--name "AWS-ConfigureAWSPackage" \
--targets Key=tag:Environment,Values=dev \
--parameters '{"action":["Install"],"name":["AmazonCloudWatchAgent"]}' \
--association-name "install-cw-agent" \
--schedule-expression "rate(1 day)"If someone uninstalls the agent, the next scheduled run puts it back. That's the difference from Run Command: Run Command is a one-shot action, State Manager is an ongoing guarantee.
Patch Manager — Scan, Then Install
Patch Manager automates OS and application patching through two concepts: a patch baseline (rules for which patches are approved) and a patch group (a tag that associates instances with a baseline). Create a baseline that auto-approves critical and important security patches:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Baseline: approve Critical/Important security patches immediately
BASELINE_ID=$(aws ssm create-patch-baseline \
--name "DevServerBaseline" \
--operating-system AMAZON_LINUX_2023 \
--approval-rules '{
"PatchRules": [{
"PatchFilterGroup": {
"PatchFilters": [
{ "Key": "CLASSIFICATION", "Values": ["Security"] },
{ "Key": "SEVERITY", "Values": ["Critical", "Important"] }
]
},
"ApproveAfterDays": 0,
"EnableNonSecurity": false
}]
}' \
--query 'BaselineId' --output text)
# The patch group tag value ("dev-servers") matches the instance's PatchGroup tag
aws ssm register-patch-baseline-for-patch-group \
--baseline-id "$BASELINE_ID" \
--patch-group "dev-servers"Patching happens via the
AWS-RunPatchBaseline document, and the single most important parameter is Operation. Use Scan to report compliance without changing anything:1
2
3
4
5
6
7
8
9
10
11
12
# Scan only — reports missing patches, installs nothing
aws ssm send-command \
--document-name "AWS-RunPatchBaseline" \
--targets Key=tag:PatchGroup,Values=dev-servers \
--parameters '{"Operation":["Scan"]}'
sleep 30
# Check the compliance summary for the instance
aws ssm describe-instance-patch-states \
--instance-ids "$INSTANCE_ID" \
--query 'InstancePatchStates[0].{Installed:InstalledCount,Missing:MissingCount,Failed:FailedCount}'Switch
Operation to Install to actually apply patches (in production you'd run this inside a maintenance window so reboots happen on schedule):1
2
3
4
5
# Install — applies approved patches, may reboot
aws ssm send-command \
--document-name "AWS-RunPatchBaseline" \
--targets Key=tag:PatchGroup,Values=dev-servers \
--parameters '{"Operation":["Install"]}'The key difference to remember: Scan reports, Install changes. Reach for Scan to measure compliance, Install to fix it.
Inventory — Know What You Have
Inventory collects metadata — installed applications, network config, Windows updates — from your managed nodes on a schedule, using another State Manager association:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Gather software inventory from all dev instances every 12 hours
aws ssm create-association \
--name "AWS-GatherSoftwareInventory" \
--targets Key=tag:Environment,Values=dev \
--schedule-expression "rate(12 hours)" \
--association-name "gather-inventory"
sleep 60
# Peek at the collected application list for one instance
aws ssm list-inventory-entries \
--instance-id "$INSTANCE_ID" \
--type-name "AWS:Application" \
--query 'Entries[0:5].{Name:Name,Version:Version}'Inventory data can be synced to S3 and queried with Athena for fleet-wide questions like "which instances still run a vulnerable package version?"
With all six capabilities in hand, a few patterns separate a demo from a production setup.
Patterns That Make It Production-Grade
The capabilities are the easy part. These patterns are what make SSM safe and scalable in a real environment:
- Target by tags, never by instance IDs. Every example above used
Key=tag:.... Tag-based targeting is what lets the same command serve one instance or ten thousand, and it keeps working as instances come and go. - Roll out with
MaxConcurrencyandMaxErrors. Treat every fleet-wide action like a deployment: limit how many instances change at once, and stop automatically when failures cross a threshold. - Turn on Session Manager logging. Point session output to S3 and a CloudWatch log group so every interactive session is auditable. This is the control that lets you drop SSH without losing accountability.
- Run instances in private subnets with VPC endpoints. The agent only needs outbound HTTPS. Interface VPC endpoints for
ssm,ssmmessages, andec2messageslet fully private instances be managed with no internet gateway at all. - Patch inside maintenance windows. Schedule
Installoperations in a defined window so reboots and disruption land when you expect them, not mid-day.
These are the shape of a healthy SSM deployment. Adopt them from the start and the control plane stays both secure and auditable as the fleet grows.
Clean Up
Tear the lab down in dependency order — associations and patch-group registration first, then the instance, then the IAM resources (a role can't be deleted while it's still in an instance profile). The only resource here that accrues ongoing cost is the running EC2 instance, so terminating it is the part that matters most for your bill.
One subtlety: both associations were created with
--targets, so they must be deleted by association ID, not by name — we look the IDs up first.1
2
3
4
5
6
7
8
9
10
11
12
# Delete the State Manager / Inventory associations (resolve IDs from names)
for NAME in install-cw-agent gather-inventory; do
ASSOC_ID=$(aws ssm list-associations \
--association-filter-list "key=AssociationName,value=$NAME" \
--query 'Associations[0].AssociationId' --output text)
[ "$ASSOC_ID" != "None" ] && aws ssm delete-association --association-id "$ASSOC_ID"
done
# Deregister the patch group from the baseline, then delete the baseline
aws ssm deregister-patch-baseline-for-patch-group \
--baseline-id "$BASELINE_ID" --patch-group "dev-servers"
aws ssm delete-patch-baseline --baseline-id "$BASELINE_ID"The
AWS-CreateImage runbook left behind an AMI and its backing snapshot. Deregister the image, then delete the snapshot:1
2
3
4
5
6
7
8
9
10
11
12
# Find the AMI this account created from the lab instance
AMI_ID=$(aws ec2 describe-images --owners self \
--filters "Name=name,Values=AWS-CreateImage*" \
--query 'Images[0].ImageId' --output text)
if [ "$AMI_ID" != "None" ]; then
# Grab the snapshot the AMI references before deregistering it
SNAP_ID=$(aws ec2 describe-images --image-ids "$AMI_ID" \
--query 'Images[0].BlockDeviceMappings[0].Ebs.SnapshotId' --output text)
aws ec2 deregister-image --image-id "$AMI_ID"
[ "$SNAP_ID" != "None" ] && aws ec2 delete-snapshot --snapshot-id "$SNAP_ID"
fiTerminate the instance, then unwind the IAM instance profile and role:
1
2
3
4
5
6
7
8
9
10
11
12
13
# Terminate the instance (stops the only ongoing charge in this lab)
aws ec2 terminate-instances --instance-ids "$INSTANCE_ID"
aws ec2 wait instance-terminated --instance-ids "$INSTANCE_ID"
# A role must be removed from its instance profile before either can be deleted
aws iam remove-role-from-instance-profile \
--instance-profile-name SSMLabProfile --role-name SSMLabRole
aws iam delete-instance-profile --instance-profile-name SSMLabProfile
# Detach the managed policy, then delete the role
aws iam detach-role-policy --role-name SSMLabRole \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam delete-role --role-name SSMLabRoleWrapping Up
Systems Manager replaces the bastion-and-SSH model with one IAM-controlled control plane. The mental model is small: get instances to managed node status with the SSM Agent and an
AmazonSSMManagedInstanceCore instance profile, then reach for the capability that fits the job — Run Command, Session Manager, or Automation to act now; State Manager, Patch Manager, or Inventory to hold a desired state over time — always targeting by tag.The concrete next step is a mindset shift: make Session Manager the default way anyone touches an instance, and delete your bastion hosts. Once access runs through IAM and the agent, closing port 22 across your fleet is a one-time change — and the operational capabilities in this post come along for free.
Are you looking into improve the practices for managing your fleet of EC2 instances? Let's talk!
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article