← Back to blog

AWS · EC2 · EBS · Security Groups · AWS CLI · Backend

EC2: What Actually Happens When You Launch a Server?

Launch an EC2 instance and trace its AMI, EBS volume, networking, and user data. Learn why a running server can still fail to answer HTTP requests.

Ravi Kumar//17 min read

Launching an Amazon Elastic Compute Cloud (EC2) instance starts a virtual server from an Amazon Machine Image (AMI), with the compute, storage, and networking you selected. A running instance doesn't tell you whether your application is ready or reachable. EC2 launch behavior

You launch a server. It gets a public IP. The console says running.

Then you try its address and nothing loads.

That symptom can come from several places. The operating system might still be booting. The web server might not have started. Or the application might be working while a network rule blocks your request.

This is part 3 of AWS by Doing. Part 1 covered storing and reading an S3 object. Part 2 showed why permission to upload doesn't include permission to download. This time, we'll follow a request all the way to a server we launch ourselves.

We ran this with AWS CLI 2.37.9 in Ohio, us-east-2, on October 11, 2026. The instance served the page locally, but our first external request timed out. After we allowed TCP 80 from our client's public IPv4 address, the first retry returned HTTP 200 with the exact same bytes.

The commands below create fresh, disposable resources so you can repeat the experiment.

Quick answer

We'll launch one Amazon Linux 2023 instance, install Apache during its first boot, and serve a single line of text.

Then we'll verify that Apache answers inside the instance, try reaching it with inbound HTTP blocked, and allow our client's IP address before repeating the request.

That separates three questions:

  1. Has the instance started?
  2. Is the application serving a response?
  3. Can our request reach it?

One green indicator can't answer all three.

What does an EC2 launch create?

The instance is the virtual machine. Its root disk is an Amazon Elastic Block Store (EBS) volume, and its network interface sits in a subnet inside an Amazon Virtual Private Cloud (VPC). The launch settings connect those pieces.

PieceIts job in this lab
AMISupplies the Amazon Linux operating-system image
Instance typeDefines the server's compute resources and capabilities
Root EBS volumeHolds the operating system and our page as block storage
Primary network interfaceConnects the instance to a subnet and holds its private IP
Public IPv4 addressProvides an internet-facing address for this experiment
Security groupControls traffic allowed to and from the interface
User dataConfigures Apache during the first boot

We'll reuse an existing VPC, subnet, route table, and internet gateway. Launching this instance doesn't create a new copy of that network.

The AMI and instance type must also agree on architecture. We'll use the x86_64 Amazon Linux image with t3.micro, resolving the image in Ohio instead of copying an AMI ID from another region. Finding the current AMI

Our run resolved al2023-ami-2023.12.20260930.0-kernel-6.18-x86_64. The instance launched in us-east-2a with an encrypted 8 GiB gp3 root volume. The public parameter can resolve to a newer image when you try this.

Before launching anything

You'll need AWS CLI v2, Python 3, curl, uuidgen, diff, and a Bash or Zsh terminal. Use an authenticated development identity with permission to inspect the existing network, read the public AMI parameter, launch and tag an instance and its volume, manage a dedicated security group, retrieve console output, and terminate the lab instance.

This walkthrough uses Ohio, us-east-2, and a default VPC with internet access. If your account doesn't have that setup, stop at the network check. Don't create a new VPC or change shared network rules just to make the commands work.

EC2 compute, EBS storage, and public IPv4 usage can incur charges. Check your account's credits and billing plan; a small instance isn't automatically free. Plan to run the test and clean up in the same session. EC2 pricing, public IPv4 pricing

Run each block in the same terminal. Stop on an unexpected error, keeping the resource IDs for cleanup. Read the cleanup section before launching.

Step 1: Name the lab and check the network

set +x
export AWS_PAGER=""
 
aws --version
aws sts get-caller-identity --query Arn --output text
 
