Learning AWS Day 2: Deploying a Real API to EC2 (And Locking Myself Out Again)

Day 1 produced nothing I could point at. Billing alarms, IAM groups, a lockout I caused and fixed within the hour, all of it real learning but none of it visible. Day 2 is the opposite kind of day. By the end of it I had a URL, a real one, that I could open on my phone, on a different wifi network, on a laptop that had never touched this project, and it would return JSON from code I wrote, running on a server I provisioned myself an hour earlier. Coming from a frontend background where the backend has always been someone else’s API base URL in an env file, that moment landed differently than I expected.

What EC2 actually is, in terms that made sense to me

I’ve used Cloudflare Pages and Vercel for years, and both of those exist specifically to make you forget servers are a thing. You push code, and somewhere a machine runs it, and you never think about which machine or where it lives. EC2 is the opposite of that on purpose. It’s a computer in one of Amazon’s data centers that you rent by the hour. You choose the operating system, you choose how big a machine you want, AWS boots it up, hands you a public IP address, and from that point on you are the administrator of that machine. Nobody abstracts anything away for you. If something is misconfigured, broken, or insecure, that’s on you now, not a platform team.

Launching the instance

The actual launch flow in the console was less ceremonial than I expected. Pick an AMI, which is just the base image the instance boots from, I went with Ubuntu 24.04 LTS since that’s what most of the tutorials and documentation assume. Pick an instance type, t2.micro, which gets you one virtual CPU and 1GB of RAM and is the type that’s actually free tier eligible for the first year.

That last point caused a small panic I didn’t expect. The console shows a “Free tier eligible” badge next to some AMIs, and Ubuntu’s AMI doesn’t have that badge. For a minute I assumed I’d picked the wrong OS image and was about to get billed. It turns out the badge is attached to the instance type, not the operating system image, so t2.micro is free tier eligible regardless of which OS you put on it. The badge placement in the console just doesn’t make that obvious at a glance.

The other piece at launch time is the key pair, an RSA key in .pem format that you download once and only once. AWS doesn’t keep a copy for you to redownload later, so losing it means losing SSH access to that instance entirely. I saved mine straight into ~/.ssh/ and made a mental note that this file is effectively the only password this server has.

Security groups, AWS’s version of a firewall

Before SSH-ing in anywhere, it’s worth understanding what a security group actually is. It’s not a network-wide firewall, it’s a set of inbound and outbound rules attached to a specific instance, controlling what traffic is allowed to reach it and on which ports.

I set up two rules at launch. SSH on port 22, with the source locked to my own IP address rather than open to the entire internet. The reasoning here isn’t theoretical, leaving port 22 open to 0.0.0.0/0 means bots are scanning for it and attempting weak credential logins within minutes of the instance going live, this is a well documented and constantly happening thing on the public internet, not a hypothetical risk. The second rule was HTTP on port 80, open to anywhere, since that’s the port my app actually needs to be reachable on by anyone who wants to hit it.

The console throws up a yellow warning the moment you set a rule’s source to 0.0.0.0/0, flagging it as a wide-open rule. That warning is correct to show, and it would be a real problem if it were sitting on the SSH rule. On the HTTP rule for a public demo app, it’s exactly the configuration you want, so I left it as is.

The chmod mistake

This is where Day 2 had its own version of Day 1’s lockout moment.

I ran the SSH command pointed at my new instance’s public IP and instead of a shell prompt I got a wall of red text. The relevant line read something like “Permissions 0664 for s2-keypair.pem are too open,” followed by “bad permissions” and finally “Permission denied (publickey).” My first instinct reading that was that the server was rejecting my key, which sent me down the wrong troubleshooting path for a minute. What’s actually happening is that SSH on your own machine refuses to even attempt the connection if your private key file is readable by other local users, it treats that as a security violation and won’t proceed regardless of whether the key itself is valid. The server never even sees the attempt.

The fix is a single command:

# Fix .pem file permissions before SSH
chmod 400 ~/.ssh/s2-keypair.pem

That sets the file to owner-read-only, which is what SSH requires before it will trust the key. I reran the connection:

