
I tried running the KiroCrew official Docker image on EC2 and testing connection via Tailscale
This page has been translated by machine translation. View original
Introduction
Last time, I installed KiroCrew directly on EC2 and accessed it via an SSH tunnel. This time, I confirmed a configuration where the official KiroCrew Docker image is launched as a container and accessed from within the tailnet via Tailscale Serve.
Tailscale Serve is a feature that exposes local services to devices within the tailnet. I restricted Docker port publishing to loopback only and verified end-to-end connectivity from another device within the tailnet. I also looked at the named volume configuration for persisting sessions and settings, as well as the contents recorded in the access log.
Verification Details
Verification Environment
| Item | Configuration |
|---|---|
| Infrastructure | Amazon EC2 built with CloudFormation |
| EC2 | t4g.medium / arm64 / Amazon Linux 2023 / ap-northeast-1 |
| Software | Docker 25.0.16 / Compose v5.4.0 / Tailscale 1.102.2 / KiroCrew 0.1.3 |
| Storage | named volume (/home/kirocrew) |
| KiroCrew | ghcr.io/kirodotdev/kirocrew:stable (launched via Docker Compose) |
| Access Path | Access within tailnet via Tailscale Serve |
The EC2 instance, VPC, and security group were created using a CloudFormation template, and connections to the instance are handled through SSM Session Manager.
Isolating KiroCrew in a Docker Container
KiroCrew is launched with Docker Compose via /opt/kirocrew/compose.yaml. Port publishing is restricted to the loopback address only. Tailscale Serve is solely responsible for exposing the service to the tailnet.
services:
kirocrew:
image: ghcr.io/kirodotdev/kirocrew:stable
ports:
- "127.0.0.1:5478:5476"
environment:
- KIROCREW_ALLOW_UNSANDBOXED=1
After starting, I verified the port publishing state and confirmed it was bound only to loopback as intended.
127.0.0.1:5478->5476/tcp
KIROCREW_ALLOW_UNSANDBOXED=1 is set as a prerequisite for this configuration where the Docker container serves as the isolation boundary, and does not guarantee the general safety of KiroCrew.
Performing Initial Setup via SSM
The initial setup includes Tailscale authentication, Kiro CLI device flow authentication, kirocrew setup, and Tailscale Serve configuration. Since all of these involve interactive operations such as displaying authentication URLs or entering codes, this initial setup was not automated and was performed as terminal operations via SSM Session Manager.
aws ssm start-session --target "<INSTANCE_ID>"
cd /opt/kirocrew
# Tailscale authentication (open the displayed URL in a browser to approve)
sudo tailscale up
# Initial setup of Kiro CLI and KiroCrew inside the container
sudo docker compose exec kirocrew kiro-cli login --use-device-flow
sudo docker compose exec kirocrew kirocrew setup
Specify the port published to loopback as the forwarding destination for Tailscale Serve.
sudo tailscale serve --bg --yes "http://127.0.0.1:5478"
sudo tailscale serve status
https://<HOSTNAME>.<TAILNET_NAME>.ts.net (tailnet only)
|-- / proxy http://127.0.0.1:5478
tailnet only is a setting that restricts the publishing scope to within the tailnet. funnel is not used in this verification.
To use the Serve URL in subsequent settings, retrieve it from the output of tailscale serve status.
SERVE_URL="$(sudo tailscale serve status | awk '$1 ~ /^https:\/\// {print $1; exit}')"
case "$SERVE_URL" in
https://*) echo "Serve URL: $SERVE_URL" ;;
*) echo "ERROR: Tailscale Serve URL not found" >&2; exit 1 ;;
esac
Register the retrieved Serve URL with KiroCrew and restart it.
sudo docker compose exec -T kirocrew kirocrew config set \
dashboard.url "$SERVE_URL"
sudo docker compose restart kirocrew
sudo docker compose exec -T kirocrew kirocrew config get dashboard.url
sudo docker compose ps
curl --fail --silent http://127.0.0.1:5478/api/health
The configuration value, container state, and local health response were all as expected.
Connecting via Tailscale Serve
From another device already connected to Tailscale, I sent a health check to the URL assigned by Serve.
curl "https://<HOSTNAME>.<TAILNET_NAME>.ts.net/api/health"
{"ok": true}
Temporarily Storing the Dashboard Access Token
To open the dashboard, issue a dashboard token inside the container. The token is not output to chat, logs, or Git, but is saved to a temporary file local to EC2.
cd /opt/kirocrew
SERVE_URL="$(sudo tailscale serve status | awk '$1 ~ /^https:\/\// {print $1; exit}')"
TOKEN=$(sudo docker compose exec -T kirocrew kirocrew token --ttl 2h)
sudo install -m 600 /dev/null /run/kirocrew-dashboard-token
printf '%s\n' "$TOKEN" | sudo tee /run/kirocrew-dashboard-token > /dev/null
echo "Token saved to /run/kirocrew-dashboard-token (mode 600)"
echo "URL pattern: ${SERVE_URL}?token=<token>"
When checking the saved content, the token and part of the hostname are masked for display. This allows you to verify the token file contents, permissions, and dashboard connection URL without exposing the actual values.
TOKEN_FROM_FILE="$(sudo cat /run/kirocrew-dashboard-token)"
MASKED_TOKEN="$(printf '%s' "$TOKEN_FROM_FILE" | sed -E 's/^(.{4}).*(.{4})$/\1...\2/')"
SERVE_HOST="${SERVE_URL#https://}"
MASKED_HOST="$(printf '%s' "$SERVE_HOST" | sed -E 's/^(.{4}).*(.{4})$/\1...\2/')"
printf 'Token file content (masked): %s\n' "$MASKED_TOKEN"
printf 'Token file mode: %s\n' "$(sudo stat -c '%a' /run/kirocrew-dashboard-token)"
printf 'Dashboard URL (masked): https://%s?token=%s\n' "$MASKED_HOST" "$MASKED_TOKEN"
Browser Access
On a device connected to the tailnet, open the following URL in a browser. Use the value temporarily saved in /run/kirocrew-dashboard-token for the token in the URL.
https://<HOSTNAME>.<TAILNET_NAME>.ts.net?token=<token>
Once browser access is complete, delete the temporary file on EC2.
sudo rm -f /run/kirocrew-dashboard-token
Persisting Sessions and Settings
Kiro CLI credentials and KiroCrew settings are written under /home/kirocrew. By mounting /home/kirocrew as a named volume, all container writes are consolidated to a single location.
services:
kirocrew:
volumes:
- kirocrew-home:/home/kirocrew
volumes:
kirocrew-home:
KiroCrew also writes access.log to the same directory. For log-related configuration (PYTHONPATH and sitecustomize.py mounts), refer to the compose.yaml in the appendix. After accessing from a browser, let's check the permissions and recent records.
cd /opt/kirocrew
sudo docker compose exec -T kirocrew stat -c '%a %n' /home/kirocrew/access.log
sudo docker compose exec -T kirocrew sh -c 'tail -n 50 /home/kirocrew/access.log'
An excerpt of the actual output:
600 /home/kirocrew/access.log
2026-08-06T02:34:55+0000 INFO aiohttp.access: http_request method=POST path=/api/chat status=200 duration_ms=3.4
2026-08-06T02:35:15+0000 INFO aiohttp.access: http_request method=GET path=/api/health status=200 duration_ms=0.5
The dashboard token is passed as a query string, but it was not retained in access.log. In this verification, a custom access logger (sitecustomize.py in the appendix) is loaded via PYTHONPATH, and as a result, only four items are recorded: method, path, status, and duration_ms. The file permissions are 600.
Summary
By using a Docker container as the isolation boundary and exposing the service within the tailnet via Tailscale Serve, you can access KiroCrew on EC2 through a path that does not depend on an SSH tunnel. If you want to use the same KiroCrew from multiple devices via a tunnel with access restricted to within the tailnet, give this configuration a try.
Appendix: Files for Reproduction
Here is a summary of the files to reproduce the configuration used in the main text. The actual files from the verification are included in the templates and scripts. Secret information such as authentication URLs, device codes, authkeys, and dashboard tokens are not included.
CloudFormation Template
Here is the full text of templates/kirocrew-ec2.yaml.
CloudFormation Template (full text)
AWSTemplateFormatVersion: "2010-09-09"
Description: >
Kiro Crew EC2 (ARM64/t4g.medium/AL2023) in ap-northeast-1.
Tailscale + Docker + KiroCrew + KiroCLI via UserData.
SSM-only management. No inbound ports. IMDSv2 required.
Self-contained: VPC, Subnet, IGW, RouteTable, SG, IAM Role/Profile, EC2.
# ====================================================================
# Parameters
# ====================================================================
Parameters:
InstanceType:
Type: String
Default: t4g.medium
AllowedValues: [t4g.small, t4g.medium, t4g.large]
Description: EC2 instance type (ARM64 Graviton2)
AmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64
Description: Amazon Linux 2023 ARM64 AMI (SSM Parameter)
RootVolumeSize:
Type: Number
Default: 20
MinValue: 20
MaxValue: 100
Description: Root EBS volume size in GiB
ProjectName:
Type: String
Default: kirocrew-cfn
Description: Project name used for resource naming and tags
DeployDate:
Type: String
Default: "20260806"
Description: Deployment date suffix (YYYYMMDD) for resource naming
# ====================================================================
# Resources
# ====================================================================
Resources:
# ------------------------------------------------------------------
# VPC & Network
# ------------------------------------------------------------------
KiroCrewVPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/24
EnableDnsSupport: true
EnableDnsHostnames: true
Tags:
- Key: Name
Value: !Sub "${ProjectName}-vpc-${DeployDate}"
- Key: Project
Value: !Ref ProjectName
- Key: ManagedBy
Value: CloudFormation
KiroCrewInternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Sub "${ProjectName}-igw-${DeployDate}"
- Key: Project
Value: !Ref ProjectName
KiroCrewVPCGatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref KiroCrewVPC
InternetGatewayId: !Ref KiroCrewInternetGateway
KiroCrewSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref KiroCrewVPC
CidrBlock: 10.0.0.0/24
AvailabilityZone: ap-northeast-1a
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub "${ProjectName}-subnet-${DeployDate}"
- Key: Project
Value: !Ref ProjectName
KiroCrewRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref KiroCrewVPC
Tags:
- Key: Name
Value: !Sub "${ProjectName}-rtb-${DeployDate}"
- Key: Project
Value: !Ref ProjectName
KiroCrewDefaultRoute:
Type: AWS::EC2::Route
DependsOn: KiroCrewVPCGatewayAttachment
Properties:
RouteTableId: !Ref KiroCrewRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref KiroCrewInternetGateway
KiroCrewSubnetRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref KiroCrewSubnet
RouteTableId: !Ref KiroCrewRouteTable
# ------------------------------------------------------------------
# Security Group: inbound fully closed, outbound for Tailscale/Docker/SSM HTTPS
# ------------------------------------------------------------------
KiroCrewSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: !Sub "${ProjectName}-sg-${DeployDate}"
GroupDescription: "Kiro Crew EC2: No inbound. Outbound HTTPS SSM Docker Tailscale"
VpcId: !Ref KiroCrewVPC
# Inbound: fully closed (SSM requires no ports)
SecurityGroupIngress: []
# Outbound: HTTPS + Tailscale UDP
SecurityGroupEgress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
Description: HTTPS for SSM endpoints, Docker pull, Tailscale control
- IpProtocol: tcp
FromPort: 80
ToPort: 80
CidrIp: 0.0.0.0/0
Description: HTTP for package mirrors (dnf)
- IpProtocol: udp
FromPort: 41641
ToPort: 41641
CidrIp: 0.0.0.0/0
Description: Tailscale WireGuard UDP
- IpProtocol: udp
FromPort: 3478
ToPort: 3478
CidrIp: 0.0.0.0/0
Description: Tailscale DERP/STUN
Tags:
- Key: Name
Value: !Sub "${ProjectName}-sg-${DeployDate}"
- Key: Project
Value: !Ref ProjectName
- Key: ManagedBy
Value: CloudFormation
# ------------------------------------------------------------------
# IAM Role: SSM Core + ECR (KiroCrew image pull)
# ------------------------------------------------------------------
KiroCrewEC2Role:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub "${ProjectName}-ec2-role-${DeployDate}"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
# Instance management via SSM
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
Tags:
- Key: Project
Value: !Ref ProjectName
- Key: ManagedBy
Value: CloudFormation
KiroCrewEC2InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
InstanceProfileName: !Sub "${ProjectName}-ec2-profile-${DeployDate}"
Roles:
- !Ref KiroCrewEC2Role
# ------------------------------------------------------------------
# EC2 Instance
# ------------------------------------------------------------------
KiroCrewEC2Instance:
Type: AWS::EC2::Instance
DependsOn:
- KiroCrewVPCGatewayAttachment
- KiroCrewDefaultRoute
Properties:
InstanceType: !Ref InstanceType
ImageId: !Ref AmiId
SubnetId: !Ref KiroCrewSubnet
IamInstanceProfile: !Ref KiroCrewEC2InstanceProfile
SecurityGroupIds:
- !Ref KiroCrewSecurityGroup
# IMDSv2 required: HopLimit=2 (metadata accessible from within containers)
MetadataOptions:
HttpTokens: required
HttpPutResponseHopLimit: 2
HttpEndpoint: enabled
# Root volume: encrypted gp3
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeType: gp3
VolumeSize: !Ref RootVolumeSize
Encrypted: true
DeleteOnTermination: true
# UserData embeds bootstrap.sh base64-encoded
UserData:
Fn::Base64: !Sub |
#!/bin/bash
# Kiro Crew EC2 ARM64 bootstrap: SSM + Docker + Tailscale + KiroCrew
# CFN Stack: ${AWS::StackName} / Region: ${AWS::Region}
# Date: ${DeployDate}
#
# Human steps (NOT automated here):
# 1. sudo tailscale up
# 2. sudo docker compose exec kirocrew kiro-cli login --use-device-flow
# 3. sudo docker compose exec kirocrew kirocrew setup
# 4. sudo tailscale serve --bg --yes http://127.0.0.1:5478
# -> See runbooks/human-operations-runbook.md for full details
set -euo pipefail
LOG=/var/log/kirocrew-bootstrap.log
exec > >(tee -a "$LOG") 2>&1
echo "===== Kiro Crew bootstrap started: $(date -Is) ====="
echo "Stack: ${AWS::StackName}"
echo "Region: ${AWS::Region}"
# Enable SSM Agent on AL2023
systemctl enable --now amazon-ssm-agent || true
# Create ssm-user + grant sudo privileges
useradd -m -s /bin/bash ssm-user 2>/dev/null || true
echo 'ssm-user ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/ssm-agent-users
chmod 440 /etc/sudoers.d/ssm-agent-users
# Docker
dnf install -y docker
systemctl enable --now docker
usermod -aG docker ssm-user || true
# Docker Compose ARM64 plugin (pinned v5.4.0)
COMPOSE_VERSION=v5.4.0
install -d -m 0755 /usr/local/lib/docker/cli-plugins
curl --fail --silent --show-error --location \
"https://github.com/docker/compose/releases/download/${!COMPOSE_VERSION}/docker-compose-linux-aarch64" \
--output /usr/local/lib/docker/cli-plugins/docker-compose
chmod 0755 /usr/local/lib/docker/cli-plugins/docker-compose
# swapfile (memory supplement 1GiB)
if ! swapon --show --noheadings | grep -q . && [ ! -e /swapfile ]; then
fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
# Tailscale (authentication performed by human)
curl -fsSL https://tailscale.com/install.sh | sh
systemctl enable --now tailscaled
# KiroCrew working directory
install -d -m 0755 /opt/kirocrew
# HTTP access log: dedicated logger that does not record query strings or credentials
cat > /opt/kirocrew/kirocrew-safe-access-log-sitecustomize.py <<'PY'
"""Safe query-free aiohttp access logging for Kiro Crew gateway."""
from __future__ import annotations
import logging, os
from logging.handlers import RotatingFileHandler
try:
from aiohttp import web_log
from aiohttp.web_runner import AppRunner
class SafeAccessLogger(web_log.AbstractAccessLogger):
def log(self, request, response, time):
path = getattr(request, "path", "<unknown>")
if not isinstance(path, str): path = "<unknown>"
self.logger.info("http_request method=%s path=%s status=%s duration_ms=%.1f",
getattr(request,"method","<unknown>"), path[:512],
getattr(response,"status","<unknown>"), time*1000.0)
_orig = AppRunner.__init__
def _safe_init(self, app, *, handle_signals=False, access_log_class=SafeAccessLogger, **kw):
return _orig(self, app, handle_signals=handle_signals, access_log_class=access_log_class, **kw)
if not getattr(AppRunner,"_kirocrew_safe_access_logging",False):
AppRunner.__init__ = _safe_init
AppRunner._kirocrew_safe_access_logging = True
al = logging.getLogger("aiohttp.access")
al.setLevel(logging.INFO); al.propagate = True
lp = os.environ.get("KIROCREW_ACCESS_LOG_FILE","/home/kirocrew/access.log")
if not any(getattr(h,"_kirocrew_safe_access",False) for h in al.handlers):
h = RotatingFileHandler(lp, maxBytes=2*1024*1024, backupCount=3, encoding="utf-8")
h.setLevel(logging.INFO)
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s",datefmt="%Y-%m-%dT%H:%M:%S%z"))
h._kirocrew_safe_access = True; al.addHandler(h)
try: os.chmod(lp, 0o600)
except OSError: pass
except Exception: pass
PY
chmod 0644 /opt/kirocrew/kirocrew-safe-access-log-sitecustomize.py
# Docker Compose file
cat > /opt/kirocrew/compose.yaml <<'COMPOSE'
name: kirocrew-ec2
services:
kirocrew:
image: ghcr.io/kirodotdev/kirocrew:stable
container_name: kirocrew-ec2-gateway
restart: unless-stopped
ports:
- "127.0.0.1:${!KIROCREW_HOST_PORT:?Set KIROCREW_HOST_PORT}:5476"
volumes:
- kirocrew-ec2-home:/home/kirocrew
- ./kirocrew-safe-access-log-sitecustomize.py:/opt/kirocrew/patches/sitecustomize.py:ro
environment:
PYTHONPATH: /opt/kirocrew/patches
KIROCREW_ACCESS_LOG_FILE: /home/kirocrew/access.log
KIROCREW_ALLOW_UNSANDBOXED: ${!KIROCREW_ALLOW_UNSANDBOXED:?Set KIROCREW_ALLOW_UNSANDBOXED=1 after operator approval}
volumes:
kirocrew-ec2-home:
name: ${!KIROCREW_VOLUME_NAME:?Set KIROCREW_VOLUME_NAME}
COMPOSE
# Environment variable file
VOLUME_SUFFIX=$(date +%Y%m%d)
cat > /opt/kirocrew/.env <<ENV
KIROCREW_HOST_PORT=5478
KIROCREW_VOLUME_NAME=kirocrew-ec2-home-cfn-${!VOLUME_SUFFIX}
KIROCREW_ALLOW_UNSANDBOXED=1
ENV
chmod 600 /opt/kirocrew/.env
# Start container
cd /opt/kirocrew
docker compose config --quiet
docker compose pull
docker compose up -d --force-recreate
# Health check (up to 120 seconds)
for attempt in $(seq 1 60); do
if curl --fail --silent http://127.0.0.1:5478/api/health >/dev/null 2>&1; then
echo "Kiro Crew health: OK (attempt=${!attempt})"
break
fi
if [ "${!attempt}" -eq 60 ]; then
echo "ERROR: health check failed"
docker compose logs --tail=100 kirocrew || true
exit 1
fi
sleep 2
done
# Sandbox configuration
docker compose exec -T kirocrew kirocrew config set \
agent.sandbox_allow_unsandboxed_exec true || true
echo "--- versions ---"
docker --version
docker compose version
tailscale version
echo "--- container status ---"
docker compose ps
echo "===== bootstrap completed: $(date -Is) ====="
Tags:
- Key: Name
Value: !Sub "${ProjectName}-ec2-${DeployDate}"
- Key: Project
Value: !Ref ProjectName
- Key: ManagedBy
Value: CloudFormation
- Key: SandboxPosture
Value: container-only
- Key: BootstrapDate
Value: !Ref DeployDate
# ====================================================================
# Outputs
# ====================================================================
Outputs:
InstanceId:
Description: EC2 Instance ID
Value: !Ref KiroCrewEC2Instance
Export:
Name: !Sub "${AWS::StackName}-InstanceId"
VpcId:
Description: VPC ID
Value: !Ref KiroCrewVPC
Export:
Name: !Sub "${AWS::StackName}-VpcId"
SubnetId:
Description: Subnet ID
Value: !Ref KiroCrewSubnet
Export:
Name: !Sub "${AWS::StackName}-SubnetId"
SecurityGroupId:
Description: Security Group ID
Value: !Ref KiroCrewSecurityGroup
Export:
Name: !Sub "${AWS::StackName}-SecurityGroupId"
IAMRoleArn:
Description: IAM Role ARN
Value: !GetAtt KiroCrewEC2Role.Arn
SSMConnectCommand:
Description: SSM Session Manager connect command
Value: !Sub "aws ssm start-session --target ${KiroCrewEC2Instance} --region ${AWS::Region}"
BootstrapLogCommand:
Description: Bootstrap log tail command via SSM
Value: !Sub "aws ssm send-command --instance-ids ${KiroCrewEC2Instance} --document-name AWS-RunShellScript --parameters commands='tail -100 /var/log/kirocrew-bootstrap.log' --region ${AWS::Region}"
Docker Compose File
This is the Compose definition placed at /opt/kirocrew/compose.yaml by UserData. Compose uses ghcr.io/kirodotdev/kirocrew:stable, and the host-side publish destination is restricted to loopback.
compose.yaml
name: kirocrew-ec2
services:
kirocrew:
image: ghcr.io/kirodotdev/kirocrew:stable
container_name: kirocrew-ec2-gateway
restart: unless-stopped
ports:
- "127.0.0.1:${KIROCREW_HOST_PORT:?Set KIROCREW_HOST_PORT}:5476"
volumes:
- kirocrew-ec2-home:/home/kirocrew
- ./kirocrew-safe-access-log-sitecustomize.py:/opt/kirocrew/patches/sitecustomize.py:ro
environment:
PYTHONPATH: /opt/kirocrew/patches
KIROCREW_ACCESS_LOG_FILE: /home/kirocrew/access.log
# Sandbox must be disabled on AL2023/Docker runtime. The container is the sole isolation boundary.
KIROCREW_ALLOW_UNSANDBOXED: ${KIROCREW_ALLOW_UNSANDBOXED:?Set KIROCREW_ALLOW_UNSANDBOXED=1 after operator approval}
volumes:
kirocrew-ec2-home:
name: ${KIROCREW_VOLUME_NAME:?Set KIROCREW_VOLUME_NAME}
UserData bootstrap
This is the full content of userdata/bootstrap.sh executed during initial EC2 setup.
userdata/bootstrap.sh (full content)
#!/bin/bash
# Kiro Crew EC2 ARM64 bootstrap: SSM + Docker + Tailscale + KiroCrew
# 2026-08-06 ap-northeast-1 CloudFormation UserData
#
# Steps performed by humans (not performed in UserData):
# - sudo tailscale up # Tailscale authentication
# - sudo docker compose exec kirocrew kiro-cli login --use-device-flow # KiroCLI authentication
# - sudo docker compose exec kirocrew kirocrew setup # KiroCrew initial setup
# - sudo tailscale serve --bg --yes http://127.0.0.1:5478 # Tailscale Serve configuration
# - Runbook: see runbooks/human-operations-runbook.md
#
# Security posture:
# - IMDSv2 required (MetadataOptions: HttpTokens=required)
# - SSM-only management access (no inbound ports required)
# - Docker publishes to loopback only (127.0.0.1:5478)
# - Tailscale Serve publishes to tailnet only (funnel not used)
set -euo pipefail
LOG=/var/log/kirocrew-bootstrap.log
exec > >(tee -a "$LOG") 2>&1
echo "===== Kiro Crew bootstrap started: $(date -Is) ====="
# AL2023 includes SSM Agent. Verify it is running.
systemctl enable --now amazon-ssm-agent || true
# Create ssm-user, add to Docker group, and grant sudo privileges
useradd -m -s /bin/bash ssm-user 2>/dev/null || true
echo 'ssm-user ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/ssm-agent-users
chmod 440 /etc/sudoers.d/ssm-agent-users
# Install Docker. AL2023 uses dnf.
dnf install -y docker
systemctl enable --now docker
usermod -aG docker ssm-user || true
# Docker Compose ARM64 plugin (fixed version)
COMPOSE_VERSION=v5.4.0
install -d -m 0755 /usr/local/lib/docker/cli-plugins
curl --fail --silent --show-error --location \
"https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-aarch64" \
--output /usr/local/lib/docker/cli-plugins/docker-compose
chmod 0755 /usr/local/lib/docker/cli-plugins/docker-compose
# swapfile (memory supplement for 4GiB instances)
if ! swapon --show --noheadings | grep -q . && [ ! -e /swapfile ]; then
fallocate -l 1G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
# Install Tailscale, but authentication is performed by humans
curl -fsSL https://tailscale.com/install.sh | sh
systemctl enable --now tailscaled
# Create KiroCrew working directory
install -d -m 0755 /opt/kirocrew
# HTTP access log: dedicated logger that does not record query strings or authentication information
cat > /opt/kirocrew/kirocrew-safe-access-log-sitecustomize.py <<'PY'
"""Safe query-free aiohttp access logging for the Kiro Crew gateway.
Loaded via PYTHONPATH/sitecustomize.py.
Recorded fields: HTTP method, path (no query), status, duration_ms only.
query, header, cookie, authorization, IP, and body are not recorded.
"""
from __future__ import annotations
import logging
import os
from logging.handlers import RotatingFileHandler
try:
from aiohttp import web_log
from aiohttp.web_runner import AppRunner
class SafeAccessLogger(web_log.AbstractAccessLogger):
def log(self, request, response, time):
path = getattr(request, "path", "<unknown>")
if not isinstance(path, str):
path = "<unknown>"
self.logger.info(
"http_request method=%s path=%s status=%s duration_ms=%.1f",
getattr(request, "method", "<unknown>"),
path[:512],
getattr(response, "status", "<unknown>"),
time * 1000.0,
)
_original_init = AppRunner.__init__
def _safe_app_runner_init(self, app, *, handle_signals=False, access_log_class=SafeAccessLogger, **kwargs):
return _original_init(self, app, handle_signals=handle_signals, access_log_class=access_log_class, **kwargs)
if not getattr(AppRunner, "_kirocrew_safe_access_logging", False):
AppRunner.__init__ = _safe_app_runner_init
AppRunner._kirocrew_safe_access_logging = True
access_logger = logging.getLogger("aiohttp.access")
access_logger.setLevel(logging.INFO)
access_logger.propagate = True
log_path = os.environ.get("KIROCREW_ACCESS_LOG_FILE", "/home/kirocrew/access.log")
if not any(getattr(h, "_kirocrew_safe_access", False) for h in access_logger.handlers):
handler = RotatingFileHandler(log_path, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8")
handler.setLevel(logging.INFO)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s", datefmt="%Y-%m-%dT%H:%M:%S%z"))
handler._kirocrew_safe_access = True
access_logger.addHandler(handler)
try:
os.chmod(log_path, 0o600)
except OSError:
pass
except Exception:
pass
PY
chmod 0644 /opt/kirocrew/kirocrew-safe-access-log-sitecustomize.py
# Docker Compose file
cat > /opt/kirocrew/compose.yaml <<'COMPOSE'
name: kirocrew-ec2
services:
kirocrew:
image: ghcr.io/kirodotdev/kirocrew:stable
container_name: kirocrew-ec2-gateway
restart: unless-stopped
ports:
- "127.0.0.1:${KIROCREW_HOST_PORT:?Set KIROCREW_HOST_PORT}:5476"
volumes:
- kirocrew-ec2-home:/home/kirocrew
- ./kirocrew-safe-access-log-sitecustomize.py:/opt/kirocrew/patches/sitecustomize.py:ro
environment:
PYTHONPATH: /opt/kirocrew/patches
KIROCREW_ACCESS_LOG_FILE: /home/kirocrew/access.log
# Sandbox must be disabled on AL2023/Docker runtime. The container is the sole isolation boundary.
KIROCREW_ALLOW_UNSANDBOXED: ${KIROCREW_ALLOW_UNSANDBOXED:?Set KIROCREW_ALLOW_UNSANDBOXED=1 after operator approval}
volumes:
kirocrew-ec2-home:
name: ${KIROCREW_VOLUME_NAME:?Set KIROCREW_VOLUME_NAME}
COMPOSE
# Environment variables file (mode 600)
VOLUME_SUFFIX=$(date +%Y%m%d)
cat > /opt/kirocrew/.env <<ENV
KIROCREW_HOST_PORT=5478
KIROCREW_VOLUME_NAME=kirocrew-ec2-home-cfn-${VOLUME_SUFFIX}
# Required to run Kiro CLI and agent on AL2023/Docker runtime.
# Operator has explicitly approved that the container serves as the sole isolation boundary.
KIROCREW_ALLOW_UNSANDBOXED=1
ENV
chmod 600 /opt/kirocrew/.env
# Start container
cd /opt/kirocrew
docker compose config --quiet
docker compose pull
docker compose up -d --force-recreate
# Health check (wait up to 120 seconds)
for attempt in $(seq 1 60); do
if curl --fail --silent http://127.0.0.1:5478/api/health >/dev/null 2>&1; then
echo "Kiro Crew health: OK (attempt=${attempt})"
break
fi
if [ "$attempt" -eq 60 ]; then
echo "ERROR: Kiro Crew health check failed after 120s"
docker compose logs --tail=100 kirocrew || true
exit 1
fi
sleep 2
done
# Sandbox configuration
docker compose exec -T kirocrew kirocrew config set \
agent.sandbox_allow_unsandboxed_exec true || true
# Version confirmation log
echo "--- versions ---"
docker --version
docker compose version
tailscale version
echo "--- container status ---"
docker compose ps
printf '%s\n' '====================================='
printf '%s\n' 'Bootstrap completed. Human steps:'
printf '%s\n' ' 1. sudo tailscale up'
printf '%s\n' ' 2. sudo tailscale status'
printf '%s\n' ' 3. cd /opt/kirocrew && sudo docker compose exec kirocrew kiro-cli login --use-device-flow'
printf '%s\n' ' 4. sudo docker compose exec kirocrew kirocrew setup'
printf '%s\n' ' 5. sudo tailscale serve --bg --yes http://127.0.0.1:5478'
printf '%s\n' ' 6. See runbooks/human-operations-runbook.md for full details'
printf '%s\n' '====================================='
echo "===== Kiro Crew bootstrap completed: $(date -Is) ====="
SSM Manual Operations Runbook
These are the steps after starting an SSM session: authentication, initial setup, Tailscale Serve configuration, dashboard token retrieval, connection verification, and access log review. Specific Tailscale ACL examples are not included in the scope of this verification.
human-operations-runbook.md
# Kiro Crew EC2 - Human Operations Runbook
These are the steps that humans perform via SSM sessions on EC2 instances deployed with CloudFormation.
## Target Environment
| Item | Value |
|---|---|
| Region | ap-northeast-1 |
| Stack name | `kirocrew-cfn-20260806` (as specified at deployment) |
| Docker host port | `127.0.0.1:5478` |
| Compose directory | `/opt/kirocrew` |
| Bootstrap log | `/var/log/kirocrew-bootstrap.log` |
> **Note**: Do not save authentication URLs, device codes, Tailscale tokens, or dashboard tokens in this Runbook, chat, or Git.
---
## Pre-check: Bootstrap Completion and SSM Online
After CFn deployment, wait 2–5 minutes for the EC2 instance to come online in SSM.
```bash
# Obtain InstanceId from CFn stack Outputs
STACK_NAME=kirocrew-cfn-20260806
REGION=ap-northeast-1
INSTANCE_ID=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--region "$REGION" \
--query 'Stacks[0].Outputs[?OutputKey==`InstanceId`].OutputValue' \
--output text)
echo "InstanceId: $INSTANCE_ID"
# Verify SSM Online (repeat until Online)
aws ssm describe-instance-information \
--region "$REGION" \
--filters "Key=InstanceIds,Values=$INSTANCE_ID" \
--query 'InstanceInformationList[0].{Id:InstanceId,PingStatus:PingStatus,PlatformType:PlatformType}' \
--output table
```
Open an SSM session.
```bash
aws ssm start-session --target "$INSTANCE_ID" --region "$REGION"
```
Verify that bootstrap is complete (last line of UserData).
```bash
sudo tail -30 /var/log/kirocrew-bootstrap.log
```
Expected tail output:
```
===== bootstrap completed: 2026-08-06T...
```
Verify the container and health.
```bash
cd /opt/kirocrew
sudo docker compose ps
curl --fail --silent http://127.0.0.1:5478/api/health
```
Expected value: `{"ok": true}`
---
## Step 1: Tailscale Authentication
### 1-1. Tailscale up (display authentication URL)
Run inside the SSM session.
```bash
sudo tailscale up
```
Open the displayed authentication URL in a browser and complete login.
> Do not save the authentication URL in this Runbook, chat, or Git.
### 1-2. Verify Tailscale status
```bash
sudo tailscale status
sudo tailscale version
```
Verify that `Connected` is displayed.
---
## Step 2: Kiro CLI License Authentication
### 2-1. Device-flow authentication
Run inside the SSM session.
```bash
cd /opt/kirocrew
sudo docker compose exec kirocrew kiro-cli login --use-device-flow
```
Use the displayed URL and device code to log in via browser.
> Do not save the authentication URL or device code in this Runbook, chat, or Git.
### 2-2. Verify authentication
```bash
sudo docker compose exec -T kirocrew kiro-cli whoami 2>/dev/null || \
sudo docker compose exec -T kirocrew kiro-cli status 2>/dev/null || true
```
---
## Step 3: Kiro Crew Initial Setup
### 3-1. kirocrew setup
Run inside the SSM session (interactive operation required).
```bash
sudo docker compose exec kirocrew kirocrew setup
```
Recommended settings:
| Item | Recommended Value |
|---|---|
| Workspace | Default (press Enter as-is) |
| Slack | Skip (no) |
| Slash command | `kirocrew` |
| Timezone | `Asia/Tokyo` |
| Playwright MCP | Install (yes) |
| AWS cloud launch | Skip (no) |
### 3-2. Kiro Crew doctor
```bash
sudo docker compose exec -T kirocrew kirocrew doctor
sudo docker compose ps
```
---
## Step 4: Tailscale Serve Configuration
### 4-1. Start Serve (tailnet only)
```bash
sudo tailscale serve --bg --yes "http://127.0.0.1:5478"
```
### 4-2. Get Serve URL
```bash
sudo tailscale serve status
```
Example of expected value (hostname varies per environment):
```
https://<TAILSCALE_HOSTNAME> (tailnet only)
|-- / proxy http://127.0.0.1:5478
```
Verify that `tailnet only` is displayed. Do not use `funnel`.
### 4-3. Set Serve URL as a variable
```bash
SERVE_URL="$(sudo tailscale serve status | awk '$1 ~ /^https:\/\// {print $1; exit}')"
case "$SERVE_URL" in
https://*) echo "Serve URL: $SERVE_URL" ;;
*) echo "ERROR: Tailscale Serve URL not found" >&2; exit 1 ;;
esac
```
---
## Step 5: Register Serve URL with Kiro Crew
```bash
cd /opt/kirocrew
sudo docker compose exec -T kirocrew kirocrew config set \
dashboard.url "$SERVE_URL"
sudo docker compose restart kirocrew
```
Verify the configuration.
```bash
sudo docker compose exec -T kirocrew kirocrew config get dashboard.url
sudo docker compose ps
curl --fail --silent http://127.0.0.1:5478/api/health
```
---
## Step 6: Retrieve Dashboard Token
### 6-1. Issue a short-lived token
```bash
cd /opt/kirocrew
TOKEN=$(sudo docker compose exec -T kirocrew kirocrew token --ttl 2h)
```
### 6-2. Temporarily save the browser connection URL to a file
> Do not output the token to chat, logs, or Git.
```bash
# Temporarily save to EC2 local only (mode 600)
echo "$TOKEN" | sudo tee /run/kirocrew-dashboard-token > /dev/null
sudo chmod 600 /run/kirocrew-dashboard-token
echo "Token saved to /run/kirocrew-dashboard-token (mode 600)"
echo "URL pattern: ${SERVE_URL}?token=<token>"
```
### 6-3. Read, use, and delete the token
Run inside the SSM session.
```bash
# Read (do not copy the token value into this Runbook)
sudo cat /run/kirocrew-dashboard-token
# Delete immediately after browser access
sudo rm -f /run/kirocrew-dashboard-token
```
---
## Step 7: Connection Verification
### 7-1. On the EC2 host
```bash
curl --fail --silent "http://127.0.0.1:5478/api/health"
sudo docker compose ps
sudo tailscale serve status
```
### 7-2. From another device connected to Tailscale
Run from a Mac or similar device connected to the tailnet (no SSH tunnel required).
```bash
SERVE_URL="<URL confirmed via tailscale serve status>"
curl --connect-timeout 5 --max-time 10 --fail "${SERVE_URL}/api/health"
```
Expected value: `{"ok": true}`
### 7-3. Browser access
Open the following in a browser on a device connected to the tailnet.
```
https://<TAILSCALE_HOSTNAME>?token=<token>
```
---
## Step 8: Access Log Verification
```bash
cd /opt/kirocrew
# Verify file permissions
sudo docker compose exec -T kirocrew stat -c '%a %n' /home/kirocrew/access.log
# Check the last 50 lines (records method, path, status, duration_ms only)
sudo docker compose exec -T kirocrew sh -c 'tail -n 50 /home/kirocrew/access.log'
```
The access log records only method, path (without query), HTTP status, and duration_ms.
Query strings, headers, cookies, authorization, IP, and body are not recorded.
---
## Troubleshooting
### `Host header not allowed.`
```bash
cd /opt/kirocrew
SERVE_URL="$(sudo tailscale serve status | awk '$1 ~ /^https:\/\// {print $1; exit}')"
sudo docker compose exec -T kirocrew kirocrew config set dashboard.url "$SERVE_URL"
sudo docker compose restart kirocrew
curl --fail --silent "http://127.0.0.1:5478/api/health"
```
### Bootstrap not complete
```bash
sudo tail -50 /var/log/kirocrew-bootstrap.log
sudo systemctl status docker
sudo docker compose -f /opt/kirocrew/compose.yaml ps
```
### Container fails to start
```bash
cd /opt/kirocrew
cat .env # Does not contain secret values
sudo docker compose logs --tail=50 kirocrew
sudo docker compose up -d --force-recreate
```
---
## Stop / Rollback
To stop only Tailscale Serve:
```bash
sudo tailscale serve --https=443 off
```
To stop Tailscale Serve and revert to localhost only:
```bash
cd /opt/kirocrew
sudo tailscale serve --https=443 off
sudo docker compose exec -T kirocrew kirocrew config set \
dashboard.url "http://localhost:5478"
sudo docker compose restart kirocrew
```
---
## Security Notes
- Use Tailscale Serve with `tailnet only`; do not use `tailscale funnel`
- Do not change Docker port binding to `0.0.0.0:5478` (loopback only)
- Restrict accessible users and devices via Tailscale ACL
- Do not share or save short-lived Kiro Crew dashboard tokens
- `KIROCREW_ALLOW_UNSANDBOXED=1` means the container is the sole isolation boundary. Operate only after an operator has approved the risk of storing credentials inside the container
- Disconnect SSM sessions with `exit` after completing operations