LAB_REGION=us-east-2
LAB_NAME="aws-by-doing-03-$(uuidgen | tr '[:upper:]' '[:lower:]')"
LAB_DIR=$(mktemp -d)
 
LAB_VPC=$(aws ec2 describe-vpcs --region "$LAB_REGION" \
  --filters Name=is-default,Values=true \
  --query 'Vpcs[0].VpcId' --output text)
 
printf 'Default VPC: %s\n' "$LAB_VPC"
bash

If authentication fails, finish your existing login or SSO flow before continuing. The identity ARN is an identifier, not a credential, but redact account identifiers before sharing output. These commands don't print access keys or session tokens; keep AWS debug logging disabled too.

If LAB_VPC is empty or None, this account doesn't meet the walkthrough's default-network prerequisite. Otherwise, select an available default subnet:

LAB_SUBNET=$(aws ec2 describe-subnets --region "$LAB_REGION" \
  --filters "Name=vpc-id,Values=$LAB_VPC" \
    Name=default-for-az,Values=true Name=state,Values=available \
  --query 'Subnets[0].SubnetId' --output text)
 
printf 'Selected subnet: %s\n' "$LAB_SUBNET"
bash

Stop if there isn't a subnet. Then inspect its effective route table. A subnet without an explicit association uses the VPC's main table:

LAB_ROUTE=$(aws ec2 describe-route-tables --region "$LAB_REGION" \
  --filters "Name=association.subnet-id,Values=$LAB_SUBNET" \
  --query 'RouteTables[0].RouteTableId' --output text)
 
if [ -z "$LAB_ROUTE" ] || [ "$LAB_ROUTE" = None ]; then
  LAB_ROUTE=$(aws ec2 describe-route-tables --region "$LAB_REGION" \
    --filters "Name=vpc-id,Values=$LAB_VPC" Name=association.main,Values=true \
    --query 'RouteTables[0].RouteTableId' --output text)
fi
 
aws ec2 describe-route-tables --region "$LAB_REGION" \
  --route-table-ids "$LAB_ROUTE" --query 'RouteTables[0].Routes' --output json
 
aws ec2 describe-internet-gateways --region "$LAB_REGION" \
  --filters "Name=attachment.vpc-id,Values=$LAB_VPC" \
  --query 'InternetGateways[].{Gateway:InternetGatewayId,Attachments:Attachments}' \
  --output json
 
aws ec2 describe-network-acls --region "$LAB_REGION" \
  --filters "Name=association.subnet-id,Values=$LAB_SUBNET" \
  --query 'NetworkAcls[].{Id:NetworkAclId,Rules:Entries}' --output json
bash

For this lab, check that the route table has an active 0.0.0.0/0 route to the internet gateway attached to this VPC. Check that the subnet's network ACL allows IPv4 traffic in both directions, as an unmodified default ACL does. ACLs evaluate numbered rules in order; a preceding deny matters. Stop if your configuration differs rather than change it here. Subnet routing, network ACL rules

A public IP needs this network path to be useful. It doesn't create the route or override traffic rules. Internet gateway requirements

Step 2: Resolve the image and make storage explicit

LAB_AMI=$(aws ssm get-parameter --region "$LAB_REGION" \
  --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
  --query Parameter.Value --output text)
 
aws ec2 describe-images --region "$LAB_REGION" \
  --owners amazon --image-ids "$LAB_AMI" \
  --output json > "$LAB_DIR/image.json"
 
python3 - "$LAB_DIR" <<'PY'
import json
import sys
from pathlib import Path
 
