AWS · lesson 3 of 10
EC2 Fundamentals
Launch an EC2 instance behind a security group with Terraform, SSH into it, and read the exact CLI output that confirms each step worked.
By SkillByExample · Updated
An EC2 instance is a virtual machine you rent by the second. Getting one running safely means getting three things right in order: what traffic can reach it, what image and size it boots from, and how you authenticate into it once it’s up.
This lesson uses a real AWS free-tier account — LocalStack’s EC2 emulation returns
plausible-looking API responses but there is no real network path to SSH into, which is
the whole point of the last step here. A t3.micro running Ubuntu falls inside the AWS
Free Tier’s 750 instance-hours per month.
Step 1: the security group (default-deny firewall)
AWS security groups block all inbound traffic by default. Nothing is reachable until a rule explicitly opens it.
aws ec2 create-security-group \
--group-name web-server-sg \
--description "Allow inbound SSH and HTTP" \
--vpc-id vpc-0a1b2c3d4e5f60789
$ aws ec2 create-security-group --group-name web-server-sg --description "Allow inbound SSH and HTTP" --vpc-id vpc-0a1b2c3d4e5f60789
{
"GroupId": "sg-0f4b8d2a91c7e3456"
}
aws ec2 authorize-security-group-ingress \
--group-id sg-0f4b8d2a91c7e3456 \
--ip-permissions \
'IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=203.0.113.50/32,Description="my IP only"}]' \
'IpProtocol=tcp,FromPort=80,ToPort=80,IpRanges=[{CidrIp=0.0.0.0/0,Description="public HTTP"}]'
$ aws ec2 authorize-security-group-ingress --group-id sg-0f4b8d2a91c7e3456 --ip-permissions ...
{
"Return": true,
"SecurityGroupRules": [
{
"SecurityGroupRuleId": "sgr-05c1e8a2f9b34d701",
"GroupId": "sg-0f4b8d2a91c7e3456",
"IsEgress": false,
"IpProtocol": "tcp",
"FromPort": 22,
"ToPort": 22,
"CidrIpv4": "203.0.113.50/32"
},
{
"SecurityGroupRuleId": "sgr-0d92f4b1a8e63c507",
"GroupId": "sg-0f4b8d2a91c7e3456",
"IsEgress": false,
"IpProtocol": "tcp",
"FromPort": 80,
"ToPort": 80,
"CidrIpv4": "0.0.0.0/0"
}
]
}
Port 22 is scoped to one /32 (a single IP) — the reader’s own — and port 80 is open to
the world because that is the point of a web server. Port 443 would be added the same way
once TLS is configured.
Step 2: define the instance with Terraform
Terraform makes the launch reproducible instead of a one-off console click.
# main.tf
resource "aws_instance" "web" {
ami = "ami-0c101f26f147fa7fd" # Ubuntu 22.04 LTS, us-east-1
instance_type = "t3.micro"
key_name = "my-ssh-key"
vpc_security_group_ids = ["sg-0f4b8d2a91c7e3456"]
tags = {
Name = "web-server"
}
}
output "public_ip" {
value = aws_instance.web.public_ip
}
terraform apply -auto-approve
$ terraform apply -auto-approve
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Still creating... [20s elapsed]
aws_instance.web: Creation complete after 24s [id=i-0d3e7c92a1b45f6e8]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
public_ip = "54.210.45.12"
Step 3: confirm state with the CLI
aws ec2 describe-instances --instance-ids i-0d3e7c92a1b45f6e8 \
--query 'Reservations[0].Instances[0].{State:State.Name,Type:InstanceType,IP:PublicIpAddress}'
$ aws ec2 describe-instances --instance-ids i-0d3e7c92a1b45f6e8 --query 'Reservations[0].Instances[0].{State:State.Name,Type:InstanceType,IP:PublicIpAddress}'
{
"State": "running",
"Type": "t3.micro",
"IP": "54.210.45.12"
}
Step 4: SSH in
chmod 400 my-ssh-key.pem
ssh -i my-ssh-key.pem ubuntu@54.210.45.12
$ ssh -i my-ssh-key.pem ubuntu@54.210.45.12
The authenticity of host '54.210.45.12 (54.210.45.12)' can't be established.
ED25519 key fingerprint is SHA256:k3pQmZ9v2Y7Ldx4Fj0RtHb8AeWnJcXsL1oVuMhTqYzA.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Welcome to Ubuntu 22.04.4 LTS (GNU/Linux 6.5.0-1017-aws x86_64)
ubuntu@ip-172-31-24-108:~$
Connecting from a different IP than the one authorized in step 1 fails at the TCP level, not with an SSH error — the security group drops the packets before SSH ever runs:
$ ssh -i my-ssh-key.pem ubuntu@54.210.45.12
ssh: connect to host 54.210.45.12 port 22: Operation timed out
A timeout (not “connection refused”) is the signature of a security-group block — the packet never got a response at all.
Cleaning up
EC2 bills by the second while running; terminate-instances is the only state that stops
billing entirely (stopped still bills for any attached EBS volume).
terraform destroy -auto-approve
$ terraform destroy -auto-approve
aws_instance.web: Destroying... [id=i-0d3e7c92a1b45f6e8]
aws_instance.web: Still destroying... [id=i-0d3e7c92a1b45f6e8, 10s elapsed]
aws_instance.web: Destruction complete after 32s
Destroy complete! Resources: 1 destroyed.
Practice
1. Try SSHing from an IP not in the security group's allow list and observe the failure mode.
$ ssh -i my-ssh-key.pem ubuntu@54.210.45.12
ssh: connect to host 54.210.45.12 port 22: Operation timed out
No “connection refused”, no SSH banner — the security group silently drops the SYN packet. Compare this to a wrong username, which does get a banner and then a permission error.
2. Add an outbound-only HTTPS rule and list all rules on the group.
aws ec2 describe-security-groups --group-ids sg-0f4b8d2a91c7e3456 \
--query 'SecurityGroups[0].IpPermissionsEgress'
[
{
"IpProtocol": "-1",
"IpRanges": [{"CidrIp": "0.0.0.0/0"}]
}
]
IpProtocol: "-1" means “all protocols, all ports” — this is the default egress rule AWS
creates automatically, which is why outbound traffic just works until you tighten it.
3. Stop and start the instance, then check whether the public IP changed.
aws ec2 stop-instances --instance-ids i-0d3e7c92a1b45f6e8
aws ec2 start-instances --instance-ids i-0d3e7c92a1b45f6e8
aws ec2 describe-instances --instance-ids i-0d3e7c92a1b45f6e8 \
--query 'Reservations[0].Instances[0].PublicIpAddress'
"18.212.77.201"
Different from 54.210.45.12 — a fresh public IP is assigned on every start unless an
Elastic IP is associated with the instance.
4. Allocate and associate an Elastic IP, then repeat the stop/start.
aws ec2 allocate-address --domain vpc
aws ec2 associate-address --instance-id i-0d3e7c92a1b45f6e8 --allocation-id eipalloc-0b3c9f2a1d5e8760f
aws ec2 stop-instances --instance-ids i-0d3e7c92a1b45f6e8
aws ec2 start-instances --instance-ids i-0d3e7c92a1b45f6e8
aws ec2 describe-instances --instance-ids i-0d3e7c92a1b45f6e8 \
--query 'Reservations[0].Instances[0].PublicIpAddress'
"52.90.14.203"
Same IP before and after the stop/start this time — the Elastic IP is now pinned to the instance rather than assigned fresh each boot. Note it bills hourly when not attached to a running instance, which is the opposite of what most people expect.
5. Query all running instances tagged Name=web-server across the account.
aws ec2 describe-instances \
--filters "Name=tag:Name,Values=web-server" "Name=instance-state-name,Values=running" \
--query 'Reservations[].Instances[].InstanceId' --output text
i-0d3e7c92a1b45f6e8
The two-filter pattern (tag plus state) is the standard way to find “the instances I actually care about” instead of parsing every instance in the account by hand.
Next: S3 — buckets, uploads, bucket policies, and versioning.