Skip to main content
AWS · lesson 3 of 10
AWS beginner 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

aws ec2 terraform ssh

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.

Frequently Asked Questions

Does this lesson need a real AWS account?
Yes. LocalStack Community's EC2 support mocks the control-plane API responses but does not run an actual reachable instance to SSH into, so the network-facing parts of this lesson (security groups, SSH, public IP) need a real AWS free-tier account. A t3.micro or t2.micro instance is within the AWS Free Tier's 750 hours/month for the first 12 months.
Why does the security group default to denying all inbound traffic?
AWS security groups are default-deny for inbound and default-allow for outbound. Every inbound rule you see in this lesson is something explicitly opened; anything not listed is blocked. This is the opposite of a traditional server firewall's common default and is one of the first things to internalize about EC2 networking.
Why restrict the SSH rule to one IP instead of 0.0.0.0/0?
Port 22 open to the world is scanned continuously by bots attempting credential and key brute-forcing. Restricting the source CIDR to your own IP (or a bastion host's IP) removes that entire attack surface without changing anything about how you actually connect.
What happens to my public IP if I stop and start the instance?
By default EC2 assigns a new public IPv4 address every time an instance is stopped and started again — the old one is released back to AWS's pool. An Elastic IP is a static public IP you allocate separately and associate with the instance, and it survives stop/start cycles until you explicitly release it.