directory = Path(sys.argv[1])
images = json.loads((directory / "image.json").read_text())["Images"]
assert len(images) == 1, "Expected one Amazon-owned image"
image = images[0]
assert image["State"] == "available"
assert image["Architecture"] == "x86_64"
assert image["RootDeviceType"] == "ebs"
assert len(image["BlockDeviceMappings"]) == 1, "Review additional image devices"
device = image["BlockDeviceMappings"][0]
assert device["DeviceName"] == image["RootDeviceName"]
size = device["Ebs"]["VolumeSize"]
mapping = [{
    "DeviceName": image["RootDeviceName"],
    "Ebs": {
        "VolumeSize": size,
        "VolumeType": "gp3",
        "Encrypted": True,
        "DeleteOnTermination": True
    }
}]
(directory / "storage.json").write_text(json.dumps(mapping, indent=2))
print(f'Image: {image["Name"]}; root volume: {size} GiB, encrypted gp3')
PY
bash

This uses the image's root device and volume size instead of guessing them. DeleteOnTermination tells EC2 to delete this volume when we terminate the instance. The volume uses the account's configured EBS encryption key; your setup identity must be allowed to use it. Disk encryption protects stored data, while access to the web page depends on the network and application. EBS default encryption key

Step 3: Give the server its first-boot instructions

We'll serve a fixed line of text. The page needs no database or application secrets, and our boot script makes no AWS API calls.

cat > "$LAB_DIR/user-data.sh" <<'BOOT'
#!/bin/bash
set -euo pipefail
dnf install -y httpd
printf 'Hello from AWS by Doing, lab 3.\n' > /var/www/html/index.html
systemctl enable --now httpd
curl --fail --silent --show-error http://127.0.0.1/ > /tmp/lab-local-response.txt
printf 'Hello from AWS by Doing, lab 3.\n' | cmp - /tmp/lab-local-response.txt
printf 'AWS_BY_DOING_03_HTTP_READY\n' > /dev/console
BOOT
 
printf 'Hello from AWS by Doing, lab 3.\n' > "$LAB_DIR/expected.txt"
bash

The script's final three commands give us evidence of application readiness: request the page through loopback, compare its exact bytes, and write a console marker only after both checks succeed.

On the standard Linux configuration, user data runs as root during the first boot. It doesn't run on every restart by default. Installing packages adds time, so instance state and application readiness can diverge. The CLI handles base64 encoding when we pass this script through --user-data file://.... User data behavior

Don't put credentials in user data. It is stored as instance configuration and copied onto the machine; it isn't a secret store.

Step 4: Launch with HTTP closed

Create a dedicated security group and inspect its rules:

LAB_SG=$(aws ec2 create-security-group --region "$LAB_REGION" \
  --group-name "$LAB_NAME" --description 'Disposable AWS by Doing EC2 lab' \
  --vpc-id "$LAB_VPC" --query GroupId --output text)
 
aws ec2 describe-security-groups --region "$LAB_REGION" \
  --group-ids "$LAB_SG" \
  --query 'SecurityGroups[0].{Inbound:IpPermissions,Outbound:IpPermissionsEgress}' \
  --output json
bash

Confirm that Inbound is empty. A new group normally allows outbound traffic, which this instance needs to install Apache. We'll attach only this group so another group's inbound allow can't change the experiment. Security-group rules are combined across attached groups. Security group rules

Launch exactly one instance:

LAB_INSTANCE=$(aws ec2 run-instances --region "$LAB_REGION" \
  --image-id "$LAB_AMI" --instance-type t3.micro --count 1 \
  --client-token "$LAB_NAME" \
  --network-interfaces \
    "DeviceIndex=0,SubnetId=$LAB_SUBNET,Groups=$LAB_SG,AssociatePublicIpAddress=true,DeleteOnTermination=true" \
  --block-device-mappings "file://$LAB_DIR/storage.json" \
  --metadata-options HttpEndpoint=enabled,HttpTokens=required \
  --credit-specification CpuCredits=standard \
  --user-data "file://$LAB_DIR/user-data.sh" \
  --tag-specifications \
    "ResourceType=instance,Tags=[{Key=Name,Value=$LAB_NAME}]" \
    "ResourceType=volume,Tags=[{Key=Name,Value=$LAB_NAME}]" \
  --query 'Instances[0].InstanceId' --output text)
 
