I tried running the KiroCrew official Docker image on EC2 and testing connection via Tailscale

I tried running the KiroCrew official Docker image on EC2 and testing connection via Tailscale

Last time I tried a configuration where KiroCrew, which had been installed directly on EC2, was operated with Docker containers as isolation boundaries and made accessible from within the tailnet via Tailscale Serve. I also verified the attended setup via SSM, the named volume configuration, and the items recorded in the access logs.
2026.08.06

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.

https://dev.classmethod.jp/articles/kiro-crew-remote-on-ec2/

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.

https://tailscale.com/docs/features/tailscale-serve

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

Share this article

AWSのお困り事はクラスメソッドへ