setup / 2 · shared-services
Provider
Work in the shared-services account. The provider VPC,
private subnets, and private-lb subnets
already exist — create the relay EC2, start relay.py, then the
NLB and endpoint service. Validate after each step (fictitious sample output
below — replace IDs with yours).
Which account
| Account role | Provider spoke (hosts the relay) |
| AWS CLI profile | shared-services |
| Already exists | Provider VPC, private subnets (workloads), private-lb subnets (NLB) |
| What you create | Relay EC2 (+ SG), NLB, target group, listener, endpoint service |
Confirm you are in the right account, then set the existing network IDs (fictitious examples throughout this walkthrough — substitute yours):
export AWS_PROFILE=shared-services
export AWS_REGION=ap-southeast-6 # your Region
aws sts get-caller-identity
# Existing provider spoke (example IDs — use yours)
export PROVIDER_VPC_ID=vpc-0a1b2c3d4e5f67890
# private — workload subnets (relay EC2). Pick one AZ.
export PROVIDER_PRIVATE_SUBNET_ID=subnet-0aaa1111bbbb2222
# private-lb — load balancer subnets (internal NLB needs 2+ AZs)
export PROVIDER_LB_SUBNET_IDS=subnet-0lb1aaaa1111bbbb,subnet-0lb2cccc2222dddd,subnet-0lb3eeee3333ffff
Validate — account ID and role look right (fictitious):
{
"UserId": "AROAEXAMPLEID:you@example.com",
"Account": "111122223333",
"Arn": "arn:aws:sts::111122223333:assumed-role/AWSReservedSSO_AdministratorAccess_example/you@example.com"
}
EC2, NLB, and later endpoint hours are billable. Tag with
demo=pl-vs-inspected-tgwso Teardown is easy to find.
Relay EC2
Still with AWS_PROFILE=shared-services. Launch in a private subnet with
SSM (no public IP). The spoke must provide NAT — the relay uses live
Wikimedia ingest only (no offline replay).
SSM interface endpoints
Private Session Manager needs VPC interface endpoints for ssm and
ssmmessages. Some Regions also offer ec2messages; others (including
ap-southeast-6) do not — create what exists:
aws ec2 describe-vpc-endpoint-services \
--profile shared-services --region "$AWS_REGION" \
--query "ServiceNames[?contains(@, 'ssm') || contains(@, 'ec2messages')]" \
--output text
Validate — in ap-southeast-6 only ssm and ssmmessages appear (no
ec2messages). Confirm your VPC has those endpoints available:
aws ec2 describe-vpc-endpoints \
--profile shared-services --region "$AWS_REGION" \
--filters "Name=vpc-id,Values=$PROVIDER_VPC_ID" \
--query 'VpcEndpoints[?contains(ServiceName, `ssm`)].{ServiceName:ServiceName,State:State}' \
--output table
---------------------------------------------------------------
| DescribeVpcEndpoints |
+------------------------------------------------+------------+
| com.amazonaws.ap-southeast-6.ssm | available |
| com.amazonaws.ap-southeast-6.ssmmessages | available |
+------------------------------------------------+------------+
If ssm is missing, create an interface endpoint tagged for this lab (reuse your
existing endpoint security group; do not weaken it):
# Example — use your private subnets (2+ AZs) and existing endpoint SG
aws ec2 create-vpc-endpoint \
--profile shared-services --region "$AWS_REGION" \
--vpc-id "$PROVIDER_VPC_ID" \
--vpc-endpoint-type Interface \
--service-name "com.amazonaws.${AWS_REGION}.ssm" \
--subnet-ids "$PROVIDER_PRIVATE_SUBNET_ID" \
--security-group-ids "$EXISTING_ENDPOINT_SG_ID" \
--private-dns-enabled \
--tag-specifications 'ResourceType=vpc-endpoint,Tags=[{Key=demo,Value=pl-vs-inspected-tgw},{Key=Name,Value=plvtgw-ssm}]'
Wait until State=available before launching the instance. Teardown removes
only endpoints tagged Name=plvtgw-ssm.
Security group
Dashed boundary = security group. Ingress TCP 9000 from provider VPC CIDR (NLB) and consumer spoke CIDR (TGW path).
PROVIDER_SG_ID=$(aws ec2 create-security-group \
--profile shared-services --region "$AWS_REGION" \
--group-name plvtgw-relay \
--description "pl-vs-inspected-tgw relay TCP 9000" \
--vpc-id "$PROVIDER_VPC_ID" \
--tag-specifications 'ResourceType=security-group,Tags=[{Key=demo,Value=pl-vs-inspected-tgw},{Key=Name,Value=plvtgw-relay}]' \
--query 'GroupId' --output text)
PROVIDER_VPC_CIDR=$(aws ec2 describe-vpcs \
--profile shared-services --region "$AWS_REGION" \
--vpc-ids "$PROVIDER_VPC_ID" \
--query 'Vpcs[0].CidrBlock' --output text)
# Consumer spoke CIDR — required for the TGW path (source is the subscriber IP).
# Example only; use your real consumer VPC CIDR.
export CONSUMER_VPC_CIDR=10.60.0.0/20
aws ec2 authorize-security-group-ingress \
--profile shared-services --region "$AWS_REGION" \
--group-id "$PROVIDER_SG_ID" \
--ip-permissions \
"IpProtocol=tcp,FromPort=9000,ToPort=9000,IpRanges=[{CidrIp=${PROVIDER_VPC_CIDR},Description=NLB and provider VPC}]" \
"IpProtocol=tcp,FromPort=9000,ToPort=9000,IpRanges=[{CidrIp=${CONSUMER_VPC_CIDR},Description=TGW path from consumer}]"
echo "PROVIDER_SG_ID=$PROVIDER_SG_ID"
echo "PROVIDER_VPC_CIDR=$PROVIDER_VPC_CIDR"
echo "CONSUMER_VPC_CIDR=$CONSUMER_VPC_CIDR"
PrivateLink (NLB → relay) needs the provider VPC CIDR. The TGW path needs the consumer CIDR too — hub firewall PASS alone does not open this SG.
Validate:
aws ec2 describe-security-groups \
--profile shared-services --region "$AWS_REGION" \
--group-ids "$PROVIDER_SG_ID" \
--query 'SecurityGroups[0].{GroupId:GroupId,GroupName:GroupName,Ingress:IpPermissions}' \
--output json
{
"GroupId": "sg-0aaaa1111bbbb2222",
"GroupName": "plvtgw-relay",
"Ingress": [
{
"IpProtocol": "tcp",
"FromPort": 9000,
"ToPort": 9000,
"IpRanges": [
{
"Description": "NLB and provider VPC",
"CidrIp": "10.50.0.0/20"
},
{
"Description": "TGW path from consumer",
"CidrIp": "10.60.0.0/20"
}
]
}
]
}
IAM instance profile (SSM)
Role + AmazonSSMManagedInstanceCore → instance profile attached at launch.
Create once if you do not already have a profile with AmazonSSMManagedInstanceCore:
aws iam create-role \
--profile shared-services \
--role-name plvtgw-ec2-ssm \
--assume-role-policy-document '{
"Version":"2012-10-17",
"Statement":[{
"Effect":"Allow",
"Principal":{"Service":"ec2.amazonaws.com"},
"Action":"sts:AssumeRole"
}]
}'
aws iam attach-role-policy \
--profile shared-services \
--role-name plvtgw-ec2-ssm \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam create-instance-profile \
--profile shared-services \
--instance-profile-name plvtgw-ec2-ssm
aws iam add-role-to-instance-profile \
--profile shared-services \
--instance-profile-name plvtgw-ec2-ssm \
--role-name plvtgw-ec2-ssm
aws iam tag-role \
--profile shared-services \
--role-name plvtgw-ec2-ssm \
--tags Key=demo,Value=pl-vs-inspected-tgw
# IAM is eventually consistent
sleep 15
Validate:
aws iam get-instance-profile \
--profile shared-services \
--instance-profile-name plvtgw-ec2-ssm \
--query 'InstanceProfile.{Name:InstanceProfileName,Roles:Roles[0].RoleName}' \
--output json
{
"Name": "plvtgw-ec2-ssm",
"Roles": "plvtgw-ec2-ssm"
}
Launch the instance
EC2 in private; NAT (exists) for live Wikimedia; SSM for access — no public IP.
AMI_ID=$(aws ssm get-parameters \
--profile shared-services --region "$AWS_REGION" \
--names /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
--query 'Parameters[0].Value' --output text)
PROVIDER_INSTANCE_ID=$(aws ec2 run-instances \
--profile shared-services --region "$AWS_REGION" \
--image-id "$AMI_ID" \
--instance-type t3.micro \
--subnet-id "$PROVIDER_PRIVATE_SUBNET_ID" \
--security-group-ids "$PROVIDER_SG_ID" \
--iam-instance-profile Name=plvtgw-ec2-ssm \
--no-associate-public-ip-address \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=plvtgw-relay},{Key=demo,Value=pl-vs-inspected-tgw}]' \
--query 'Instances[0].InstanceId' --output text)
aws ec2 wait instance-running \
--profile shared-services --region "$AWS_REGION" \
--instance-ids "$PROVIDER_INSTANCE_ID"
RELAY_PRIVATE_IP=$(aws ec2 describe-instances \
--profile shared-services --region "$AWS_REGION" \
--instance-ids "$PROVIDER_INSTANCE_ID" \
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
echo "PROVIDER_INSTANCE_ID=$PROVIDER_INSTANCE_ID"
echo "RELAY_PRIVATE_IP=$RELAY_PRIVATE_IP"
Validate — instance running:
aws ec2 describe-instances \
--profile shared-services --region "$AWS_REGION" \
--instance-ids "$PROVIDER_INSTANCE_ID" \
--query 'Reservations[0].Instances[0].{InstanceId:InstanceId,State:State.Name,PrivateIp:PrivateIpAddress}' \
--output json
{
"InstanceId": "i-0aaaa1111bbbb2222",
"State": "running",
"PrivateIp": "10.50.1.10"
}
Validate — then SSM Online (may take 1–2 minutes):
aws ssm describe-instance-information \
--profile shared-services --region "$AWS_REGION" \
--filters "Key=InstanceIds,Values=$PROVIDER_INSTANCE_ID" \
--query 'InstanceInformationList[0].{InstanceId:InstanceId,PingStatus:PingStatus,PlatformName:PlatformName}' \
--output json
{
"InstanceId": "i-0aaaa1111bbbb2222",
"PingStatus": "Online",
"PlatformName": "Amazon Linux"
}
Then open a session:
aws ssm start-session \
--profile shared-services --region "$AWS_REGION" \
--target "$PROVIDER_INSTANCE_ID"
Relay on the provider host
Install scripts/relay.py and run it under systemd (required). Live Wikimedia
ingest only — do not use --replay.
Copy files from the laptop into the SSM session (example):
# laptop — encode and paste, or use S3
base64 -w0 scripts/relay.py
base64 -w0 scripts/relay.service.example
# on the instance — decode into place
printf '%s' '…paste relay.py b64…' | base64 -d | sudo tee /tmp/relay.py >/dev/null
printf '%s' '…paste unit b64…' | base64 -d | sudo tee /tmp/relay.service.example >/dev/null
On the instance:
sudo useradd --system --home /opt/plvtgw --shell /sbin/nologin plvtgw
sudo mkdir -p /opt/plvtgw
sudo cp /tmp/relay.py /opt/plvtgw/relay.py
sudo chown -R plvtgw:plvtgw /opt/plvtgw
sudo chmod 755 /opt/plvtgw /opt/plvtgw/relay.py
sudo cp /tmp/relay.service.example /etc/systemd/system/plvtgw-relay.service
sudo systemctl daemon-reload
sudo systemctl enable --now plvtgw-relay.service
sudo systemctl status plvtgw-relay.service --no-pager
Confirm it is listening on TCP 9000, staying up, and serving live lines:
sudo systemctl is-active plvtgw-relay.service
sudo ss -ltnp | grep ':9000'
sudo journalctl -u plvtgw-relay.service -n 20 --no-pager
# Live ingest smoke test (should print a timestamped JSON recentchange line)
python3 -c 'import socket; s=socket.create_connection(("127.0.0.1",9000),5); s.settimeout(8); print(s.recv(120)); s.close()'
Validate (fictitious hostname / payload snippet):
active
LISTEN 0 5 0.0.0.0:9000 0.0.0.0:* users:(("python3",pid=2051,fd=3))
Sep 12 03:04:02 ip-10-50-1-10.ap-southeast-6.compute.internal systemd[1]: Started plvtgw-relay.service - pl-vs-inspected-tgw stream relay (live Wikimedia).
Sep 12 03:04:02 ip-10-50-1-10.ap-southeast-6.compute.internal python3[2051]: listening on 0.0.0.0:9000
b'1789182255886942661 {"$schema":"/mediawiki/recentchange/1.0.0","meta":{"uri":"https://en.wiktionary.org/wiki/example"'
Both paths (PrivateLink and TGW) hit this same live process.
PrivateLink service side
Still with AWS_PROFILE=shared-services. Tag with demo=pl-vs-inspected-tgw.
Create the NLB
NLB_ARN=$(aws elbv2 create-load-balancer \
--profile shared-services --region "$AWS_REGION" \
--name plvtgw-nlb --type network --scheme internal \
--subnets $(echo "$PROVIDER_LB_SUBNET_IDS" | tr ',' ' ') \
--tags Key=demo,Value=pl-vs-inspected-tgw Key=Name,Value=plvtgw-nlb \
--query 'LoadBalancers[0].LoadBalancerArn' --output text)
echo "NLB_ARN=$NLB_ARN"
Validate — wait until active (often ~2 minutes):
aws elbv2 describe-load-balancers \
--profile shared-services --region "$AWS_REGION" \
--load-balancer-arns "$NLB_ARN" \
--query 'LoadBalancers[0].{Name:LoadBalancerName,State:State.Code,Scheme:Scheme}' \
--output json
{
"Name": "plvtgw-nlb",
"State": "active",
"Scheme": "internal"
}
Create the target group
TG_ARN=$(aws elbv2 create-target-group \
--profile shared-services --region "$AWS_REGION" \
--name plvtgw-tg --protocol TCP --port 9000 \
--vpc-id "$PROVIDER_VPC_ID" --target-type instance \
--health-check-protocol TCP \
--tags Key=demo,Value=pl-vs-inspected-tgw Key=Name,Value=plvtgw-tg \
--query 'TargetGroups[0].TargetGroupArn' --output text)
echo "TG_ARN=$TG_ARN"
Register the relay instance
aws elbv2 register-targets \
--profile shared-services --region "$AWS_REGION" \
--target-group-arn "$TG_ARN" \
--targets "Id=${PROVIDER_INSTANCE_ID}"
Create the listener
aws elbv2 create-listener \
--profile shared-services --region "$AWS_REGION" \
--load-balancer-arn "$NLB_ARN" --protocol TCP --port 9000 \
--default-actions "Type=forward,TargetGroupArn=${TG_ARN}"
Validate — target becomes healthy after the NLB is active and health checks pass:
aws elbv2 describe-target-health \
--profile shared-services --region "$AWS_REGION" \
--target-group-arn "$TG_ARN" \
--query 'TargetHealthDescriptions[0].{Id:Target.Id,State:TargetHealth.State}' \
--output json
{
"Id": "i-0aaaa1111bbbb2222",
"State": "healthy"
}
Create the endpoint service
SVC_ID=$(aws ec2 create-vpc-endpoint-service-configuration \
--profile shared-services --region "$AWS_REGION" \
--network-load-balancer-arns "$NLB_ARN" \
--no-acceptance-required \
--tag-specifications 'ResourceType=vpc-endpoint-service,Tags=[{Key=demo,Value=pl-vs-inspected-tgw},{Key=Name,Value=plvtgw-eps}]' \
--query 'ServiceConfiguration.ServiceId' --output text)
Resolve the service name
SVC_NAME=$(aws ec2 describe-vpc-endpoint-service-configurations \
--profile shared-services --region "$AWS_REGION" \
--service-ids "$SVC_ID" \
--query 'ServiceConfigurations[0].ServiceName' --output text)
echo "SVC_ID=$SVC_ID"
echo "SVC_NAME=$SVC_NAME"
Validate:
SVC_ID=vpce-svc-0aaaa1111bbbb2222
SVC_NAME=com.amazonaws.vpce.ap-southeast-6.vpce-svc-0aaaa1111bbbb2222
Allow the consumer account
Use the consumer account ID (not the shared-services account):
export CONSUMER_ACCOUNT_ID=444455556666 # consumer account
aws ec2 modify-vpc-endpoint-service-permissions \
--profile shared-services --region "$AWS_REGION" \
--service-id "$SVC_ID" \
--add-allowed-principals "arn:aws:iam::${CONSUMER_ACCOUNT_ID}:root"
Validate:
aws ec2 describe-vpc-endpoint-service-permissions \
--profile shared-services --region "$AWS_REGION" \
--service-id "$SVC_ID" \
--output json
{
"AllowedPrincipals": [
{
"PrincipalType": "Account",
"Principal": "arn:aws:iam::444455556666:root",
"ServicePermissionId": "vpce-svc-perm-0bbbb2222cccc3333",
"ServiceId": "vpce-svc-0aaaa1111bbbb2222"
}
]
}
Keep $SVC_NAME, $NLB_ARN, $TG_ARN, $SVC_ID, $PROVIDER_INSTANCE_ID, and $RELAY_PRIVATE_IP for Consumer and Teardown.
Optional shortcut (same API calls through the VPCE create): ./scripts/setup-privatelink.sh — keeps IDs under scripts/.state/.