# SSH into the instance
ssh -i ~/.ssh/s2-keypair.pem ubuntu@<public-ip>

and got the Ubuntu shell prompt. I sat there for a second longer than necessary, genuinely registering that I was now inside a computer running in a data center in Mumbai, several thousand kilometers from where I was sitting.

Deploying the FastAPI app

With a shell open, the actual deploy was short. Update packages, install Python’s venv and pip tooling, create a project folder and a virtual environment inside it:

# On the EC2 instance — setup
sudo apt update
sudo apt install python3-pip python3-venv -y
mkdir ~/fastapi-demo && cd ~/fastapi-demo
python3 -m venv venv
source venv/bin/activate
pip install fastapi uvicorn

The prompt changes to show (venv) once it’s active, which is the only confirmation you get that it worked. Then a single-endpoint app written with nano:

# main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "Hello from EC2!", "status": "running"}

Nothing more than that, the point of Day 2 was the deploy itself, not a FastAPI tour. Running it is where the non-obvious details live:

# Run the app — sudo required for port 80, explicit path required
# because sudo doesn't inherit virtualenv PATH
sudo venv/bin/uvicorn main:app --host 0.0.0.0 --port 80

sudo is required because Linux treats ports below 1024 as privileged, and only root can bind directly to port 80. --host 0.0.0.0 is required because Uvicorn’s default host is 127.0.0.1, which only accepts connections originating from the machine itself, so without that flag the app would be running but completely unreachable from outside the instance, which would have left me confused for a while if I’d skipped it. And it has to be sudo venv/bin/uvicorn, not just sudo uvicorn, because sudo runs with its own environment and doesn’t inherit your activated virtualenv’s PATH, so the bare command would either fail or silently run a different uvicorn entirely.

The moment it actually worked

I opened a browser on my phone, typed the instance’s public IP into the address bar, and got back:

{ "message": "Hello from EC2!", "status": "running" }

That’s the whole payoff, four words and a status field, but the path it took to get there is the part that mattered to me. My phone’s browser sent a request out over the internet to that public IP, AWS’s security group checked whether port 80 was allowed from my source, confirmed it was, and forwarded the connection to Uvicorn, which handed it to FastAPI, which ran my function and sent the response back the same way it came. None of that is new information in the abstract, I could have described that flow before today. But having built and watched every piece of it myself, instead of treating “the backend” as a base URL that already exists somewhere, made the whole thing click in a way it hadn’t before.

The IP problem nobody warns you about clearly enough

Here’s the part I’d have appreciated someone telling me plainly before I hit it myself. Stopping an EC2 instance and starting it again gives it a brand new public IP address. The SSH command I’d been using, with that IP hardcoded, was dead the next time I came back to it. And if my own home IP had changed in the meantime too, which happens more often than I’d assumed, the security group’s “My IP” rule for SSH would also be stale, blocking me out even with the right key and the right command.

This is a known, real annoyance, and the actual fix is an Elastic IP, a static address you attach to an instance so it doesn’t change across stops and starts. That’s a topic for a later day. For now, the workaround is small: check your current public IP,

# Check your current public IP (useful when security group
# "My IP" rule needs updating after IP change)
curl https://checkip.amazonaws.com

then update the SSH rule’s source in the console, where the “My IP” option in the dropdown auto-detects your current address for you. It’s a twenty second fix once you know to look for it, mildly annoying the first time you don’t.

Closing

I stopped the instance rather than terminating it when I was done for the day, and that distinction matters enough to state plainly. Stopping means no compute charges while it’s off, and your disk and configuration are preserved exactly as you left them. Terminating means the instance is gone permanently, no resuming it later. I want this exact setup available again next time, so stopped is what I wanted.

Provisioning a server, SSHing into it, and getting a real app running behind a security group turned out to be genuinely less intimidating than it sounds from the outside. Every friction point I hit today, the chmod permissions, the host binding, the sudo PATH issue, had a specific and findable cause once I stopped and actually read the error instead of just retrying the command. Day 3 is S3, static website hosting with no server involved at all, and private buckets accessed through presigned URLs.