Deployment guides · Deploy the Speakeasy tunnel agent on AWS
Deploy the Speakeasy tunnel agent on AWS
Deploy the Speakeasy tunnel agent into an existing AWS account on an EKS node group, an ECS Fargate task, or a Docker-on-EC2 host supervised by systemd, dialing out to the Speakeasy tunnel gateway.
This guide deploys the Speakeasy tunnel agent (gram-tunnel-agent) into
an AWS account you already operate, onto one of three compute targets:
an existing EKS cluster’s node group, an ECS Fargate task, or a Docker
container on an EC2 host, supervised by systemd. The agent dials outbound to
wss://tunnel.speakeasy.com and proxies to an MCP server you already
run; nothing in your network is exposed inbound.
You need an existing VPC with at least one private subnet and an
existing outbound path to the internet (a NAT Gateway or equivalent) —
this guide does not create a VPC or a NAT Gateway, and confirms the path
rather than building one. For the EKS target you need an existing
cluster and a working kubectl context for it; this guide does not
create a cluster. You need AWS CLI 2.x installed locally, and for the
EKS target a kubectl matching your cluster (recorded against kubectl
1.31 here). You need credentials permitted to create the compute
resource for your chosen target — an EKS Deployment and Secret; an ECS
task definition, service, and IAM roles; or an EC2 instance — and to
read or modify the security group attached to it. Finally, you need the
address of a private MCP server you already operate, reachable from
wherever the agent will run; this guide does not stand one up.
Two AWS facts hold across all three targets and are covered once, below, before the per-target steps. Everything after that is specific to the compute target you chose — read only that section; the three are self-contained and do not depend on each other.
Confirm outbound egress to the tunnel gateway
Section titled “Confirm outbound egress to the tunnel gateway”The agent dials wss://tunnel.speakeasy.com on TCP 443 outbound only. A
NAT gateway lets a private-subnet resource make that connection without
accepting anything unsolicited inbound — you are not exposing anything
new, you are confirming a path your VPC already has.
Check the private subnet’s route table and the security group attached
to wherever the agent will run (the node/pod security group for EKS, the
awsvpc ENI’s security group for ECS Fargate, the instance’s security
group for EC2-host):
aws ec2 describe-route-tables --filters "Name=association.subnet-id,Values=<PRIVATE_SUBNET_ID>"aws ec2 describe-security-groups --group-ids <SECURITY_GROUP_ID><PRIVATE_SUBNET_ID>— the private subnet the agent will run in; from your existing VPC.<SECURITY_GROUP_ID>— the security group attached to wherever the agent runs; from your existing VPC.
Verify: the route table shows a 0.0.0.0/0 route to a NAT gateway
(nat-*) for the subnet, and the security group’s outbound rules permit
TCP 443 to 0.0.0.0/0 — the VPC default security group already allows
all outbound traffic, so this is often already true — or explicitly to
the tunnel gateway’s resolved range if the default has been narrowed.
If no NAT route is present, outbound to wss://tunnel.speakeasy.com will
fail; add a route via your existing NAT Gateway (this guide does not
create one). If outbound 443 is blocked by a narrowed security group, add
an explicit outbound rule:
aws ec2 authorize-security-group-egress \ --group-id <SECURITY_GROUP_ID> \ --protocol tcp --port 443 --cidr 0.0.0.0/0The NAT/egress path is your existing infrastructure, confirmed here, not created — nothing above is a resource you must size.
Locate the reachable address of the existing MCP server
Section titled “Locate the reachable address of the existing MCP server”Every target needs TUNNEL_LOCAL_MCP_URL set to an address reachable
from wherever the agent runs, before you deploy it. This guide never
invents that address — it is your own MCP server’s own address, from
whatever inventory your MCP server’s own deployment documents. The form
it takes differs by target:
- EKS: an in-cluster Kubernetes Service DNS name, reachable from any
pod in the cluster including the tunnel agent’s:
http://<MCP_SERVICE_NAME>.<NAMESPACE>.svc.cluster.local:<MCP_PORT>/mcp. - ECS Fargate: an address reachable from the task’s
awsvpcelastic network interface — the ENI Fargate attaches directly to the task, in the subnet you name in--network-configuration(see Create the cluster and the awsvpc service). If the MCP server is itself another task or service on the same VPC, its address is a private DNS name or IP on that VPC:http://<MCP_PRIVATE_DNS_OR_IP>:<MCP_PORT>/mcp. - EC2-host: a private DNS name or IP reachable from the instance’s
subnet:
http://<MCP_PRIVATE_DNS_OR_IP>:<MCP_PORT>/mcp.
In every case, the security group attached to wherever the agent runs must permit outbound traffic to the MCP server’s port — confirmed by Confirm outbound egress to the tunnel gateway if the MCP server sits behind a security group this guide already checks, otherwise your own MCP-side security group.
EKS node group variant
Section titled “EKS node group variant”Confirm cluster access and the node/pod security group
Section titled “Confirm cluster access and the node/pod security group”kubectl config current-contextkubectl get nodesVerify: the current context names the cluster you expect, and nodes
show Ready:
NAME STATUS ROLES AGEip-10-0-1-23.ec2.internal Ready <none> 4dIf the wrong context is selected, switch with
kubectl config use-context <CONTEXT>. If no nodes show Ready, that is
a cluster health issue outside this guide’s scope.
The cluster is your own, per this guide’s scope — nothing here is substitutable.
Save the manifest and apply the tunnel agent Deployment (EKS)
Section titled “Save the manifest and apply the tunnel agent Deployment (EKS)”This creates a Secret and a one-replica Deployment in the cluster. It is
reversible — kubectl delete -f removes both.
The Deployment’s pod inherits the node group’s security group unless your cluster uses Security Groups for Pods. In the default case, the EKS cluster/node security group allows all traffic from itself, so pod-to-pod traffic to an in-cluster MCP server needs no new rule. If your MCP server’s pods carry a distinct Security Groups for Pods security group instead, add an inbound rule on that security group permitting the tunnel agent’s pod traffic on the MCP server’s listening port — this requires Nitro-based, ENI-trunking-capable instance types and is unsupported on EKS Auto Mode or Windows nodes; treat it as a conditional case for your own node type, not a default step.
Save the manifest to a file, then apply it:
cat > gram-tunnel.yaml <<'EOF'apiVersion: v1kind: Secretmetadata: name: gram-tunnel-keytype: OpaquestringData: TUNNEL_KEY: "<YOUR_TUNNEL_KEY>"---apiVersion: apps/v1kind: Deploymentmetadata: name: gram-tunnelspec: replicas: 1 selector: matchLabels: app: gram-tunnel template: metadata: labels: app: gram-tunnel spec: containers: - name: tunnel-agent image: ghcr.io/speakeasy-api/gram-tunnel-agent:0.1.0 env: - name: TUNNEL_KEY valueFrom: secretKeyRef: name: gram-tunnel-key key: TUNNEL_KEY - name: TUNNEL_LOCAL_MCP_URL value: "<local MCP URL>" - name: TUNNEL_GATEWAY_URL value: "wss://tunnel.speakeasy.com/connect" - name: TUNNEL_SERVICE_VERSION value: "<service version>"EOFkubectl apply -f gram-tunnel.yaml<YOUR_TUNNEL_KEY>, <local MCP URL>, and <service version> — see
Speakeasy deployment for their
origin.
Verify: check the pod reached Running:
kubectl get pods -l app=gram-tunnelNAME READY STATUS RESTARTS AGEgram-tunnel-6b7f9c8d5f-abcde 1/1 Running 0 30sIf STATUS shows CrashLoopBackOff or RESTARTS is climbing, read the
container’s logs:
kubectl logs -l app=gram-tunnel --tail=20A malformed TUNNEL_KEY or TUNNEL_LOCAL_MCP_URL surfaces here as a
repeated connection error in the tail of the log rather than a
Kubernetes-level failure — kubectl get pods alone shows Running with
climbing RESTARTS but does not say why.
STATUS stuck in ImagePullBackOff means the image tag or network
egress from the node is wrong — revisit
Confirm outbound egress to the tunnel gateway.
CrashLoopBackOff with climbing RESTARTS means the agent itself is
failing — read the trimmed kubectl logs output above for its error.
The Deployment name (gram-tunnel), the manifest filename
(gram-tunnel.yaml), and the namespace are starting points, not
requirements.
ECS Fargate variant
Section titled “ECS Fargate variant”Create the ECS task execution role
Section titled “Create the ECS task execution role”This creates a new IAM role. It is reversible — the role can be deleted — but any running task using it stops working if you remove it.
cat > ecs-tasks-trust-policy.json <<'EOF'{ "Version": "2012-10-17", "Statement": [ { "Sid": "", "Effect": "Allow", "Principal": { "Service": "ecs-tasks.amazonaws.com" }, "Action": "sts:AssumeRole" } ]}EOFaws iam create-role \ --role-name ecsTaskExecutionRole \ --assume-role-policy-document file://ecs-tasks-trust-policy.jsonaws iam attach-role-policy \ --role-name ecsTaskExecutionRole \ --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicyIf you will store TUNNEL_KEY in Secrets Manager (recommended — used
below), the inline policy granting secretsmanager:GetSecretValue
scoped to the secret’s ARN is attached in
Grant the execution role access to the secret
below, once that secret’s ARN exists.
Verify:
aws iam get-role --role-name ecsTaskExecutionRoleExpect the role’s AssumeRolePolicyDocument to show
"Service": "ecs-tasks.amazonaws.com".
If the role name already exists under a different trust policy,
create-role fails; reuse the existing role only if its trust policy
matches, otherwise pick a different name.
ecsTaskExecutionRole is the AWS console’s own default name, not a
requirement — any name works as long as the task definition’s
--execution-role-arn matches it.
Create the tunnel key secret
Section titled “Create the tunnel key secret”This creates a new Secrets Manager secret holding TUNNEL_KEY. It is
reversible — the secret can be scheduled for deletion, though any
running task referencing it stops being able to start new tasks once it
is gone.
aws secretsmanager create-secret \ --name gram-tunnel-key \ --secret-string '{"TUNNEL_KEY":"<YOUR_TUNNEL_KEY>"}'<YOUR_TUNNEL_KEY> is the K4 placeholder — never a real key. The
command’s own JSON output on success matters for the next step:
{ "ARN": "arn:aws:secretsmanager:<REGION>:<ACCOUNT_ID>:secret:gram-tunnel-key-<SUFFIX>", "Name": "gram-tunnel-key", "VersionId": "<VERSION_ID>"}Secrets Manager appends a hyphen and a random six-character suffix
(<SUFFIX> above) to the name at the end of the ARN. The ARN you
paste into the task definition’s valueFrom below is this full returned
ARN, including that suffix — never an ARN hand-built from the secret
name alone, because a hand-built ARN omits the suffix and will not
resolve.
Verify:
aws secretsmanager describe-secret --secret-id gram-tunnel-keyExpect the response to include the same full ARN (with its
six-character suffix) returned by create-secret above, and
"Name": "gram-tunnel-key". This is also the safe way to recover the
full ARN if you did not copy it from the create-secret output.
If a secret with this name already exists, create-secret fails with
ResourceExistsException — reuse the existing secret’s ARN (from
describe-secret) rather than retrying with a new name, or
delete-and-recreate if that is safe.
The secret name gram-tunnel-key is this guide’s choice, substitutable,
as long as the task definition’s valueFrom below is updated to match
the ARN this command actually returns.
Grant the execution role access to the secret
Section titled “Grant the execution role access to the secret”This attaches an inline IAM policy to ecsTaskExecutionRole (created in
Create the ECS task execution role),
scoped to the secret ARN the previous step returned. It is reversible —
remove the inline policy with iam delete-role-policy — but the task
fails to start new instances without it once removed.
cat > ecs-secret-access-policy.json <<'EOF'{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": "<SECRET_ARN>" } ]}EOFaws iam put-role-policy \ --role-name ecsTaskExecutionRole \ --policy-name gram-tunnel-secret-access \ --policy-document file://ecs-secret-access-policy.json<SECRET_ARN> — the full ARN returned by
aws secretsmanager create-secret in
Create the tunnel key secret above, including the
six-character suffix AWS appends, never an ARN hand-built from the
secret name. kms:Decrypt is additionally required only if the secret
uses a customer-managed KMS key rather than the account’s default key —
not added here since this guide does not create a customer-managed key.
Verify:
aws iam get-role-policy --role-name ecsTaskExecutionRole --policy-name gram-tunnel-secret-accessExpect the response’s PolicyDocument to show
"Action": "secretsmanager:GetSecretValue" and "Resource" set to the
same full secret ARN from
Create the tunnel key secret.
If this policy was skipped or scoped to the wrong ARN, the task’s
secrets reference fails at task start with an access-denied error —
re-run describe-secret from
Create the tunnel key secret to confirm the ARN
matches exactly, including the suffix.
The inline policy name (gram-tunnel-secret-access) is this guide’s
choice, substitutable; the action and resource scope are the documented
requirement, not a starting point.
Register the task definition
Section titled “Register the task definition”This registers a new ECS task definition revision. It is reversible —
registering a new revision does not delete the old one. networkMode
must be awsvpc because the task is placed on Fargate. cpu/memory
use the smallest documented Fargate combination, 256 (.25 vCPU) /
512 MiB.
cat > tunnel-agent-task-def.json <<'EOF'{ "family": "gram-tunnel-agent", "networkMode": "awsvpc", "requiresCompatibilities": ["FARGATE"], "cpu": "256", "memory": "512", "executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/ecsTaskExecutionRole", "containerDefinitions": [ { "name": "tunnel-agent", "image": "ghcr.io/speakeasy-api/gram-tunnel-agent:0.1.0", "environment": [ { "name": "TUNNEL_LOCAL_MCP_URL", "value": "<local MCP URL>" }, { "name": "TUNNEL_GATEWAY_URL", "value": "wss://tunnel.speakeasy.com/connect" }, { "name": "TUNNEL_SERVICE_VERSION", "value": "<service version>" } ], "secrets": [ { "name": "TUNNEL_KEY", "valueFrom": "<SECRET_ARN>" } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/gram-tunnel-agent", "awslogs-region": "<REGION>", "awslogs-stream-prefix": "tunnel-agent" } } } ]}EOFaws ecs register-task-definition --cli-input-json file://tunnel-agent-task-def.json<ACCOUNT_ID>,<REGION>— your own account and region.<SECRET_ARN>— the full ARN returned byaws secretsmanager create-secretin Create the tunnel key secret above, including the six-character suffix AWS appends — never an ARN hand-built from the secret name.<local MCP URL>and<service version>— see Speakeasy deployment.
TUNNEL_KEY is passed via the secrets container-definition parameter,
backed by Secrets Manager, never via environment — the ECS equivalent
of the Kubernetes secretKeyRef.
A CloudWatch Logs log group (/ecs/gram-tunnel-agent) must exist before
tasks start logging to it:
aws logs create-log-group --log-group-name /ecs/gram-tunnel-agentVerify:
aws ecs describe-task-definition --task-definition gram-tunnel-agentExpect status: ACTIVE and the container definition to show the pinned
image tag 0.1.0.
ClientException: Invalid 'cpu' setting for task means the CPU/memory
pair does not match a Fargate-documented combination. The secrets
reference fails at task start if the execution role lacks
secretsmanager:GetSecretValue on that ARN.
256/512 is a starting point — the smallest Fargate-supported pair —
not a requirement. The family name gram-tunnel-agent and the log group
name are this guide’s choice, substitutable.
Create the cluster and the awsvpc service
Section titled “Create the cluster and the awsvpc service”This creates an ECS cluster (if one does not already exist for this
purpose) and a service that runs and maintains the task’s desired count.
It is reversible — deleting the service stops the task. The awsvpc
network mode requires networkConfiguration on the service.
aws ecs create-cluster --cluster-name gram-tunnel-clusteraws ecs create-service \ --cluster gram-tunnel-cluster \ --service-name gram-tunnel \ --task-definition gram-tunnel-agent \ --desired-count 1 \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=[<PRIVATE_SUBNET_ID>],securityGroups=[<SECURITY_GROUP_ID>],assignPublicIp=DISABLED}"<PRIVATE_SUBNET_ID> and <SECURITY_GROUP_ID> come from
Confirm outbound egress to the tunnel gateway.
assignPublicIp=DISABLED because the task runs in a private subnet
reached via NAT — this is also the documented default.
Verify:
aws ecs describe-services --cluster gram-tunnel-cluster --services gram-tunnelExpect "runningCount": 1 and "status": "ACTIVE".
A task stuck in PENDING usually means the security group or subnet has
no route to pull the image over the internet/NAT — revisit
Confirm outbound egress to the tunnel gateway.
RESOURCE:ENI limit errors mean the account’s ENI quota for the
instance/subnet is exhausted — a day-two capacity concern outside this
guide’s scope.
The cluster name gram-tunnel-cluster and service name gram-tunnel
are this guide’s choice, substitutable; desired-count of 1 matches
the single-replica Kubernetes example.
EC2-host variant
Section titled “EC2-host variant”Choose the access path and launch the EC2 instance
Section titled “Choose the access path and launch the EC2 instance”This launches a new EC2 instance if you do not already have one to run the agent on. It is reversible — an instance can be terminated — but terminating it stops the tunnel.
Choose the access path before you launch. A key pair cannot be
attached to run-instances after the instance already exists, and the
instance sits in a private subnet with no path in from outside the
VPC — the steps below (installing Docker, writing the environment
file, enabling the unit) all need a working shell on this instance
before they can run. Pick one of these two documented, substitutable
access paths per your own account’s access standard:
- SSH via an EC2 key pair. Requires
--key-nameat launch and a security group rule permitting inbound TCP 22 from wherever you connect — your own choice of bastion, VPN, or other path into a private subnet. - AWS Systems Manager Session Manager. No inbound port and no key
pair: Session Manager starts an outbound-initiated session over the
SSM Agent already present on current Amazon Linux AMIs, provided the
instance’s IAM instance profile carries the AWS-managed policy
AmazonSSMManagedInstanceCore, and the same outbound-443 egress path this guide already confirms in Confirm outbound egress to the tunnel gateway (Session Manager also dials out over 443).
This guide’s remaining EC2-host steps assume Session Manager — it needs no inbound security-group rule and no key-pair distribution, matching the outbound-only posture the tunnel agent itself already requires. The SSH path above is the substitutable alternative if your access standard requires it.
First create the instance profile and attach the managed policy:
aws iam create-role \ --role-name gram-tunnel-instance-role \ --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}'aws iam attach-role-policy \ --role-name gram-tunnel-instance-role \ --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCoreaws iam create-instance-profile --instance-profile-name gram-tunnel-instance-profileaws iam add-role-to-instance-profile \ --instance-profile-name gram-tunnel-instance-profile \ --role-name gram-tunnel-instance-roleThen launch with that instance profile and no --key-name, since
Session Manager needs none:
aws ec2 run-instances \ --image-id <AMI_ID> \ --instance-type t3.micro \ --subnet-id <PRIVATE_SUBNET_ID> \ --security-group-ids <SECURITY_GROUP_ID> \ --iam-instance-profile Name=gram-tunnel-instance-profile \ --count 1<AMI_ID>— your own chosen AMI; this guide does not pick one, AMI selection is a day-two/account-policy choice outside its scope, but it must carry the SSM Agent preinstalled or installable — current Amazon Linux AMIs do.<PRIVATE_SUBNET_ID>and<SECURITY_GROUP_ID>come from Confirm outbound egress to the tunnel gateway.
For the substitutable SSH path instead, add --key-name <KEY_PAIR_NAME>
in place of --iam-instance-profile and skip the role/profile commands
above.
Verify:
aws ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].InstanceId"aws ssm describe-instance-information --filters "Key=InstanceIds,Values=<INSTANCE_ID>"Expect the new instance ID listed with state running, and (a minute
or two after boot, once the SSM Agent has registered) the same instance
ID present in the describe-instance-information response — confirming
Session Manager can reach it before you depend on that access path for
the steps below.
If the instance launches but has no route to the internet, the subnet
lacks a NAT path — revisit
Confirm outbound egress to the tunnel gateway;
Session Manager cannot register either, since it also needs outbound
443. If the instance never appears in describe-instance-information,
the instance profile is missing or lacks
AmazonSSMManagedInstanceCore, or the SSM Agent is not present on the
chosen AMI.
t3.micro is a starting point sized for a single lightweight proxy
process, not a requirement — no source consulted for this guide states a
CPU/memory requirement for the agent process, so this is the smallest
generally-available instance type, not a sized recommendation. The
access path itself (Session Manager vs. SSH/key pair) is a starting
point per your own access standard, not a requirement — everything else
in this step (role name, instance-profile name) is this guide’s choice.
Install and enable Docker on the instance
Section titled “Install and enable Docker on the instance”This installs the Docker package and starts the Docker service on the
instance. It is reversible (sudo yum remove docker).
Connect to the instance first, using the access path chosen in Choose the access path and launch the EC2 instance — this guide’s remaining steps assume Session Manager:
aws ssm start-session --target <INSTANCE_ID>This requires the Session Manager plugin installed locally. The remaining commands in this and the following step run in that session, on the instance itself.
This guide picks Amazon Linux 2023 as the EC2-host target’s AMI and documents Docker’s install path for that AMI only — if you launched a different AMI in Choose the access path and launch the EC2 instance, substitute that AMI’s own documented Docker install procedure; this guide does not attempt to cover every distribution.
sudo yum update -ysudo yum install dockersudo systemctl enable --now docker.servicesudo usermod -a -G docker ec2-userThe AWS ECS Developer Guide’s “Installing Docker on AL2023” procedure
documents sudo yum update -y, sudo yum install docker, and
sudo service docker start followed by adding ec2-user to the
docker group. This guide substitutes
sudo systemctl enable --now docker.service for sudo service docker start, because the systemd unit created below depends on the Docker
daemon being both started now and started automatically on every future
boot — service docker start does neither past the current session.
The usermod step is optional: the systemd unit below runs docker via
sudo, so no interactive shell needs passwordless docker access; it
is carried from the cited AWS procedure only for a reader who also wants
to run ad hoc docker commands in the session without sudo.
Verify:
sudo systemctl is-active docker.servicesudo systemctl is-enabled docker.servicedocker infoExpect active, enabled, and docker info to print daemon details
without the “Cannot connect to the Docker daemon” error.
If docker info fails with “Cannot connect to the Docker daemon. Is the
docker daemon running on this host?”, the cited AWS procedure notes this
can require an instance reboot to pick up group membership or driver
state. If systemctl enable --now docker.service itself fails, the package may
not have installed the unit under that name. Run systemctl status docker to see the unit’s real name and state, and use that name in the
enable --now command and in the Requires=/After= lines of the unit
you create in the next step.
sudo yum install docker and the AL2023 AMI choice are this guide’s
starting point per the cited AWS procedure, not a requirement — if you
are on a different AMI or Linux distribution, install Docker by that
distribution’s own documented method; the remaining steps are unaffected
as long as docker run and docker logs are available afterward.
Write the environment file, then supervise the agent container
Section titled “Write the environment file, then supervise the agent container”This writes the systemd EnvironmentFile and creates a systemd unit that
runs the tunnel agent’s container image
(ghcr.io/speakeasy-api/gram-tunnel-agent:0.1.0) via docker run in
the foreground, so systemd itself supervises the container process and
restarts it on failure and on instance boot. The unit does not run
docker run -d: a detached container exits in the background from
systemd’s point of view the instant docker run -d returns, so
Restart= would restart a command that already succeeded (starting a
container) rather than one that failed, and systemd would have no live
process to supervise. Running the container in the foreground makes
ExecStart the container process itself, so Restart=on-failure
restarts the container, not merely the launcher.
Ordering matters and is fatal if reversed. systemd.service(5)
documents that if the file named by an unprefixed EnvironmentFile= “does
not exist, cannot be read, or contains invalid content, the service will
fail to start.” This guide does not use the - prefix that would make
the file optional, because a silently-missing environment file is a
worse failure than a loud one for a unit holding the tunnel key. Create
the directory and the file below before systemctl enable --now
runs, not after — reversing this order means the unit’s own first start
fails and this step’s Verify cannot pass.
Create the directory and the environment file with restrictive
permissions, since it holds TUNNEL_KEY:
sudo mkdir -p /etc/gram-tunnelsudo tee /etc/gram-tunnel/env > /dev/null <<'EOF'TUNNEL_GATEWAY_URL=wss://tunnel.speakeasy.com/connectTUNNEL_KEY=<YOUR_TUNNEL_KEY>TUNNEL_LOCAL_MCP_URL=<local MCP URL>TUNNEL_SERVICE_VERSION=<service version>EOFsudo chmod 600 /etc/gram-tunnel/envchmod 600 restricts the file to the owner (root, since it is written
via sudo tee) — the mitigation for holding a secret in the process
environment rather than a managed secret store, passed to the container
via docker run --env-file, never inlined as a -e flag, so
TUNNEL_KEY never appears in the unit file, docker ps, or shell
history. <YOUR_TUNNEL_KEY>, <local MCP URL>, and <service version>
are populated in
Speakeasy deployment.
Then create the unit and enable it, now that the file it depends on exists:
[Unit]Description=Speakeasy tunnel agentAfter=docker.service network-online.targetRequires=docker.serviceWants=network-online.target
[Service]EnvironmentFile=/etc/gram-tunnel/envExecStartPre=-/usr/bin/docker rm -f gram-tunnelExecStart=/usr/bin/docker run --name gram-tunnel --env-file /etc/gram-tunnel/env ghcr.io/speakeasy-api/gram-tunnel-agent:0.1.0Restart=on-failureRestartSec=5s
[Install]WantedBy=multi-user.targetsudo systemctl daemon-reloadsudo systemctl enable --now gram-tunnel.serviceRequires=docker.service and After=docker.service make the unit’s
own dependency on the Docker daemon explicit rather than relying on
boot-order luck. ExecStartPre=-/usr/bin/docker rm -f gram-tunnel (the
leading - makes a non-zero exit non-fatal) removes a stale container
of the same name left over from a previous crashed run, since docker run --name fails if a container by that name already exists — without
it, a restart after a crash that left the old container in an
exited-but-not-removed state would loop on that error instead of
recovering. No --rm flag is used on the docker run in ExecStart,
because --rm deletes the container the instant the foreground process
exits, racing systemd’s own view of the unit’s state; ExecStartPre
cleans up the previous container explicitly instead.
This guide does not inline the tunnel key into the unit file itself: an
EnvironmentFile with restrictive permissions, passed via --env-file,
keeps the key out of shell history and process listings, unlike passing
it directly on a command line.
Verify:
stat -c '%a %n' /etc/gram-tunnel/envsystemctl is-active gram-tunnel.servicedocker logs gram-tunnel --tail 20Expect 600 /etc/gram-tunnel/env, active, and the log tail to show
tunnel agent connected, not merely the first-seen tunnel-agent starting line (see
Speakeasy deployment for the
full readiness contract). This is the same readiness line every other
compute target’s Verify reads — docker logs is this target’s form of
that same check, parallel to kubectl logs (EKS) and aws logs tail /
CloudWatch (ECS Fargate).
If /etc/gram-tunnel/env does not exist at start time, systemctl enable --now exits non-zero and systemctl is-active shows failed —
the ordering above prevents this; if it happens anyway,
journalctl -u gram-tunnel.service shows “Failed to load environment
files” or similar. docker run fails with “Cannot connect to the Docker
daemon” if Docker (from
Install and enable Docker on the instance) is not
active — Requires=docker.service surfaces this as the unit failing to
start rather than starting and immediately erroring. The container
restart-loops (climbing restart count visible in systemctl status gram-tunnel.service) if TUNNEL_KEY or TUNNEL_LOCAL_MCP_URL is
malformed, visible in docker logs gram-tunnel.
The unit name (gram-tunnel.service), the container name
(gram-tunnel), RestartSec=5s, and the EnvironmentFile path are this
guide’s starting point, not a requirement — any process supervisor
documented for your own AMI/distribution that can run docker run in
the foreground and restart it on failure achieves the same effect.
This deploys the Speakeasy tunnel agent onto the compute target you set up in External setup: an EKS node group, an ECS Fargate task, or a Docker container on an EC2 host, supervised by systemd. Each variant below is self-contained — follow only the one matching your target.
Pull the tunnel agent image
Section titled “Pull the tunnel agent image”The image is published publicly on GHCR. No registry credentials are
required — GHCR issues an anonymous pull token, which Kubernetes and the
ECS agent handle transparently. Do not add an imagePullSecret, a
docker login, or any other credential step for this image.
For the EKS and ECS Fargate targets, verify the pinned tag is resolvable before deploying:
docker manifest inspect ghcr.io/speakeasy-api/gram-tunnel-agent:0.1.0The EC2-host target’s systemd unit pulls the image implicitly on its
first docker run — Docker pulls an image it does not already have
locally. The same docker manifest inspect command above works there
once Docker itself is installed and running (see
Install and enable Docker on the instance).
Deploy the tunnel agent
Section titled “Deploy the tunnel agent”Kubernetes (EKS node group). The Secret carries the tunnel key; the Deployment runs one replica of the agent, created against the cluster confirmed in Confirm cluster access and the node/pod security group:
apiVersion: v1kind: Secretmetadata: name: gram-tunnel-keytype: OpaquestringData: TUNNEL_KEY: "<YOUR_TUNNEL_KEY>"---apiVersion: apps/v1kind: Deploymentmetadata: name: gram-tunnelspec: replicas: 1 selector: matchLabels: app: gram-tunnel template: metadata: labels: app: gram-tunnel spec: containers: - name: tunnel-agent image: ghcr.io/speakeasy-api/gram-tunnel-agent:0.1.0 env: - name: TUNNEL_KEY valueFrom: secretKeyRef: name: gram-tunnel-key key: TUNNEL_KEY - name: TUNNEL_LOCAL_MCP_URL value: "<local MCP URL>" - name: TUNNEL_GATEWAY_URL value: "wss://tunnel.speakeasy.com/connect" - name: TUNNEL_SERVICE_VERSION value: "<service version>"Set <local MCP URL> to the in-cluster Streamable HTTP endpoint you
located in
Locate the reachable address of the existing MCP server,
in the form
http://<MCP_SERVICE_NAME>.<NAMESPACE>.svc.cluster.local:<MCP_PORT>/mcp.
Set <service version> to your own MCP service’s version string.
The image tag is pinned to 0.1.0 here rather than the Control Plane
setup screen’s :latest, so this guide’s behavior does not change under
you later. The Deployment name (gram-tunnel) is this guide’s own name,
not the Control Plane setup screen’s scratch gram-tunnel-test name.
ECS Fargate. The secret you created in
Create the tunnel key secret, and the
task definition and service you registered in
Register the task definition
and
Create the cluster and the awsvpc service,
already are this step for the ECS target — ECS has no separate “apply
a manifest” moment distinct from creating the secret, registering the
task definition, and creating the service. TUNNEL_KEY is supplied via
the task definition’s secrets parameter, referencing the Secrets
Manager secret you created for it, rather than via environment.
Docker (EC2 host). The tunnel agent’s own published image
(ghcr.io/speakeasy-api/gram-tunnel-agent:0.1.0, the same image and tag
the EKS and ECS Fargate variants run above), launched via docker run
in the foreground by the systemd unit you created in
Write the environment file, then supervise the agent container
— never docker run -d, and never as a one-off manual command, so
systemd supervises the actual container process, not just the command
that started it.
Populate the environment file loaded by that unit:
TUNNEL_GATEWAY_URL=wss://tunnel.speakeasy.com/connectTUNNEL_KEY=<YOUR_TUNNEL_KEY>TUNNEL_LOCAL_MCP_URL=<local MCP URL>TUNNEL_SERVICE_VERSION=<service version># /etc/systemd/system/gram-tunnel.service (relevant line; full unit in #ec2-supervise-agent)ExecStart=/usr/bin/docker run --name gram-tunnel --env-file /etc/gram-tunnel/env ghcr.io/speakeasy-api/gram-tunnel-agent:0.1.0Set <local MCP URL> to the private DNS name or IP you located in
Locate the reachable address of the existing MCP server,
reachable from the instance’s subnet, in the form
http://<MCP_PRIVATE_DNS_OR_IP>:<MCP_PORT>/mcp. Set
<service version> to your own MCP service’s version string.
TUNNEL_KEY reaches the container only via --env-file, sourced from
the root-owned, 0600 file above — never inlined as a docker run -e
flag, per K4.
Verify (all three targets):
The agent exposes no health port and no readiness probe. Readiness is
observable only in its logs: it writes structured JSON to stdout (Go
slog, Info level), so every line carries level, msg, and named
attributes. Three messages matter, in order:
msg | Means |
|---|---|
tunnel-agent starting (gateway, local_mcp) | process up, nothing dialed |
tunnel agent connected (gateway) | the tunnel is up |
tunnel hello received (tunnel_id, session_id) | gateway handshake complete |
A pod, task, or unit sitting in Running/active with only the first
line has started and has not dialed — that distinction is the whole
point of this check, so read the log rather than stopping at
process/pod/task status:
- EKS:
kubectl logs -l app=gram-tunnel --tail=20(see Save the manifest and apply the tunnel agent Deployment (EKS)). - ECS Fargate:
aws logs tail /ecs/gram-tunnel-agent, or the CloudWatch console. - EC2-host:
docker logs gram-tunnel --tail 20(see Write the environment file, then supervise the agent container).
A fourth message, tunnel-agent init failed, means the agent rejected
its own configuration at startup and exited non-zero — most often a
TUNNEL_GATEWAY_URL missing a host or not using wss:// (the agent
permits http(s):// only for localhost and host.docker.internal), or
a missing TUNNEL_SERVICE_VERSION. Both variables are required on every
target; a task, pod, or unit that omits TUNNEL_SERVICE_VERSION is
expected to fail startup, not run with a default.
Supply the tunnel key
Section titled “Supply the tunnel key”TUNNEL_KEY is issued by the Control Plane on the tunnel setup screen
and consumed as shown above, per target. <YOUR_TUNNEL_KEY> is the only
literal form that may appear here: never a real key, never a
realistic-looking fake. No target inlines the key directly into a
command line or an unrestricted file — Kubernetes uses secretKeyRef,
ECS Fargate uses the secrets task-definition parameter backed by
Secrets Manager, and the EC2-host target uses a permission-restricted
EnvironmentFile loaded by the systemd unit.
Keys are gram_tunnel_ followed by hex. The Control Plane stores only a
hash and a short prefix of the key, never the value — which is why the
Key prefix shown on the tunneled source’s detail view (see
Verify the tunnel below) is how you tell
which key a running deployment is using.
Rotation is not transparent. Re-issuing a key in the Control Plane replaces the stored hash immediately — the previous key stops validating as soon as you do, and a running agent still holding it fails to re-establish its session. After rotating, update the secret and restart the agent:
- EKS: update the
gram-tunnel-keySecret, thenkubectl rollout restart deployment/gram-tunnel. - ECS Fargate: update the Secrets Manager secret, then force a new
deployment:
aws ecs update-service --cluster gram-tunnel-cluster --service gram-tunnel --force-new-deployment. - EC2-host: edit
/etc/gram-tunnel/env, thensudo systemctl restart gram-tunnel.service.
Rotating the key without restarting the agent leaves it running against a key the Control Plane no longer accepts.
Verify the tunnel
Section titled “Verify the tunnel”The end-to-end check: the tunneled server appears in the registry and a tool call round-trips through it. In the Control Plane, the tunneled source’s detail view carries a Connection badge reading exactly Connected, Never connected, or Inactive, alongside Last seen (relative time), Lifecycle, Key prefix, Source ID, and Linked MCP servers. Connected with a recent Last seen is the end state you are looking for, for every compute target.
Each failure mode has its own distinct log line — read it via the log command named in Deploy the tunnel agent’s Verify above:
- Agent cannot reach the gateway — logs
tunnel agent session ended(warning), then a reconnect loop oftunnel agent reconnectingwith a backoff duration. Maps onto Confirm outbound egress to the tunnel gateway: outboundwss://to the gateway on 443 is blocked — an egress rule, a proxy, or a missing NAT path. The fix belongs in External setup, not here. - Agent cannot reach the local MCP server — logs
tunnel agent upstream error.TUNNEL_LOCAL_MCP_URLis wrong, or the agent is not placed where that address resolves — for the EKS target, check for a missing Security Groups for Pods inbound rule (see Save the manifest and apply the tunnel agent Deployment (EKS)); for ECS Fargate and EC2-host, check the task or instance’s security group or subnet for a route to the MCP server’s address (see Locate the reachable address of the existing MCP server and Confirm outbound egress to the tunnel gateway). The tunnel itself is fine in this case.
A third failure never reaches either state: configuration rejected at
startup logs tunnel-agent init failed and exits non-zero — see
Deploy the tunnel agent above.
This guide covers deployment only. For anything beyond it — scaling, upgrades, monitoring — see Speakeasy’s tunnel documentation.