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, 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 awsemu ssm start-session. Drop into a root shell instantly.

🧰

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
Tags:
  - Key: localemu:ssh-port
    Value: "50025"
PublicIpAddress: 127.0.0.1
PrivateIpAddress: 10.43.9.71

LocalEmu boots the localemu/ec2-base:v3 pre-baked image (Ubuntu + openssh-server + awscli + tooling), starts a container, injects the SSH public key, and runs the user data script. The localemu:ssh-port tag tells you which host port maps to SSH.

Step 5: Describe the running instance

Terminal
$ awsemu ec2 describe-instances --instance-ids i-345e9b5f68e616e47

InstanceId: i-345e9b5f68e616e47
State: running
PublicDnsName: localhost
PublicIpAddress: 127.0.0.1
Tags:
  - Key: localemu:ssh-port
    Value: "50025"
KeyName: my-key
InstanceType: t2.micro
ImageId: ami-ubuntu-22.04
AvailabilityZone: us-east-1a

Once running, PublicDnsName is set to localhost and PublicIpAddress to 127.0.0.1. The SSH port is in the instance tags.

Step 6: Verify the Docker container is running

Terminal
$ docker ps --filter "label=localemu.service=ec2"

CONTAINER ID   IMAGE          COMMAND                   STATUS          PORTS                                       NAMES
ee1fb33a5530   localemu/ec2-base:v3   "sh -c '#!/bin/sh\nse..."   Up 45 seconds   0.0.0.0:50021->22/tcp, [::]:50021->22/tcp   localemu-ec2-i-345e9b5f68e616e47

Step 7: List SSH-accessible instances

Terminal
$ localemu ssh --list

NAMES                              STATUS              PORTS
localemu-ec2-i-345e9b5f68e616e47   Up About a minute   0.0.0.0:50021->22/tcp, [::]:50021->22/tcp

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
$ awsemu ssm start-session --target i-345e9b5f68e616e47

root@ee1fb33a5530:/# ls
bin  boot  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

root@ee1fb33a5530:/# 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
$ SSH_PORT=$(awsemu ec2 describe-instances \
    --instance-ids i-345e9b5f68e616e47 \
    --query 'Reservations[0].Instances[0].Tags[?Key==`localemu:ssh-port`].Value' \
    --output text)

$ echo "SSH port: $SSH_PORT"
SSH port: 50025

$ ssh -i /tmp/my-key.pem -p $SSH_PORT \
    -o StrictHostKeyChecking=no root@localhost whoami
root

Standard OpenSSH works. The SSH port is available in the instance tags. Use localhost as the host and the mapped port.

Step 12: 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    PORTS     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          PORTS                                       NAMES
ee1fb33a5530   localemu/ec2-base:v3   "sh -c '#!/bin/sh\nse..."   Up 13 seconds   0.0.0.0:50021->22/tcp, [::]:50021->22/tcp   localemu-ec2-i-345e9b5f68e616e47

Stopping an instance stops the Docker container. Starting it again brings the same container back. Full lifecycle management.

Step 13: 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 SSH host port LocalEmu mapped to this instance
desc = ec2.describe_instances(InstanceIds=[instance_id])
tags = desc["Reservations"][0]["Instances"][0].get("Tags", [])
ssh_port = next(t["Value"] for t in tags if t["Key"] == "localemu:ssh-port")
print(f"SSH available on localhost:{ssh_port}")

# 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:v3Pre-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. A free host port is published to the container's port 22 and written back to the instance record as the localemu:ssh-port tag (and consumed by localemu ssh).
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.