July 15, 2026
AWS Day 4: Spinning Up an RDS Instance and Connecting It to My App
By the end of Day 2 I had a FastAPI hello-world running on an EC2 instance, reachable from anywhere. Day 3 added S3 into the mix, static hosting and presigned URLs, none of it touching the EC2 box at all. Day 4 is the day the app needs to actually remember something. A hello-world endpoint doesn’t need a database, but the moment you want to store a visitor’s message, a user’s record, anything that survives past a single request, you need somewhere for that data to live.
The tempting shortcut here was obvious: just wire up SQLite locally, get something working, and deal with a real database “later.” I talked myself out of that pretty quickly. SQLite is a file sitting next to your code. It doesn’t teach you anything about the actual hard part of running a database in the cloud, which has nothing to do with SQL and everything to do with networking. How does one machine talk to another machine that’s supposed to be unreachable from the rest of the internet? That question doesn’t exist if your database is just a file on the same disk as your app.
So Day 4 skips straight to RDS, AWS’s managed Postgres service. In one sentence, RDS is Postgres running on infrastructure AWS patches, backs up, and keeps available, and all you do is point your app at an endpoint and connect. That sentence undersells how much of the actual work has nothing to do with Postgres itself. The interesting part, and the part that actually went wrong for me twice today, is getting a security group to let one specific machine talk to another and no one else.
Getting the security model backwards, initially
My first instinct when I saw the RDS security group setup was to reach for the exact same move I made on Day 2 for SSH: open port 5432 to my own IP address. It’s the pattern I already had in my head, since it worked fine for locking down SSH to just me.
It’s wrong here for reasons that took me a minute to actually think through instead of just pattern-matching to what worked last time. My IP changes. Every time it does, I’d be back in the console updating a rule, the exact same annoyance Day 2 already taught me about with the SSH “My IP” rule going stale. But there’s a bigger problem underneath that one: an IP-based rule for a database means the database’s endpoint is, in principle, reachable by anything on the internet that happens to be sitting at an allowed IP. You’re trusting a location, not an identity.
The model that actually works is security-group-to-security-group referencing, and once it clicked it reframed how I think about “private” in cloud infrastructure entirely. Instead of saying “allow traffic from this IP address,” you say “allow traffic from anything carrying this security group.” Practically, that means the RDS security group’s inbound rule doesn’t list an IP at all. It lists the EC2 instance’s security group as the source. Only traffic originating from something wearing that specific security group is allowed in, and it doesn’t matter what IP that traffic is coming from, because the EC2 instance could restart, get a new address, move regions in theory, and the rule would still hold as long as it’s still carrying the same security group.
Here’s the shape of it:
EC2 instance RDS instance
security group: sg-ec2 security group: sg-rds
| |
| inbound rule on sg-rds: |
| allow port 5432 FROM sg-ec2 ---->|
| |
(any IP, any region-internal route) (only reachable by traffic
carrying sg-ec2)
The concept worth sitting with is that “private” in AWS doesn’t mean “password protected.” Postgres still has a username and password, sure, but that’s a separate layer. Private here means the database is not reachable from outside the network at all, full stop, regardless of whether you have the right credentials. You could have the exact right password and it wouldn’t matter, because the packet never gets there in the first place. That’s a genuinely different security model than anything I’d built as a frontend developer, where “protected” almost always meant “behind a login,” never “physically cannot be reached.”
Creating the RDS instance, and mistake number one
The console walked me through a template choice, and it’s worth flagging that the option I saw was labeled “Sandbox” rather than “Free tier,” which is what most existing tutorials and screenshots show. Same underlying idea, just a different label depending on account type.
A few settings mattered enough that I want to call them out explicitly, because getting them wrong is easy and the console’s defaults actively push you in the wrong direction. The instance class defaulted to db.m7g.large, which is not a small learning instance, it’s a genuinely sized production instance. I changed it to db.t3.micro, the small burstable class that’s appropriate for a hello-world database. Storage I left at 20GB using gp2 rather than gp3, mostly for cost predictability, and I was careful about the input box because it’s disturbingly easy to type an extra zero and end up provisioning 200GB instead of 20. Public access I set to No, which is the entire point of everything in the previous section. And I created a new security group for this, s4-rds-sg, deliberately leaving it with no inbound rules yet so I’d wire it up by hand afterward and actually understand what I was doing instead of accepting whatever the wizard defaulted to.
I finished the whole setup, felt reasonably good about it, and went to go wire up the security group rule I’d just spent a section explaining to myself. That’s when I noticed the RDS instance had been created in us-east-1, Virginia, while my EC2 instance from Day 2 has been sitting in ap-south-1, Mumbai, this entire time.
This isn’t a latency inconvenience I could shrug off. Security groups are scoped to a region. There is no way to reference a Mumbai EC2 security group as the source for a Virginia RDS security group’s inbound rule, the console won’t even let you select it, because the two resources don’t exist in the same regional context at all. The entire SG-to-SG mechanism I’d just built a mental model around simply doesn’t function across that boundary.
The fix was tedious rather than complicated: delete the Virginia instance, recreate it from scratch in Mumbai. The lesson underneath it is the one that actually stuck. The region selector in the top right of the AWS console does not change automatically when you open a new service, and it’s easy to assume it’s still set to wherever you left it when you were last in the EC2 console. Check it before creating anything, every time, not just the first time.
Wiring up the security group, properly this time
With the RDS instance finally sitting in ap-south-1 and available, the actual rule was short to write once I knew what it needed to say. In the s4-rds-sg security group, under Inbound rules, I added a new rule with type PostgreSQL, which auto-fills port 5432, and for the source I chose Custom and pasted in the EC2 instance’s security group ID directly, something that looks like sg-07521f29a162869f2.
Inbound rules for s4-rds-sg:
Type Protocol Port Source
PostgreSQL TCP 5432 sg-07521f29a162869f2 (your EC2's SG)
One thing worth flagging: the console had already created a default inbound rule on this security group using my current IP address, the same shortcut I talked myself out of earlier. I deleted that rule rather than leaving it alongside the SG reference. Adding the SG-to-SG rule on top of an existing IP rule doesn’t give you the SG-to-SG security model, it gives you both, which means the IP-based hole is still sitting open.
While I was in and around IAM and instance permissions during this session, I also ran into IMDSv2 for the first time. I’d seen older tutorials show a plain curl http://169.254.169.254/latest/meta-data/... to pull instance metadata, and running that as-is on my instance just returned a 401. Newer instances default to requiring a token-based request first, you fetch a session token from the metadata service and then attach it as a header on the actual metadata request. It’s a small thing, but it’s exactly the kind of gap that makes an older tutorial’s copy-pasted command silently fail for no reason a beginner would guess.
Proving the connection works before touching any app code
Before changing a single line of the FastAPI app, I wanted to isolate the networking and credentials question entirely from anything application-level. So I SSHed into the EC2 instance and wrote a small throwaway script.
# test_db_connection.py — delete after testing, has plaintext password
import psycopg2
conn = psycopg2.connect(
host='your-rds-endpoint.ap-south-1.rds.amazonaws.com',
port=5432,
database='postgres',
user='postgres',
password='your_password_here',
sslmode='require' # not 'verify-full' — that needs a cert bundle download
)
cur = conn.cursor()
cur.execute('SELECT version();')
print(cur.fetchone()[0])
cur.close()
conn.close()
Running python3 test_db_connection.py printed back something like PostgreSQL 18.3 on x86_64-pc-linux-gnu, compiled by..., and that one line told me three separate things were all correct at once: the security group rule was actually letting traffic through, the endpoint hostname was right, and the credentials worked. If the FastAPI app had failed to connect later, this step meant I’d already know it was a code problem, not an infrastructure problem, which is a genuinely useful thing to have ruled out in advance. I deleted the file immediately afterward, since it had a plaintext password sitting in it and there was no reason to leave that lying around on the instance.
Doing environment variables properly
Even for a learning project, hardcoding credentials directly into main.py felt like a habit not worth building, so I set it up with a .env file from the start.
pip install python-dotenv
DB_HOST=your-rds-endpoint.ap-south-1.rds.amazonaws.com
DB_PORT=5432
DB_NAME=postgres
DB_USER=postgres
DB_PASSWORD=your_actual_password
chmod 600 .env
echo ".env" >> .gitignore
chmod 600 restricts the file so other local users on the same machine can’t read it, which is functionally irrelevant on a single-user learning instance but is exactly the habit that matters the moment this pattern shows up on a real shared server. The .gitignore line is the same idea aimed at a different mistake, accidentally committing credentials to a repo. This particular project was never going to end up on GitHub, but the reflex of never letting secrets near version control is worth having regardless of the stakes of any one project.
Updating the FastAPI app
import os
from dotenv import load_dotenv
from fastapi import FastAPI
from sqlalchemy import create_engine, text
load_dotenv()
DB_HOST = os.getenv("DB_HOST")
DB_PORT = os.getenv("DB_PORT")
DB_NAME = os.getenv("DB_NAME")
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DATABASE_URL = f"postgresql+psycopg2://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?sslmode=require"
engine = create_engine(DATABASE_URL)
app = FastAPI()
@app.on_event("startup")
def startup():
with engine.connect() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS visitors (
id SERIAL PRIMARY KEY,
message TEXT NOT NULL,
visited_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.commit()
@app.get("/")
def read_root():
return {"message": "Hello from FastAPI on EC2"}
@app.post("/visit")
def record_visit(message: str = "Hello, RDS!"):
with engine.connect() as conn:
conn.execute(
text("INSERT INTO visitors (message) VALUES (:msg)"),
{"msg": message}
)
conn.commit()
return {"status": "recorded", "message": message}
@app.get("/visits")
def get_visits():
with engine.connect() as conn:
result = conn.execute(text("SELECT id, message, visited_at FROM visitors ORDER BY id DESC"))
rows = [dict(row._mapping) for row in result]
return {"visits": rows}
A few details in there are worth explaining rather than skating past. The @app.on_event("startup") handler runs once when the app boots and creates the table if it doesn’t already exist, which is fine for a learning project but is exactly the kind of thing production apps hand off to a proper migration tool like Alembic instead, since “just recreate the table on every boot” doesn’t scale to real schema changes. The ?sslmode=require on the connection string exists because Postgres 14 and later enforce SSL by default, and this tells the driver to actually use encryption without the added step of verify-full, which additionally checks the server’s certificate against a downloaded certificate bundle. require gets you the encryption without that extra setup, which is a reasonable tradeoff for a learning project even if it’s not the strictest option available. And row._mapping is worth flagging because older tutorials show row._asdict() for turning a SQLAlchemy result row into a dict, and that method doesn’t work the same way anymore under SQLAlchemy 2.x, _mapping is the current equivalent.
Mistake number two: sudo doesn’t know your virtualenv exists
I tried to start the app the same way I remembered doing it on Day 2, roughly:
sudo /usr/bin/python3 -m uvicorn main:app --host 0.0.0.0 --port 80
and got back No module named uvicorn, despite having just installed uvicorn minutes earlier and having used it successfully in this exact project before. The instinct to panic here is understandable but wrong. sudo doesn’t inherit the currently activated virtualenv, it runs using the system Python at /usr/bin/python3, and every package installed via pip into the venv simply doesn’t exist as far as that system Python is concerned. It’s not that uvicorn wasn’t installed, it’s that I’d pointed sudo at a completely different Python interpreter than the one that had it.
The fix is to find the venv’s actual Python binary and point sudo at that explicitly:
which python3
# outputs something like: /home/ubuntu/fastapi-demo/venv/bin/python3
sudo /home/ubuntu/fastapi-demo/venv/bin/python3 -m uvicorn main:app --host 0.0.0.0 --port 80
This is one of those errors that makes you feel a little dumb in the moment it happens, and then turns out to be a genuinely useful thing to understand permanently. sudo and virtualenvs don’t mix automatically, and the fix is always to give sudo the full explicit path to the binary you actually want, rather than trusting it to figure out which Python you meant.
Testing it end to end
# Insert a visit
curl -X POST "http://YOUR_EC2_IP/visit?message=hello+from+day4"
# Read all visits
curl "http://YOUR_EC2_IP/visits"
The second command came back with a JSON list containing the visit I’d just inserted, timestamp and all, which is the moment the whole chain from browser to app to security-group-gated database actually proved itself out. One small pitfall worth flagging on the curl side: don’t nest quotes inside the URL string itself. Something like "...?message="Hello"" will confuse your shell, because the inner quote closes the outer string early and leaves bash sitting there waiting for more input that never comes. Using + in place of spaces, as in message=hello+from+day4, sidesteps the whole problem.
What this actually costs
Worth being honest about this part rather than glossing over it. Partway through this session I discovered that this particular AWS account has no free-tier credits and no legacy 12-month RDS allowance sitting on it, meaning everything here was standard pay-as-you-go from the first minute, not a hypothetical bill I could ignore.
The actual numbers for what I provisioned: db.t3.micro runs around $0.016 an hour for compute, and 20GB of gp2 storage runs around $2.30 a month. The console’s “Estimated monthly costs” figure assumes the instance runs 24 hours a day for a full 730-hour month, which massively overstates what a few hours of learning use actually costs, that part of the bill really is a matter of cents. The storage charge is the one that doesn’t care whether the instance is running, though. It accrues whether the database is actively serving queries or sitting idle, which means the actual discipline worth building is either stopping the instance between sessions to at least halt the compute charge, or deleting it outright to stop everything and recreating it fresh next time. I went with delete for this session.
What actually got built, and what’s next
By the end of the session I had FastAPI on EC2 talking to Postgres on RDS, gated by a security group rule that references another security group instead of an IP address, which is the actual mechanism real cloud infrastructure runs on rather than a simplified version of it. The three things that went wrong along the way, the wrong region, the oversized default instance class, and the sudo path mismatch, ended up being the most instructive parts of the whole day. None of them were catastrophic, and all three were fixable within minutes once I actually understood what was happening instead of just retrying the same command.
Next up is an IAM Role paired with presigned PUT URLs, letting a frontend upload directly to S3 without routing the file through my own server first, the upload-side counterpart to the presigned GET URLs from Day 3. It builds directly on the security group and IAM mental models this session forced me to actually get right, rather than starting that concept from scratch.