EC2: demo & walkthrough
Launch real Docker containers as EC2 instances. SSH in, run user data scripts, manage instance lifecycle. No AWS account needed, no VMs, just containers that behave like EC2 instances.
Looking for the API surface and CLI examples? See EC2: API reference.
t2.micro
Ubuntu 22.04 instance with a user-data script that writes
/tmp/hello.txt at boot, waits for the
instance to reach running, SSHes in with
the key pair to read the user-data output back, opens a real
aws ssm start-session shell, pushes a
60-second ephemeral key with EC2 Instance Connect, tunnels a local port in with
AWS-StartPortForwardingSession, then
exercises the lifecycle:
stop-instances ->
start-instances ->
terminate-instances, verifying the Docker
container state at each step. Instances boot from the pre-baked
localemu/ec2-base image with
openssh-server already installed, so
sshd is reachable in ~1 second.
Requires EC2_VM_MANAGER=docker on the
LocalEmu host.
Source: 15-ec2-demo/ in the examples repo.
Real SSH Access
SSH into instances with real key pairs. Use standard ssh or the localemu ssh shortcut.
SSM Sessions
Interactive shell via the real aws ssm start-session, plus AWS-StartPortForwardingSession tunneling.
User Data Scripts
Bootstrap scripts run at boot. Install packages, write files, configure services.
Security Groups
Create and attach security groups with ingress rules. Full API compatibility.
IMDS Metadata
Instance Metadata Service available inside containers. Query instance identity like real EC2.
Step-by-Step Walkthrough
Step 1: Start LocalEmu with EC2 Docker backend
EC2_VM_MANAGER=docker localemu start The EC2_VM_MANAGER=docker flag tells LocalEmu to create real Docker containers when you launch EC2 instances.
Step 2: Create an SSH key pair
$ awsemu ec2 create-key-pair --key-name my-key \
--query KeyMaterial --output text > /tmp/my-key.pem
$ chmod 400 /tmp/my-key.pem LocalEmu generates a real RSA key pair. The private key is saved to a file, and the public key is injected into instances that reference this key name.
Step 3: Create a security group with SSH access
$ SG_ID=$(awsemu ec2 create-security-group \
--group-name ssh-open \
--description "SSH open" \
--query 'GroupId' --output text)
$ echo $SG_ID
sg-df9813973ad922ec6
$ awsemu ec2 authorize-security-group-ingress \
--group-id $SG_ID --protocol tcp --port 22 --cidr 0.0.0.0/0
Return: true
SecurityGroupRules:
- SecurityGroupRuleId: sgr-ee7274d05c857c4c3
GroupId: sg-df9813973ad922ec6
IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIpv4: 0.0.0.0/0 Security groups are created through the standard EC2 API. Ingress rules control which ports are accessible.
Step 4: Launch an instance with key pair, security group, and user data
$ awsemu ec2 run-instances \
--image-id ami-ubuntu-22.04 \
--instance-type t2.micro \
--count 1 \
--key-name my-key \
--security-group-ids $SG_ID \
--user-data '#!/bin/bash
echo "Hello from LocalEmu EC2" > /tmp/hello.txt
date > /tmp/boot-time.txt'
InstanceId: i-345e9b5f68e616e47
ImageId: ami-ubuntu-22.04
InstanceType: t2.micro
State: pending
KeyName: my-key
SecurityGroups:
- GroupId: sg-df9813973ad922ec6
GroupName: ssh-open
PrivateIpAddress: 10.43.9.71 LocalEmu boots the localemu/ec2-base:v4 pre-baked image (Ubuntu + ubuntu user + openssh-server + awscli + tooling), starts a container, injects the SSH public key into both ubuntu's and root's authorized_keys, and runs the user data script.
Step 5: Describe the running instance
$ awsemu ec2 describe-instances --instance-ids i-345e9b5f68e616e47
InstanceId: i-345e9b5f68e616e47
State: running
PrivateIpAddress: 10.43.9.71
KeyName: my-key
InstanceType: t2.micro
ImageId: ami-ubuntu-22.04
AvailabilityZone: us-east-1a This instance has no public-facing address, so PublicIpAddress and PublicDnsName are both omitted, matching the real AWS contract. PrivateIpAddress is what you connect to (see Step 11).
Step 6: Verify the Docker container is running
$ docker ps --filter "label=localemu.service=ec2"
CONTAINER ID IMAGE COMMAND STATUS NAMES
ee1fb33a5530 localemu/ec2-base:v4 "sh -c '#!/bin/sh\nse..." Up 45 seconds localemu-ec2-i-345e9b5f68e616e47
# No published host ports -- the container gets a real IP on the
# VPC bridge instead of a Docker port mapping. Step 7: List SSH-accessible instances
$ localemu ssh --list
NAMES STATUS
localemu-ec2-i-345e9b5f68e616e47 Up About a minute Step 8: Verify user data ran successfully
$ localemu ssh i-345e9b5f68e616e47 cat /tmp/hello.txt
Hello from LocalEmu EC2 The user data script wrote "Hello from LocalEmu EC2" to /tmp/hello.txt at boot time. The localemu ssh command connects to the instance by ID without needing to know the port.
Step 9: Check the OS inside the instance
$ localemu ssh i-345e9b5f68e616e47 cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.5 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.5 LTS (Jammy Jellyfish)"
VERSION_CODENAME=jammy
ID=ubuntu
ID_LIKE=debian Ubuntu 22.04.5 LTS running inside the container. The OS image is the upstream ubuntu:22.04 with openssh-server, iptables, awscli and basic net tooling baked in by localemu/ec2-base.
Step 10: Start an interactive SSM session
$ aws ssm start-session --target i-345e9b5f68e616e47
Starting session with SessionId: ...
root@i-345e9b5f68e616e47:/# ls
bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
root@i-345e9b5f68e616e47:/# exit
exit SSM start-session drops you into a root shell inside the container. Full interactive access, just like real AWS SSM.
Step 11: Connect with standard SSH using your key pair
# LocalEmu does not open host-to-VPC-private-IP reachability on macOS
# by design, so SSH has to come from a container on the same VPC
# network -- the "bastion pattern":
$ docker run -d --name bastion --network $(docker inspect \
--format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' \
localemu-ec2-i-345e9b5f68e616e47 | grep localemu-vpc) \
--entrypoint sh localemu/ec2-base:v4 -c 'sleep 3600'
$ docker cp /tmp/my-key.pem bastion:/tmp/my-key.pem
$ docker exec bastion sh -c \
"chmod 400 /tmp/my-key.pem && ssh -i /tmp/my-key.pem ubuntu@10.43.9.71 whoami"
ubuntu Standard OpenSSH works, using the instance's PrivateIpAddress from a container on the same VPC network (see EC2: API reference for why the host itself can't reach it directly on macOS).
Step 12: Push a 60-second ephemeral SSH key with EC2 Instance Connect
$ ssh-keygen -t ed25519 -f /tmp/eic-key -N ""
$ aws ec2-instance-connect send-ssh-public-key \
--instance-id i-345e9b5f68e616e47 \
--instance-os-user ubuntu \
--ssh-public-key file:///tmp/eic-key.pub
{"RequestId": "...", "Success": true}
# valid for 60 seconds, from a container on the same VPC network:
$ ssh -i /tmp/eic-key ubuntu@10.43.9.71 whoami
ubuntu send-ssh-public-key writes the key into authorized_keys immediately, and it's automatically removed 60 seconds later, matching AWS's own TTL exactly.
Step 13: Tunnel a local port into the instance with SSM port forwarding
$ aws ssm start-session --target i-345e9b5f68e616e47 \
--document-name AWS-StartPortForwardingSession \
--parameters 'portNumber=8012,localPortNumber=18012'
Starting session with SessionId: ...
Port 18012 opened for sessionId ...
Waiting for connections...
# from another terminal, once something is listening on :8012 inside the instance:
$ curl http://127.0.0.1:18012/ LocalEmu speaks the same SMUX v1 framing the real session-manager-plugin uses to multiplex a local TCP port over the SSM WebSocket channel, so the real aws CLI does the tunneling, no LocalEmu-specific client.
Step 14: Stop and restart the instance
$ awsemu ec2 stop-instances --instance-ids i-345e9b5f68e616e47
InstanceId: i-345e9b5f68e616e47
CurrentState: stopping
PreviousState: running
$ docker ps --filter "label=localemu.service=ec2"
CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES
(empty - container stopped)
$ awsemu ec2 start-instances --instance-ids i-345e9b5f68e616e47
InstanceId: i-345e9b5f68e616e47
CurrentState: pending
PreviousState: stopped
$ docker ps --filter "label=localemu.service=ec2"
CONTAINER ID IMAGE COMMAND STATUS NAMES
ee1fb33a5530 localemu/ec2-base:v4 "sh -c '#!/bin/sh\nse..." Up 13 seconds localemu-ec2-i-345e9b5f68e616e47 Stopping an instance stops the Docker container. Starting it again brings the same container back. Full lifecycle management.
Step 15: Terminate and cleanup
$ awsemu ec2 terminate-instances --instance-ids i-345e9b5f68e616e47
InstanceId: i-345e9b5f68e616e47
CurrentState: shutting-down
PreviousState: running
$ docker ps --filter "label=localemu.service=ec2"
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
(empty - container removed)
$ localemu stop
LocalEmu stopped. Terminating removes the Docker container completely. No orphaned containers, no leftover resources.
Python Integration
Use boto3 to drive the same demo programmatically. The script reads AWS_ENDPOINT_URL from the environment; unset it (or point at real AWS) and the identical code runs against the real EC2 API. Credentials come from the standard boto3 chain, no hardcoded secrets in source.
import os
import boto3
# Point boto3 at LocalEmu via the standard AWS_ENDPOINT_URL env var,
# or set endpoint_url here directly. Credentials come from the usual
# boto3 resolution chain (env vars, shared config, instance profile, etc).
ec2 = boto3.client(
"ec2",
endpoint_url=os.environ.get("AWS_ENDPOINT_URL", "http://localhost:4566"),
region_name="us-east-1",
)
# Create key pair
key = ec2.create_key_pair(KeyName="my-key")
with open("/tmp/my-key.pem", "w") as f:
f.write(key["KeyMaterial"])
# Create security group with SSH access
sg = ec2.create_security_group(
GroupName="ssh-access",
Description="Allow SSH",
)
ec2.authorize_security_group_ingress(
GroupId=sg["GroupId"],
IpProtocol="tcp",
FromPort=22,
ToPort=22,
CidrIp="0.0.0.0/0",
)
# Launch instance with user data
user_data = '''#!/bin/bash
echo "Hello from LocalEmu EC2" > /tmp/hello.txt'''
response = ec2.run_instances(
ImageId="ami-ubuntu-22.04",
InstanceType="t2.micro",
MinCount=1, MaxCount=1,
KeyName="my-key",
SecurityGroupIds=[sg["GroupId"]],
UserData=user_data,
)
instance_id = response["Instances"][0]["InstanceId"]
print(f"Launched: {instance_id}")
# Wait for running state
ec2.get_waiter("instance_running").wait(InstanceIds=[instance_id])
# Read the private IP -- reachable from other containers on the same
# VPC network, not from the host directly (see the bastion-pattern
# example on this page for why)
desc = ec2.describe_instances(InstanceIds=[instance_id])
private_ip = desc["Reservations"][0]["Instances"][0]["PrivateIpAddress"]
print(f"PrivateIpAddress: {private_ip}")
# Terminate when done
ec2.terminate_instances(InstanceIds=[instance_id])
print(f"Terminated: {instance_id}") Supported AMI Images
| AMI ID | Docker Image | Description |
|---|---|---|
| (default for every AMI ID) | localemu/ec2-base:v4 | Pre-baked Ubuntu base with openssh-server, iptables, curl, awscli, and other net tooling. Built once on first RunInstances call. |
How It Works
DockerVmManager picks up the new instance, ensures the localemu/ec2-base image exists (built once on first run), and starts a container on the right Docker network for the target VPC. /root/.ssh/authorized_keys inside the container, so the private PEM on your host actually opens the door. /var/lib/cloud/instance/scripts/ and executed at boot before sshd is reachable. PrivateIpAddress) rather than a Docker-published host port, matching how a real EC2 instance is reachable inside its VPC. http://169.254.169.254/ from inside the container via an iptables DNAT rule, so tooling that hardcodes the link-local IMDS address (boto3, awscli, the AWS SDKs) finds credentials and instance identity exactly where it expects. stop-instances stops the container, start-instances brings the same container back, terminate-instances removes it. The EC2 metadata record stays in sync with the container's lifecycle.