Docs / Use Cases / EC2 Docker Instances

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.

What the demo does. Creates an RSA key pair and downloads the PEM, creates a security group with an SSH ingress rule on port 22, launches a 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

Terminal
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

Terminal
$ 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

Terminal
$ 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

Terminal
$ 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

Terminal
$ 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

Terminal
$ 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

Terminal
$ localemu ssh --list

NAMES                              STATUS
localemu-ec2-i-345e9b5f68e616e47   Up About a minute

Step 8: Verify user data ran successfully

Terminal
$ 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

Terminal
$ 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

Terminal
$ 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

Terminal
# 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

Terminal
$ 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

Terminal
$ 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

Terminal
$ 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

Terminal
$ 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.

ec2_demo.py
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:v4Pre-baked Ubuntu base with openssh-server, iptables, curl, awscli, and other net tooling. Built once on first RunInstances call.

How It Works

1. awsemu ec2 run-instances reaches the EC2 API on LocalEmu, which records the instance, security group, key pair and tags in its in-memory EC2 metadata store.
2. That metadata is the input to the real behavior layer: the 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.
3. The SSH public key from the named key pair is materialised into /root/.ssh/authorized_keys inside the container, so the private PEM on your host actually opens the door.
4. The user-data script is dropped into /var/lib/cloud/instance/scripts/ and executed at boot before sshd is reachable.
5. The container gets a real IP on the VPC bridge network (recorded as PrivateIpAddress) rather than a Docker-published host port, matching how a real EC2 instance is reachable inside its VPC.
6. An IMDS sidecar (per VPC) answers 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.
7. 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.