SSM Port Forwarding: Testing a Private-Subnet Service from Your Laptop Without Opening Any Inbound Path
TL;DR
- Situation: a FastAPI service runs on ECS in private subnets, and the environment allows no inbound access at all.
- Approach: SSM Port Forwarding, which connects
localhost:8888on the laptop to port8000, where uvicorn listens inside the container.- How it works: the laptop and the container each dial out to the SSM service, and SSM relays traffic between them. IAM decides who can open the tunnel.
1. The service is deployed, but you can’t reach it
Our system runs in the AWS environment of a financial-industry client, and that environment allows no public entry point. The backend is a FastAPI service on ECS behind an internal ALB, and every workload sits in a private subnet. There’s no internet-facing load balancer, no bastion host, and no VPN.
The trouble starts at testing time: the service is deployed, but requests from a laptop never arrive.
The internal ALB’s DNS name resolves from the public internet, but it resolves to private IPs (all names and IPs in this post are illustrative):
1 | dig +short internal-my-alb-1234567890.us-east-1.elb.amazonaws.com |
flowchart LR
DEV[Developer laptop]
ALB[internal ALB<br>10.0.x.x private IP]
SVC[ECS service<br>private subnet]
DEV -.->|private IP not routable from internet<br>curl timeout| ALB
ALB --> SVC
Those two 10.0.x.x addresses are only routable inside the private network. Once a packet leaves the laptop, there is no route into the VPC, so curl just times out.
The instinct is to check whether the Security Group allows 443. But a Security Group only evaluates a packet once it reaches the target, and here the packet never even enters the VPC.
No rule is misconfigured; there is simply no path from outside to inside. To test from a laptop, you first need some channel that reaches the private subnet.
2. Five options
There are five common approaches. They differ in how they open an entry point, what you have to maintain, and how broad the access they grant is.
Option A: Make the entry point internet-facing
The most direct fix is to switch the test environment’s ALB to internet-facing and restrict source IPs with a Security Group.
flowchart LR
DEV[Developer laptop] -->|443 inbound| PALB[internet-facing ALB<br>public IP + SG allowlist]
PALB --> SVC[Private-subnet service]
It needs no extra tooling, and the laptop really can reach the service. But a closed environment now has a permanent public entry point, and even with an allowlist, someone has to maintain it and get it through security review. Our environment doesn’t allow any public entry point, so this option is out.
Option B: A bastion host with an SSH tunnel
If you can’t expose the ALB, the next common approach is to put a jump host in between. The laptop connects to the bastion over SSH and uses ssh -L to bring the remote service’s port back to the local machine.
flowchart LR
DEV[Developer laptop] -->|SSH 22 inbound| BAS[Bastion EC2<br>public IP]
BAS -->|forwards traffic| SVC[Private-subnet service]
It’s mature and easy to understand, but you now have another machine to patch and monitor, a set of SSH keys to issue, store, and revoke, and a long-lived target on the internet with port 22 open. That’s too heavy for occasionally testing one API from a laptop.
Option C: Client VPN
Client VPN solves the problem more completely. Once connected, the laptop behaves as if it were inside the VPC and can reach any internal resource it’s authorized for.
flowchart LR
DEV[Developer laptop] -->|VPN tunnel| VPNEP[Client VPN Endpoint]
VPNEP -->|laptop treated as inside VPC| SVC[Private-subnet service]
The cost is setting up an endpoint, certificates, and routes, and access is usually granted to a whole network range. I only wanted to reach one port on one container, so it’s far more than I need.
Option D: NAT Gateway, which solves the opposite direction
NAT Gateway comes up a lot in these discussions, but the arrows show that it solves a different problem.
flowchart LR
SVC[Private-subnet service] -->|inside to outside: OK| NAT[NAT Gateway]
NAT --> NET[Internet]
NET -.->|outside to inside: still blocked| SVC
A NAT Gateway lets services in a private subnet initiate connections outward, for example to pull images, call AWS APIs, or ship logs. It doesn’t let a laptop on the internet connect back in.
It does point at something useful, though:
Being able to get out doesn’t mean being able to get in. Private networks usually allow outbound traffic while tightly restricting inbound.
Options A, B, and C all open a path from outside to inside, while D only provides one from inside to outside. So flip the idea around: if both the laptop and the container connect outward and a third party joins the two connections, you never have to open inbound.
Option E: SSM Port Forwarding
AWS Systems Manager (SSM) is AWS’s service for managing machines. Its Session Manager feature lets you connect from a laptop into a host or a container by relaying through AWS, as long as the target is running the SSM Agent, a small program from AWS that talks to SSM. Port Forwarding is one type of connection Session Manager can open: it links a port on your laptop to a port on the target.
In our case, localhost:8888 on the laptop connects to localhost:8000 inside the container, where uvicorn listens. A local program only has to call localhost:8888, and the request travels through the tunnel into the container.
The tunnel doesn’t give the laptop a direct path into the VPC. Instead, the session-manager-plugin on the laptop and the SSM Agent in the container each dial out to the SSM service, and SSM joins the two.
flowchart LR
subgraph LT[Developer laptop]
PLG[session-manager-plugin<br>listening on localhost:8888]
end
subgraph AWSX[AWS]
SSM[SSM Service<br>relay]
end
subgraph VPC[ECS task in private subnet]
AG[SSM Agent]
APP[Service :8000]
end
PLG -->|① outbound TLS| SSM
AG -->|② outbound TLS| SSM
AG -->|③ connects to its own 8000| APP
That’s why it needs no public IP and no new inbound rule, and why the channel disappears as soon as the session ends.
The five options side by side
| Option | New inbound entry point | New assets to maintain | Access control | Main cost |
|---|---|---|---|---|
| A. internet-facing ALB | Yes, permanent | None | SG allowlist | Public exposure and security review |
| B. Bastion + SSH | Yes, usually port 22 | Bastion and SSH keys | Key distribution | Machine and operations cost |
| C. Client VPN | Yes, VPN endpoint | VPN configuration | Certificates, network-range access | Setup and ongoing fees |
| D. NAT Gateway | No, but wrong direction | NAT Gateway | N/A | Hourly, data, and public IPv4 charges |
| E. SSM Port Forwarding | None | No extra host | IAM and session records | Little extra traffic if NAT already exists; VPC endpoints billed separately |
3. Why SSM Port Forwarding?
All I needed was temporary access to one port on one container, without opening inbound, and SSM Port Forwarding is exactly that size. If you need long-term access to a whole internal network, choose Client VPN; if you’re serving production traffic, an internet-facing ALB is the proper entry point.
Under these constraints, it has four advantages:
- It doesn’t change the network’s entry points: everything is outbound, and the VPC gets no public path.
- There’s no host to run: no bastion to maintain for the tunnel, and no SSH keys to manage.
- Access follows identity: IAM decides who can open a tunnel, and every
StartSessionleaves a CloudTrail event. - It’s gone when you’re done: once the session closes, the local port no longer leads to the container.
It has costs too:
- Traffic goes through the SSM relay, so it’s not suited to load testing or moving large amounts of data.
- Sessions don’t persist: they disconnect after sitting idle for a while, and you have to reopen them.
- The tunnel depends on the Agent in the container. While a task is being replaced, or if the Agent is unhealthy, the connection may fail.
- Traffic goes straight into the container without passing through the ALB. It can validate the application, but not the production entry path (see section 7).
For ad hoc testing and debugging, these limits are acceptable.
4. How can it connect without inbound access?
You can think of SSM Port Forwarding as “two outgoing phone calls and one switchboard.”
Two people who can’t call each other directly both call the same switchboard. The switchboard never has to call either of them; it just patches the two calls together, and the two can talk.
sequenceDiagram
participant CLI as Laptop: aws cli + plugin
participant SSM as SSM Service
participant AG as SSM Agent in container
participant APP as Service :8000
Note over AG,SSM: When the task starts, the Agent has already dialed out<br>and opened the control channel (TLS 443)
CLI->>SSM: StartSession API (HTTPS, IAM-signed)
SSM-->>CLI: session ID + WebSocket URL + one-time token
CLI->>SSM: Dials out to open WebSocket data channel
SSM->>AG: Notifies new session over control channel
AG->>SSM: Dials out to open its own WebSocket data channel
Note over CLI,AG: SSM relays application-layer bytes<br>between the two WebSockets
CLI->>SSM: HTTP bytes from curl
SSM->>AG: Relays
AG->>APP: Opens local TCP connection and writes to 8000
APP-->>CLI: Response returns the same way
The whole process has four stages:
- The Agent is already on the line. When the task starts, the SSM Agent opens a long-lived TLS 443 connection to SSM, called the control channel. It only receives notices such as “a new session is starting” and carries no actual data.
- The operator requests a session. A session is one connection from the laptop to the container, from the moment it opens until it closes. The operator runs
aws ssm start-sessionto call theStartSessionAPI and names an SSM document. A document is a configuration AWS has already written that tells the Agent what this session should do, such as open a shell or forward a port. Once IAM approves the request, the CLI receives a WebSocket URL and a one-time token. WebSocket is a long-lived, two-way connection built on HTTPS (see appendix A2), and the token is only valid for this session. - Both ends open data channels. The plugin and the Agent each dial out to SSM and open a WebSocket, called a data channel, which carries only this session’s data. To a firewall, both sides look like ordinary outbound 443 connections.
- SSM starts relaying. When the plugin receives local data, it sends it through SSM to the Agent. The Agent then opens a local TCP connection and writes the data to port 8000 in the container.
The control channel lives as long as the Agent does, while each data channel is created and torn down with its session. That’s why the Agent has to reach RUNNING first, meaning the control channel is up, before an operator can open a session.
SSM moves application-layer bytes, and each leg of the path has its own TCP connection, so the application’s access log usually shows 127.0.0.1 as the source rather than the laptop’s IP (see appendix A3).
And because it only moves bytes, HTTP, SSE, and any other protocol that runs over TCP can use this tunnel.
5. Prerequisites
For Port Forwarding to connect, the container needs an SSM Agent, but ECS doesn’t put one there by default, so you rely on a feature called ECS Exec. ECS Exec was built to let a person run commands inside a container from their laptop (aws ecs execute-command). When it’s enabled, ECS places the SSM Agent into the container at task startup, and Port Forwarding borrows that same Agent.
There are five things to set up: the ECS service, the task role, the container’s outbound path, the operator’s IAM identity, and the local plugin. The task role and the operator’s IAM identity are permission settings. Even though nothing opens inbound, that doesn’t mean anyone can open a tunnel, because the checkpoint has moved from the network to IAM. The task role decides whether the container can accept a session, and the operator’s permissions decide who can open one.
Enable ECS Exec
The ECS service must have enableExecuteCommand turned on. When a task starts, ECS mounts the SSM Agent binary into the container and reports its status under the name ExecuteCommandAgent (ECS calls platform-managed programs like this “managed agents”).
The setting only applies to newly started tasks, so after you enable enableExecuteCommand you have to redeploy; existing tasks won’t get an Agent.
Let the task role open control and data channels
The task role is the IAM identity a container uses while it runs, and any program inside the container uses it to call AWS APIs. The Agent runs inside the container too, so the permissions go on the task role, not the execution role (the identity ECS uses before the task starts, to pull images and write logs; see appendix A5 for a comparison). The task role needs these four actions:
1 | { |
The four actions map to the two kinds of channel from section 4, and each channel takes two steps, register and connect:
| Action | When | What it does |
|---|---|---|
ssmmessages:CreateControlChannel |
Task startup | Registers the control channel with SSM |
ssmmessages:OpenControlChannel |
Task startup | Dials out a WebSocket and waits for notices |
ssmmessages:CreateDataChannel |
Each new session | Registers this session’s data channel |
ssmmessages:OpenDataChannel |
Each new session | Dials out a WebSocket that carries the data |
Without these permissions, the Agent can’t reach SSM, and ExecuteCommandAgent never reaches RUNNING.
Let the container reach SSM
The task role grants permission, but the connection also needs a network path: the Agent must be able to reach SSM Messages, the part of SSM that handles control and data channels.
The container can reach AWS services through a NAT Gateway, or you can create a com.amazonaws.<region>.ssmmessages endpoint in the VPC.
A VPC endpoint provides a private entry point inside the VPC, so the container can reach SSM Messages without going over the public internet. For ECS Exec, you only need the ssmmessages endpoint (plus kms if you encrypt with your own KMS key). The ssm, ec2messages, and other endpoints that the Systems Manager docs list for EC2 aren’t needed for ECS.
Allow the operator to start sessions
The operator is the person opening the tunnel, and their IAM identity needs ssm:StartSession. AWS’s example policy also lets operators manage their own sessions:
1 | [ |
The StartSession resource lists two things: the ECS task ARN limits which tasks the operator can connect to, and the document ARN limits them to Port Forwarding. Without permission, the API returns AccessDenied immediately, so there’s no WebSocket URL or token, and no data channel can be opened.
The policy lists a document ARN because the document determines what a session can do. A document is a configuration AWS has already written that tells the Agent whether this session should open a shell or forward a port. If the policy doesn’t restrict the document, an operator who runs aws ssm start-session without naming one gets the default, SSM-SessionManagerRunShell, which opens a shell straight into the container. That shell isn’t recorded, because the logging feature that saves commands and output to CloudWatch Logs or S3 is only wired into aws ecs execute-command, so afterward nobody can tell what the operator did inside the container. For that reason, AWS’s ECS Exec documentation recommends denying ssm:StartSession on ECS tasks outright, and adds that these sessions also count against the session quota.
This post restricts the document instead of denying the action entirely. That way, operators can open Port Forwarding but not a shell, and Port Forwarding carries HTTP requests, so there were never any commands to record. CloudTrail, the AWS service that records API calls, still logs the operator, time, source IP, target, and document for every StartSession, ResumeSession, and TerminateSession; appendix A4 lists which fields to look at.
Install the local plugin
Finally, install the session-manager-plugin locally. The AWS CLI calls the StartSession API, but it’s this plugin that actually listens on the local port and forwards the data.
If you’re not sure which of these conditions is missing, AWS’s amazon-ecs-exec-checker can check your ECS Exec setup.
6. Opening the tunnel
The command that opens the tunnel is aws ssm start-session, and it needs two pieces of information: which container to connect to (the target), and what this session should do (the document).
Start with the target. SSM identifies an ECS container with a string in a fixed format:
1 | ecs:<cluster>_<task-id>_<container-runtime-id> |
The string has three parts: the cluster the container is in, the ID of its task, and the container’s own runtime ID. It has to go down to the container level because one task can run several containers at once, for example a sidecar (a helper container) that collects logs next to the main application. You already know the cluster name, and you can find the task ID with aws ecs list-tasks; for the runtime ID, you name the container and look it up with aws ecs describe-tasks.
Next, the document. For ECS targets, the AWS docs explicitly list AWS-StartPortForwardingSessionToRemoteHost, so that’s what we use here. This document takes three parameters: host is the machine the Agent connects to, and localhost means the container itself; portNumber is the port on that machine, and since our image runs uvicorn on 8000 by default, it’s 8000; localPortNumber is the port the plugin listens on locally, which here is 8888.
The commands below put the two together: they find a running task, confirm the Agent has started, look up the runtime ID to build the target, and finally open the session, mapping the container’s 8000 to the laptop’s 8888 (the cluster, service, and container names are illustrative):
1 | CLUSTER=my-cluster |
If it works, this terminal stays in the foreground. As long as the process keeps running, another terminal can send requests through the local port:
1 | curl -i http://localhost:8888/health |
The two localhosts here refer to different machines. localhost:8888 is on the laptop, where the plugin listens; host=localhost in the document is the container itself, where the application listens on 8000.
Because this path goes straight into the container and skips the ALB’s TLS termination (the ALB is what normally handles HTTPS encryption and decryption), the test URL is usually http://, not https://.
Common snags
- The Agent status is empty. The most common cause is enabling
enableExecuteCommandwithout redeploying the task, since only new tasks start the managed agent. If it’s still empty after a redeploy, check versions: Fargate needs platform version 1.4.0 or later, and the EC2 launch type needs ECS agent 1.50.2 or later. - No runtime ID comes back. If the task has a sidecar,
CONTAINERmust point to the right container. The container name isn’t necessarily the same as the ECS service name.
RemoteHost can reach more than localhost
The same approach works for hosts outside the container. An RDS database (AWS’s managed database) in a private subnet is just as unreachable from the laptop, but the container can reach it. The host in AWS-StartPortForwardingSessionToRemoteHost doesn’t have to be localhost; set it to the RDS address and the Agent connects to RDS instead, turning the same ECS task into a relay to the database.
The other document, AWS-StartPortForwardingSession, ends the tunnel at the SSM target itself. In practice it also works directly against ECS targets, but that combination isn’t documented in the official ECS docs:
1 | aws ssm start-session \ |
7. A working tunnel doesn’t mean the production path works
A tunnel test only covers the layers the traffic actually passes through.
SSM Port Forwarding sends traffic straight into the container, so it can validate application logic, API responses, and the container port. Production requests also go through DNS, TLS, the ALB listener, and the target group, and none of those layers are on the tunnel’s path.
These problems can still show up on the production path even when tunnel tests pass completely:
- An expired certificate or a mismatched domain makes the TLS handshake fail.
- The security policy rejects a particular TLS version or cipher.
- A listener rule is misconfigured, or the application handles
X-Forwarded-*headers differently. - The ALB idle timeout cuts off streaming connections that go quiet for too long.
- A target group health check marks a healthy service as unhealthy, and production requests get 502s.
- Load-balancing behavior and ALB access logs on the production path go unverified.
For example, SSE can keep streaming through the tunnel but get cut off by the idle timeout when it goes through the ALB.
8. The same shape elsewhere: reverse tunnels
This approach isn’t unique to AWS. It’s generally called a reverse tunnel, or more broadly the broker pattern.
What these have in common is that an internal agent connects out to a relay service first. External commands or data then flow back in over that existing connection, so no internal endpoint ever has to accept inbound traffic.
Other examples with the same shape:
- A GitHub Actions self-hosted runner pulls jobs from GitHub instead of letting GitHub connect into the build environment.
- Cloudflare Tunnel and ngrok run a local agent that connects out to a relay, which then sends public traffic back to the local machine.
- AWS IoT devices behind a home NAT open a long-lived MQTT connection and stay attached to a broker.
- Remote desktop software has two computers, each behind NAT, connect out to a relay.
- Tailscale uses DERP to help establish connections, and keeps relaying through it when a direct connection isn’t possible.
SSM itself offers several features over the same pipeline. aws ecs execute-command carries terminal input and output (that is, a shell); aws ssm start-session --target i-xxxx opens a session to an EC2 instance; and send-command runs commands on machines that SSM manages.
Appendix
A1. How does a timeout differ from connection refused?
- A
timeoutusually means the packet got no response. The route may not exist, a firewall may have dropped the packet, or the destination may be unreachable. connection refusedmeans the packet reached the host, but nothing is listening on the target port, so the operating system sent back an RST.
For example, while the tunnel is open, the only thing listening on the laptop is localhost:8888. If you call the laptop’s localhost:8000 instead, and nothing local uses that port, you’ll usually get connection refused.
A2. What is the WebSocket “upgrade”?
A WebSocket starts as an ordinary HTTPS request with an Upgrade: websocket header. Once the server replies 101 Switching Protocols, the same TCP connection switches from HTTP’s request-response pattern to a persistent two-way channel.
Because the connection starts out as standard TLS 443 traffic, a firewall still sees it as an ordinary outbound HTTPS connection.
A3. Bytes are relayed, not connections
Think of the application data as a letter and the TCP header as its envelope.
- The letter is the HTTP bytes
curlsends, and those contents are relayed all the way to the application. - An envelope is only valid for one leg of the trip.
curl ↔ plugin,plugin ↔ SSM,Agent ↔ SSM, andAgent ↔ applicationare all separate connections. - At the end of each leg, the old envelope is thrown away, and the letter gets a new one for the next leg.
So the application sees a new local TCP connection opened by the Agent, and the source is usually 127.0.0.1. If the application only listens on IPv6, it may see ::1 instead.
A4. Which CloudTrail fields should you check?
In CloudTrail Event history, filter on Event name = StartSession. The most useful fields in each event are:
userIdentity.arn: which IAM identity started the session.eventTimeandsourceIPAddress: when it started and from which IP.requestParameters.target: the connection target, in the formatecs:<cluster>_<task-id>_<runtime-id>.requestParameters.documentName: whether it used Port Forwarding, RemoteHost, or another document.
You can also look up TerminateSession and ResumeSession to see when, and by whom, a session was closed or resumed.
A5. Task role vs. execution role
taskRoleArnis the identity the container uses while it runs. Both the application and the SSM Agent use this role when they call AWS APIs.executionRoleArnis the identity the ECS platform uses before the task starts, for example to pull images, create log streams, or read values from Secrets Manager and inject them into the container.
Think of the execution role as the landlord’s permissions for setting up the room, and the task role as the tenant’s identity after moving in. The SSM Agent runs inside the container, so it uses the task role.
Comments