printf 'Lab instance: %s\n' "$LAB_INSTANCE"
bash

The network interface gets a private address and an automatically assigned public IPv4 address. We request that the interface be deleted with the instance. The metadata setting requires tokens for version 2 of EC2's instance metadata service, or IMDSv2.

standard CPU credits avoid Unlimited-mode surplus-credit charges. CPU performance is limited to the instance's baseline when credits run out; compute usage is still billed. Launch options, standard credit mode

The client token identifies this launch request for safe retries with the same parameters. If the CLI response is interrupted, inspect instances with this token before starting a new lab or changing its parameters:

aws ec2 describe-instances --region "$LAB_REGION" \
  --filters "Name=client-token,Values=$LAB_NAME" \
  --query 'Reservations[].Instances[].{Id:InstanceId,State:State.Name}' --output table
bash

We didn't attach an EC2 key pair or open port 22. The boot script and console output are enough for this test. The instance also doesn't need an IAM role to install Apache and serve our page.

The launch API returned pending. Our later instance description returned running, with a public IPv4 address and the expected volume and interface attached. We verified that IMDSv2 tokens were required and CPU credits were set to standard.

Step 5: Check the instance and application separately

aws ec2 wait instance-running --region "$LAB_REGION" \
  --instance-ids "$LAB_INSTANCE"
 
aws ec2 describe-instances --region "$LAB_REGION" \
  --instance-ids "$LAB_INSTANCE" \
  --query 'Reservations[0].Instances[0].{State:State.Name,Image:ImageId,Type:InstanceType,Subnet:SubnetId,PrivateIP:PrivateIpAddress,PublicIP:PublicIpAddress,Disks:BlockDeviceMappings,Interfaces:NetworkInterfaces[].NetworkInterfaceId}' \
  --output json
 
