EC2 is where a huge share of the world’s Docker containers actually run, whether directly on bare EC2 instances or underneath ECS and EKS. This guide walks through provisioning a Docker-ready EC2 instance from the ground up — networking, security groups, installation, TLS-secured remote access, and persistent storage — using nothing but the AWS CLI, so every step is scriptable and repeatable.
Prerequisites
- An AWS account
- AWS CLI configured (
aws configure), or run via container (see the companion guide on cloud CLIs in Docker) - An SSH key pair
Step 1: Create a Key Pair
aws ec2 create-key-pair --key-name docker-key --query 'KeyMaterial' --output text > docker-key.pem
chmod 400 docker-key.pem
Step 2: Create a Security Group
Security groups are AWS’s equivalent of a stateful firewall attached directly to instances.
aws ec2 create-security-group \
--group-name docker-sg \
--description "Docker host security group"
Expected output:
{
"GroupId": "sg-0a1b2c3d4e5f6a7b8"
}
Allow SSH, HTTP, and (scoped) the Docker API port:
aws ec2 authorize-security-group-ingress \
--group-id sg-0a1b2c3d4e5f6a7b8 \
--protocol tcp --port 22 --cidr YOUR_IP/32
aws ec2 authorize-security-group-ingress \
--group-id sg-0a1b2c3d4e5f6a7b8 \
--protocol tcp --port 80 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress \
--group-id sg-0a1b2c3d4e5f6a7b8 \
--protocol tcp --port 2376 --cidr YOUR_IP/32
Step 3: Launch the Instance
Use Amazon Linux 2023’s built-in dnf package or Ubuntu with a user-data script — I’ll use Ubuntu here since it maps cleanly to the same install steps used across the Azure and GCP guides in this series.
cat > user-data.sh << 'EOF'
#!/bin/bash
apt-get update -y
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
usermod -aG docker ubuntu
systemctl enable docker
systemctl start docker
EOF
aws ec2 run-instances \
--image-id ami-0e86e20dae9224db8 \
--instance-type t3.small \
--key-name docker-key \
--security-group-ids sg-0a1b2c3d4e5f6a7b8 \
--user-data file://user-data.sh \
--count 1 \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=docker-host-01}]'
Note: the AMI ID above is region-specific and changes over time. Look up the current Ubuntu 24.04 LTS AMI for your region with:
aws ec2 describe-images \ --owners 099720109477 \ --filters "Name=name,Values=ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*" \ --query 'sort_by(Images, &CreationDate)[-1].ImageId' \ --output text
Step 4: Get the Public IP and Connect
aws ec2 describe-instances \
--filters "Name=tag:Name,Values=docker-host-01" \
--query 'Reservations[0].Instances[0].PublicIpAddress' \
--output text
ssh -i docker-key.pem ubuntu@<PUBLIC_IP>
docker --version
docker run hello-world
Expected output:
Docker version 27.3.1, build ce12230
Hello from Docker!
This message shows that your installation appears to be working correctly.
Step 5: Secure Remote Docker API Access with TLS
If you want to control this Docker daemon remotely from your laptop rather than SSHing in each time, generate CA and server certificates on the instance:
mkdir -p ~/docker-certs && cd ~/docker-certs
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -out ca.pem
openssl genrsa -out server-key.pem 4096
openssl req -subj "/CN=$(hostname)" -new -key server-key.pem -out server.csr
echo subjectAltName = IP:<PUBLIC_IP>,IP:127.0.0.1 > extfile.cnf
openssl x509 -req -days 365 -sha256 -in server.csr -CA ca.pem -CAkey ca-key.pem \
-CAcreateserial -out server-cert.pem -extfile extfile.cnf
Configure /etc/docker/daemon.json:
{
"hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
"tls": true,
"tlsverify": true,
"tlscacert": "/etc/docker/ca.pem",
"tlscert": "/etc/docker/server-cert.pem",
"tlskey": "/etc/docker/server-key.pem"
}
sudo systemctl restart docker
On your laptop:
export DOCKER_HOST=tcp://<PUBLIC_IP>:2376
export DOCKER_TLS_VERIFY=1
export DOCKER_CERT_PATH=~/docker-certs/client
docker ps
Step 6: Attach an EBS Volume for Persistent Storage
aws ec2 create-volume \
--availability-zone us-east-1a \
--size 100 \
--volume-type gp3
aws ec2 attach-volume \
--volume-id vol-0abc123def456 \
--instance-id i-0123456789abcdef0 \
--device /dev/sdf
On the instance:
sudo lsblk
sudo mkfs -t ext4 /dev/xvdf
sudo mkdir /mnt/docker-data
sudo mount /dev/xvdf /mnt/docker-data
Add "data-root": "/mnt/docker-data/docker" to daemon.json and restart Docker so images and containers live on the EBS volume rather than the (typically smaller) root EBS volume.
Networking Fundamentals on EC2
EC2 instances by default get a private IP within a VPC subnet and, if launched in a public subnet, an ephemeral public IP mapped via the Internet Gateway. Security groups act at the instance’s network interface level — this is a different layer entirely from Docker’s own bridge networking. A container listening on port 80 inside the instance is invisible to the outside world until both (a) you’ve published that port to the host with -p 80:80, and (b) the security group allows inbound traffic on port 80. Missing either one is the most common reason a freshly deployed container “isn’t working” on a first EC2 deployment.
Running a Real Workload
docker run -d --name web-demo -p 80:80 --restart unless-stopped nginx:latest
Visit http://<PUBLIC_IP> in a browser to confirm.
Monitoring
docker stats --no-stream
For production visibility, install the Amazon CloudWatch agent, which can be configured to collect Docker container-level metrics (CPU, memory, disk) alongside standard EC2 host metrics:
sudo apt-get install -y amazon-cloudwatch-agent
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
-a fetch-config -m ec2 -c file:/opt/aws/amazon-cloudwatch-agent/etc/config.json -s
Deploying a Multi-Container Application with Compose
Install the Compose plugin (bundled with the get-docker.sh script used earlier) and define your stack:
# docker-compose.yml
services:
web:
image: nginx:latest
ports:
- "80:80"
restart: unless-stopped
api:
image: myregistry.example.com/api:1.0.0
environment:
- DATABASE_URL=postgresql://appuser:apppass@db:5432/appdb
restart: unless-stopped
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=appdb
- POSTGRES_USER=appuser
- POSTGRES_PASSWORD=apppass
restart: unless-stopped
volumes:
db-data:
docker compose up -d
Expected output:
[+] Running 4/4
✔ Network ubuntu_default Created
✔ Container ubuntu-db-1 Started
✔ Container ubuntu-api-1 Started
✔ Container ubuntu-web-1 Started
High Availability with an Auto Scaling Group and Load Balancer
A single EC2 instance is a single point of failure. For production traffic, wrap the instance configuration in a launch template and an Auto Scaling Group behind an Application Load Balancer:
aws ec2 create-launch-template \
--launch-template-name docker-host-template \
--version-description v1 \
--launch-template-data '{
"ImageId": "ami-0e86e20dae9224db8",
"InstanceType": "t3.small",
"KeyName": "docker-key",
"SecurityGroupIds": ["sg-0a1b2c3d4e5f6a7b8"],
"UserData": "'"$(base64 -w0 user-data.sh)"'"
}'
aws autoscaling create-auto-scaling-group \
--auto-scaling-group-name docker-asg \
--launch-template LaunchTemplateName=docker-host-template,Version='$Latest' \
--min-size 2 --max-size 4 --desired-capacity 2 \
--vpc-zone-identifier "subnet-0123abc,subnet-0456def"
AWS will replace unhealthy instances automatically based on the ASG’s health check configuration, giving you self-healing infrastructure without introducing a full orchestrator.
Understanding EC2’s Networking Layers
As with every cloud, it’s worth keeping these layers distinct in your head:
- VPC and subnet — the private IP address space the instance lives in, and whether it’s public or private.
- Security group — the stateful firewall attached to the instance’s network interface; this is what
aws ec2 authorize-security-group-ingressmodifies. - Docker’s internal bridge network — entirely invisible to AWS, handles container-to-container traffic on the instance.
- Published ports (
-p) — the bridge connecting the instance’s public IP to a container’s internal port.
Every one of these layers needs to be correctly configured for a container to be reachable from outside — a green docker ps output guarantees nothing about external reachability on its own.
Best Practices
- Use an IAM instance role rather than embedding AWS access keys on the instance — the EC2 metadata service (IMDSv2) provides temporary credentials automatically.
- Enable IMDSv2 only (
--metadata-options HttpTokens=required) to protect against SSRF-based credential theft. - Scope security groups tightly — port 2376 should never be open to
0.0.0.0/0. - Use gp3 EBS volumes over gp2 for better baseline IOPS at lower cost for Docker’s storage-heavy workloads.
- Consider ECS or EKS instead of raw EC2 if you’re managing more than a handful of services — they remove daemon and patching overhead entirely while still running on EC2 under the hood.
- Tag everything (
Environment,Team,Project) for cost allocation from the start.
Choosing an Instance Type
Instance sizing is one of the most common early mistakes on EC2 Docker hosts. t3/t3a burstable instances are fine for low, spiky traffic (dev environments, small internal tools) but can throttle under sustained CPU load once their CPU credit balance runs out — check this with:
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 --metric-name CPUCreditBalance \
--dimensions Name=InstanceId,Value=i-0123456789abcdef0 \
--start-time 2026-07-29T00:00:00Z --end-time 2026-07-30T00:00:00Z \
--period 3600 --statistics Average
For sustained, predictable container workloads, m6i or m7g (Graviton, ARM-based — note that container images need arm64 variants) instances give consistent performance without credit throttling. Memory-heavy workloads like databases or caching layers are usually better served by the r-family instances.
Troubleshooting
- Can’t SSH in — check the security group allows port 22 from your current IP; note that many home/office IPs change over time, so
YOUR_IP/32rules need periodic updates. docker run hello-worldfails right after boot — user-data scripts can take a minute or two to finish; check/var/log/cloud-init-output.logfor errors.- Public IP is empty — confirm the instance was launched in a public subnet with auto-assign public IP enabled, or that you attached an Elastic IP.
- EBS volume not showing under
lsblk— confirm the--devicename matches what the instance type actually exposes; Nitro-based instances often rename/dev/sdfto/dev/xvdfor an NVMe device path.
Summary
A Docker host on EC2 comes down to four building blocks: a security group controlling what’s reachable, an instance with Docker installed via user-data, TLS certificates if you want remote API access, and an EBS volume if you need storage beyond the root disk. None of this is EC2-specific once Docker itself is running — the container networking, image layering, and daemon behavior are identical to any other Linux host; only the surrounding provisioning and security group layer changes from cloud to cloud.
References
- Amazon EC2 documentation: https://docs.aws.amazon.com/ec2/
- Docker Engine installation guide: https://docs.docker.com/engine/install/ubuntu/
- Docker Engine API security (TLS): https://docs.docker.com/engine/security/protect-access/
- AWS IMDSv2 documentation: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html