We Ditched SSH for SSM and Kamal Deploys Got Simpler
How we switched Kamal from SSH to AWS SSM for SOC2 compliance without setting up a VPN. No open ports, IAM-based access, and a simpler deploy setup.

We ditched SSH for SSM and Kamal deploys got simpler
We were going through SOC2 compliance at MilkStraw AI and hit a familiar problem: SSH port 22 was open on our servers.
Our Kamal deploy setup needed it. Kamal connects to your servers over SSH, so port 22 has to be open. Your security group needs an inbound rule. If you're deploying from GitHub Actions, you're either opening SSH to the world or maintaining a list of GitHub's IP ranges (which change).
The usual fix is a VPN. Set up a bastion host or a WireGuard tunnel, route deploy traffic through it, close port 22 to the public. It works. It's also more infrastructure to maintain and more things to explain during the audit.
We went with AWS Systems Manager (SSM) instead. No VPN, no open ports, and it satisfies SOC2 without any extra infrastructure. Here's how we set it up.
Why SSM over a VPN
The SOC2 requirement is simple: don't expose management ports to the internet. A VPN satisfies this, but it's a whole system. You need to provision it, monitor it, rotate credentials, and make sure CI can reach it.
SSM tunnels SSH through the AWS API using IAM for authentication. No inbound ports, no VPN to babysit. Access control lives in IAM policies, which you already have if you're on AWS. Your auditor sees "no open SSH ports, access gated through IAM" and moves on.
What actually changes
With SSM, Kamal still uses SSH under the hood. SSM just provides the tunnel. From Kamal's perspective, nothing changes except the connection method.
In practice:
Port 22 stays closed. No inbound SSH rule in your security group.
Servers don't need public IPs. They can sit in private subnets.
Access is controlled through IAM policies, not SSH key distribution.
You identify servers by EC2 instance ID (
i-0abc1234def567890) instead of IP address.
One tradeoff: our IPs were static (Elastic IPs), so they never changed. Instance IDs do change when you terminate and replace an instance. We update the config less often than you'd think, but it's not zero. If you're frequently rotating instances, a tag-based lookup script would be better than hardcoding IDs.
The AWS setup
Before touching Kamal, your EC2 instances need to talk to SSM. This is the most tedious part, but you only do it once.
1. SSM Agent
The SSM Agent comes pre-installed on most modern AWS AMIs: Amazon Linux 2/2023, Ubuntu 16.04+, Windows Server 2012 R2+, SLES 15.3+, AlmaLinux, and macOS 13+. The full list is in the AWS docs. If you're running a recent AWS-provided AMI, the agent is almost certainly already there. Just verify it's running:
If for some reason the agent isn't present (older AMIs, custom images, or distros like RHEL), install it manually. On Ubuntu:
One caveat: the pre-installed version may not be the latest. AWS recommends automating SSM Agent updates to keep it current.
2. Attach an IAM Instance Profile
The EC2 instance needs permission to register with SSM. Create an IAM role with the AmazonSSMManagedInstanceCore managed policy and attach it as an instance profile.
If you're using Terraform:
Or through the AWS Console: IAM > Roles > Create Role > EC2 use case > attach AmazonSSMManagedInstanceCore > name it > attach it to your instance.
3. Network access to SSM endpoints
The SSM Agent talks to AWS over HTTPS (port 443). Your instances need outbound access to three endpoints:
ssm.<region>.amazonaws.comssmmessages.<region>.amazonaws.comec2messages.<region>.amazonaws.com
If your instances have internet access through a NAT gateway or internet gateway, this already works. If they're in a fully private subnet with no internet, you'll need VPC endpoints for those three services.
Verify the connection
Once all three pieces are in place, check that SSM can see your instance:
You should see your instance ID with Online status. If it's not showing up, the agent isn't running or the IAM role isn't attached.
The Kamal changes
With SSM working on the AWS side, the Kamal changes are small.
Install the Session Manager plugin
Whatever machine runs Kamal (your laptop, a CI runner) needs the Session Manager plugin for the AWS CLI. Without it, aws ssm start-session just fails.
We use mise for tool management. One line in mise.toml:
Run mise install and both the AWS CLI and the Session Manager plugin are available. Works on macOS and Linux.
If you're not using mise, install the plugin directly from AWS docs. On macOS (Apple Silicon):
For Intel Macs, replace mac_arm64 with mac in the URL.
Add the SSH proxy command
In your base config/deploy.yml, add a proxy_command under ssh:
This tells SSH to route through SSM instead of connecting directly. %h gets replaced with the hostname (now an instance ID) and %p with the port number. Kamal passes these through automatically.
Replace IPs with instance IDs
In your environment-specific configs, swap IP addresses for EC2 instance IDs:
Find your instance IDs in the EC2 console or with:
That's the entire Kamal-side change. Three modifications: install the plugin, add the proxy command, replace IPs with instance IDs.
CI/CD setup
If you deploy from GitHub Actions, the runner also needs the Session Manager plugin and AWS credentials with ssm:StartSession permission.
If you're already deploying with Kamal from CI, you likely have an IAM role assumed via OIDC. Add SSM permissions to that role:
For the plugin, add an install step to your workflow:
Replace <region>, <account-id>, and <instance-id> in the IAM policy with your values.
What you can remove
Once SSM is working:
Remove inbound SSH (port 22) rules from your security groups.
Remove public IPs from instances that don't need them. If they're behind a load balancer, they probably don't.
Stop maintaining SSH key distribution for deploy access. IAM handles authentication now.
The SSH key is still needed for the ubuntu user on the instance itself (set via EC2 key pair at launch), since Kamal runs SSH over the SSM tunnel. But you're no longer exposing that SSH port to the internet.
Gotchas we ran into
If Kamal can't connect, check the Session Manager plugin first. It's almost always that. session-manager-plugin should be in your PATH, and the error message when it's missing doesn't tell you what's actually wrong.
Watch your AWS CLI region. The SSM session starts in whatever region your CLI is configured for. Our instances are in eu-west-1, and we lost 20 minutes the first time because someone's CLI defaulted to us-east-1. Set AWS_REGION explicitly.
SSM also adds a couple seconds of latency to the initial connection compared to direct SSH. For deploys you won't notice. For interactive sessions (like kamal app exec), there's a brief pause when connecting. Subsequent commands in the same session are fast.
The full picture
Here's what the deploy config looks like after the switch:
No new dependencies beyond the Session Manager plugin. The deploy command is the same: kamal deploy. Kamal doesn't know or care that it's going through SSM.
We made this switch in a single afternoon. Staging first, then production after confirming everything deployed cleanly. The whole thing took about an hour, and most of that was setting up the IAM instance profile because we hadn't done it before.
If you're working toward SOC2 and deploying with Kamal on AWS, SSM is the path of least resistance. No VPN to maintain, no open ports to explain, and the setup fits in a single afternoon. We expected it to be a compliance checkbox. Turns out, closing port 22 and controlling access through IAM is just a better way to run deploys regardless of compliance.