LAB_IP=$(aws ec2 describe-instances --region "$LAB_REGION" \
  --instance-ids "$LAB_INSTANCE" \
  --query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
 
LAB_VOLUME=$(aws ec2 describe-instances --region "$LAB_REGION" \
  --instance-ids "$LAB_INSTANCE" \
  --query 'Reservations[0].Instances[0].BlockDeviceMappings[0].Ebs.VolumeId' --output text)
 
LAB_ENI=$(aws ec2 describe-instances --region "$LAB_REGION" \
  --instance-ids "$LAB_INSTANCE" \
  --query 'Reservations[0].Instances[0].NetworkInterfaces[0].NetworkInterfaceId' --output text)
 
aws ec2 describe-volumes --region "$LAB_REGION" --volume-ids "$LAB_VOLUME" \
  --query 'Volumes[0].{SizeGiB:Size,Type:VolumeType,Encrypted:Encrypted,State:State}' \
  --output json
 
aws ec2 describe-instance-status --region "$LAB_REGION" \
  --instance-ids "$LAB_INSTANCE" --include-all-instances --output json
bash

Keep these IDs for cleanup and the public IP for our HTTP requests. Check that the volume matches our configuration and that LAB_IP contains a public IPv4 address, rather than an empty value or None.

EC2 status checks inspect infrastructure and instance health, including underlying-system problems and instance network reachability. They don't make an HTTP request to our page. A passing status check doesn't establish that Apache serves the response we expect. What status checks cover

Retrieve the latest console output and search for the marker:

aws ec2 get-console-output --region "$LAB_REGION" \
  --instance-id "$LAB_INSTANCE" --latest --query Output --output text \
  > "$LAB_DIR/console.txt"
 
rg --fixed-strings AWS_BY_DOING_03_HTTP_READY "$LAB_DIR/console.txt"
bash

Use grep -F instead if rg isn't installed. The AWS CLI decodes the console output for us. It may take time for the marker to appear; retrieve a fresh copy before searching again. If it still doesn't appear after a few minutes, inspect the boot output for errors and clean up if setup can't finish. Console output

Don't diagnose the next timeout as a security-group issue until the local HTTP check has succeeded. A marker missing from one console snapshot isn't proof that the application failed, either.

That console detail mattered in our run. The first inspection found the instance running, with status checks still initializing. Later, all three reported checks—system, instance, and attached EBS—were ok, but our retrieved console output still ended during package installation.

A subsequent retrieval contained:

AWS_BY_DOING_03_HTTP_READY
text

The final cloud-init message reported completion at about 149 seconds of guest uptime. We retrieved the marker later than that logged completion time. Console snapshots and polling add delay, so first seeing the marker wasn't an exact measurement of when Apache became ready.

The marker is useful because of what precedes it: a successful loopback request and byte comparison. That's stronger application evidence than the instance state alone.

Step 6: Make the same request before and after one rule

Once the readiness marker is present, request the page from your own machine:

curl --noproxy '*' --connect-timeout 5 --max-time 10 \
  --silent --show-error --write-out 'HTTP %{http_code}\n' "http://$LAB_IP/"
bash

Our request timed out after about five seconds. Curl exited with code 28 and reported HTTP status 000, which means it received no HTTP status code. Run this command individually; the failure is deliberate.

Apache had answered locally, but our external request couldn't connect. We rechecked that the only attached security group still had no inbound rules, then added one rule and retried.

Find the public IPv4 address of the machine making the request. Use the same machine for both requests, and keep its network connection consistent:

LAB_CLIENT_IP=$(curl --noproxy '*' -4 --fail --silent --show-error \
  --connect-timeout 5 --max-time 10 https://checkip.amazonaws.com)
 
python3 - "$LAB_CLIENT_IP" <<'PY'
import ipaddress
import sys
address = ipaddress.ip_address(sys.argv[1])
assert address.version == 4 and address.is_global, "Expected a public IPv4 address"
PY
bash

Stop if the IP check fails. Once it succeeds, allow TCP 80 from that address:

aws ec2 authorize-security-group-ingress --region "$LAB_REGION" \
  --group-id "$LAB_SG" --protocol tcp --port 80 \
  --cidr "$LAB_CLIENT_IP/32" --query Return --output text
 
aws ec2 describe-security-groups --region "$LAB_REGION" \
  --group-ids "$LAB_SG" --query 'SecurityGroups[0].IpPermissions' --output json
bash

Verify that the only inbound rule allows TCP 80 from LAB_CLIENT_IP/32. Then request the page:

curl --noproxy '*' --fail --silent --show-error \
  --connect-timeout 5 --max-time 10 \
  --output "$LAB_DIR/response.txt" --write-out 'HTTP %{http_code}\n' \
  "http://$LAB_IP/" &&
  diff "$LAB_DIR/expected.txt" "$LAB_DIR/response.txt"
bash

/32 allows one IPv4 address. The rule can take a short time to apply; retry the same request before changing anything else. Adding an inbound rule

The first request after adding the rule succeeded:

HTTP 200
text

The byte comparison passed. No output from diff means the files match. The 32-byte response, including its trailing newline, was:

Hello from AWS by Doing, lab 3.
text

We didn't restart Apache, change the page, or relaunch the instance. We added one inbound rule for the same client and retried.

If the request still fails, check that your public client address hasn't changed, that the instance still has the same public IP, and that the console contains the readiness marker. A VPN or firewall can change the path too. Don't broaden the inbound rule to troubleshoot an unidentified problem.

This lab uses plain HTTP to serve disposable dummy text.

Running, ready, reachable: different evidence

Here's what we observed and what each signal can establish.

ObservationWhat it tells usWhat it doesn't tell us
Instance is runningThe virtual machine has started bootingOur page is ready
EC2 status checks passThe reported system and instance checks passedThe page returns the right content
Local byte check succeedsApache served our expected bytes over loopback at that timeA remote client can reach port 80
Remote request times outThis client didn't complete the connection in timeWhich layer caused the failure, by itself
Remote HTTP 200 and matching bytesThis client reached the page and received the expected responseEvery client has access, or that future requests will succeed

Our test isolated the missing inbound rule: Apache served the page locally, the external request timed out with HTTP closed, and the same client received the exact page after we allowed its address. The sequence of checks gave us the diagnosis.

This also connects back to IAM. Our CLI identity needs permission to call EC2 APIs. The anonymous HTTP request to our dummy Apache page depends on network access and the application's behavior. Adding an IAM permission to the caller won't open TCP port 80.

Step 7: Terminate the instance and verify cleanup

First, confirm that the recorded instance is the disposable lab instance:

aws ec2 describe-instances --region "$LAB_REGION" \
  --instance-ids "$LAB_INSTANCE" \
  --query 'Reservations[0].Instances[0].{Id:InstanceId,Name:Tags[?Key==`Name`].Value|[0],State:State.Name}' \
  --output json
bash

Check the name against LAB_NAME, then terminate the instance with that ID:

aws ec2 terminate-instances --region "$LAB_REGION" \
  --instance-ids "$LAB_INSTANCE" \
  --query 'TerminatingInstances[].{Id:InstanceId,State:CurrentState.Name}' --output json
 
aws ec2 wait instance-terminated --region "$LAB_REGION" \
  --instance-ids "$LAB_INSTANCE"
 
aws ec2 wait volume-deleted --region "$LAB_REGION" \
  --volume-ids "$LAB_VOLUME"
 
aws ec2 describe-network-interfaces --region "$LAB_REGION" \
  --network-interface-ids "$LAB_ENI" \
  --query 'NetworkInterfaces[].NetworkInterfaceId' --output json
bash

The ENI check should eventually return InvalidNetworkInterfaceID.NotFound. A permission error doesn't confirm deletion. If the interface is still present, allow deletion to finish and inspect its attachment before doing anything else. Don't manually delete a resource you haven't identified.

After the instance, root volume, and interface are gone, delete the lab's security group:

aws ec2 delete-security-group --region "$LAB_REGION" --group-id "$LAB_SG"
 
aws ec2 describe-security-groups --region "$LAB_REGION" \
  --group-ids "$LAB_SG" --query 'SecurityGroups[].GroupId' --output json
bash

Expect InvalidGroup.NotFound after successful deletion. If group deletion reports a dependency, check whether the recorded interface has finished disappearing; don't detach groups from unrelated resources.

The automatically assigned public IPv4 address is released when the instance is terminated. We didn't allocate an Elastic IP. The VPC and subnet remain because they existed before this lab. Public IP lifecycle

If launch failed before returning an instance ID, use the client-token lookup from step 4 to check whether an instance was created. If one exists, set LAB_INSTANCE to its ID and use step 5 to record its volume and interface IDs before cleanup. If none exists, only the dedicated group needs deleting. The small local files remain in LAB_DIR.

Stopping an EBS-backed instance retains its storage, which can continue to incur charges. Termination deletes only the volumes configured for deletion. That's why we set DeleteOnTermination and verify the recorded volume rather than assume it's gone. Instance states and billing, termination behavior

Our cleanup checks confirmed the instance was terminated, the root volume returned InvalidVolume.NotFound, the interface returned InvalidNetworkInterfaceID.NotFound, and the dedicated group returned InvalidGroup.NotFound. No resource created by this lab remains active.

We verified cleanup about five and a half minutes after the launch timestamp. That interval includes the test and cleanup checks; it isn't a measurement of billed instance runtime. Actual billed cost has not been measured.

The useful question when the page won't load

Start with the evidence you actually have. Running answers a question about the instance. A local response answers a question about the application. A response from your client answers a question about the complete path at that moment.

On the next unreachable server, check those separately before changing permissions or reinstalling the application. You'll have a smaller problem to investigate.